Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI
This commit is contained in:
1822 files changed
+24568
-21314
No files matched your search
@@ -0,0 +1,129 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/layout"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
var res BreakpointListResult
|
||||
res.Element = CodePanel(c).Disabled(opts.Disabled).Label(core.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, core.Msg(c, "breakpoint_list.empty")).Padding(u * 4)
|
||||
return
|
||||
}
|
||||
layout.ScrollArea(c, nil, layout.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
|
||||
input.Checkbox(c, &on, "", input.CheckboxOptions{Disabled: opts.Disabled}).Label(fmt.Sprintf(core.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 core.IconAction(c, icons.Must("funnel"), fmt.Sprintf(core.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 := input.TextInput(c, &edit.draft, input.TextInputOptions{Placeholder: core.Msg(c, "breakpoint_list.placeholder"), Label: core.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(core.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 := core.Tokens(c)
|
||||
d := core.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,116 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
type breakpointProbe struct {
|
||||
bps []Breakpoint
|
||||
disabled bool
|
||||
toggled, edited, opened []string
|
||||
}
|
||||
|
||||
func (p *breakpointProbe) view(c *ui.Context) {
|
||||
core.Use(c, core.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,141 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/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) core.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 := core.Density(c)
|
||||
var res core.ChoiceResult
|
||||
res.Element = CodePanel(c).Disabled(opts.Disabled)
|
||||
disabled := res.Element.IsDisabled()
|
||||
res.Element.Children(func() {
|
||||
if len(threads) == 0 {
|
||||
CodeCaption(c, core.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 = s.Current, core.NewChoiceResult(res.Element, 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 core.IconAction(c, icons.Must(chev), fmt.Sprintf(core.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(core.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,106 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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,170 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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: core.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(core.FontSize(c, CodeSize)).SingleLine()
|
||||
}
|
||||
|
||||
// CodeCaption is muted caption text.
|
||||
func CodeCaption(c *ui.Context, s string) *ui.Element {
|
||||
return ui.Text(c, s).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(core.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 := core.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 := core.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 := core.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 := core.Tokens(c)
|
||||
u := core.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 := core.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 := core.Density(c)
|
||||
restore := data.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 := core.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(core.FontSize(c, 11)).FontWeight(700).TextColor(col)
|
||||
})
|
||||
}
|
||||
|
||||
// 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: core.FontSize(c, CodeSize)})
|
||||
return w
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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) core.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 := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var res core.ChoiceResult
|
||||
res.Element = CodePanel(c).Disabled(opts.Disabled)
|
||||
res.Element.Children(func() {
|
||||
if len(lines) == 0 {
|
||||
CodeCaption(c, core.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 := data.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 := core.Msg(c, "code_viewer.fold")
|
||||
if s.Folded.Has(li) {
|
||||
label = core.Msg(c, "code_viewer.unfold")
|
||||
}
|
||||
chev := "chevron-down"
|
||||
if s.Folded.Has(li) {
|
||||
chev = "chevron-right"
|
||||
}
|
||||
if core.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 = core.NewChoiceResult(res.Element, 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(core.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,152 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.Settings{Mode: core.Light})
|
||||
k := core.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,121 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// 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() {
|
||||
input.SearchInput(c, &s.Query, input.SearchInputOptions{Disabled: opts.Disabled, Placeholder: core.Msg(c, "command_history.search")}).Element.Grow(1)
|
||||
})
|
||||
commandHistoryFilter(entries, s)
|
||||
if len(s.shown) == 0 {
|
||||
text := core.Msg(c, "command_history.empty")
|
||||
if len(entries) > 0 {
|
||||
text = core.Msg(c, "command_history.none")
|
||||
}
|
||||
CodeCaption(c, text).Padding(core.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 core.IconAction(c, icons.Must("refresh-cw"), fmt.Sprintf(core.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 := core.Tokens(c)
|
||||
ui.Column(c).Grow(1).MinWidth(0).Children(func() {
|
||||
CodeMono(c, e.Command)
|
||||
ui.Row(c).Gap(core.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(core.Msg(c, "command_history.exit"), e.ExitCode)).TextColor(k.Danger)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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,132 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
b := anchor.Bounds()
|
||||
core.OverlayLayer(c, open)
|
||||
res.Element = ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
|
||||
core.OverlayStyle(c, panel, overlay.OverlayAnchored(c, panel, b))
|
||||
panel.Margin(4, 0, 0, 0).Padding(d.Unit()).Width(overlay.PopoverWide(c, 360)).MaxHeight(overlay.PopoverRoom(c, b)).
|
||||
Label(core.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 := data.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(core.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,106 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
in := input.TextInput(c, &p.text, input.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,144 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
|
||||
)
|
||||
|
||||
// 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 = navigation.Toolbar(c, navigation.ToolbarOptions{Label: core.Msg(c, "debug_toolbar.label")}, func() {
|
||||
first := DebugContinue
|
||||
if state == DebugRunning {
|
||||
first = DebugPause
|
||||
}
|
||||
for _, a := range []DebugAction{first, DebugStepOver, DebugStepInto, DebugStepOut, DebugRestart, DebugStop} {
|
||||
name := core.Msg(c, debugKeys[a])
|
||||
if a == DebugContinue && state == DebugStopped {
|
||||
name = core.Msg(c, "debug_toolbar.start")
|
||||
}
|
||||
on := DebugEnabled(state, a) && !opts.Disabled
|
||||
b := navigation.ToolbarButton(c, name, navigation.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,115 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func debugTester(state *DebugState, disabled bool, got *[]DebugAction) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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,2 @@
|
||||
// Package code holds MujicaUI terminal and code views.
|
||||
package code
|
||||
@@ -0,0 +1,278 @@
|
||||
package code_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/code"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
func ExampleTerminal() {
|
||||
var term code.TerminalState
|
||||
term.Write("\x1b[1m~/court\x1b[0m $ ls\r\n\x1b[34mdecrees\x1b[0m ledger.txt\r\n$ ")
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
code.Terminal(c, &term, code.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 := []code.TerminalTab{{ID: "a", Title: "zsh"}, {ID: "b", Title: "build", Busy: true}}
|
||||
active := "a"
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := code.TerminalTabs(c, &tabs, &active, code.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 code.TerminalState
|
||||
term.Write("build ok\r\nerror: missing seal\r\n")
|
||||
var search code.TerminalSearchState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Gap(8).Children(func() {
|
||||
code.TerminalSearch(c, &term, &search, code.TerminalSearchOptions{})
|
||||
code.Terminal(c, &term, code.TerminalOptions{}).Height(160)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCommandHistory() {
|
||||
history := []code.HistoryEntry{{Command: "git status"}, {Command: "go test ./...", ExitCode: 1}}
|
||||
var state code.CommandHistoryState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := code.CommandHistory(c, history, &state, code.CommandHistoryOptions{})
|
||||
r.Element.Height(240)
|
||||
if r.Replayed != nil {
|
||||
fmt.Println("run", r.Replayed.Command)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleLogViewer() {
|
||||
lines := []code.LogLine{{Level: code.LogInfo, Text: "court opened"}, {Level: code.LogError, Text: "seal missing"}}
|
||||
var state code.LogViewerState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
code.LogViewer(c, lines, &state, code.LogViewerOptions{}).Element.Height(240)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleProcessList() {
|
||||
procs := []code.Process{{PID: 7, Name: "archivist", CPU: 42, Memory: 50 << 20}, {PID: 9, Name: "herald", CPU: 3, Memory: 8 << 20}}
|
||||
var state code.ProcessListState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := code.ProcessList(c, procs, &state, code.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 code.CodeViewerState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
code.CodeViewer(c, lines, &state, code.CodeViewerOptions{Language: "go"}).Element.Height(240)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMinimap() {
|
||||
lines := []string{"package court", "", "func Seal() {}", "func Herald() {}"}
|
||||
view := code.MinimapView{Top: 0, Visible: 2}
|
||||
draw := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if code.Minimap(c, lines, &view, code.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 := code.FindState{Query: "seal", Replacement: "signet", ShowReplace: true}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if code.FindWidget(c, &text, &state, code.FindWidgetOptions{}).Replaced > 0 {
|
||||
fmt.Println(text)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSearchPanel() {
|
||||
results := []code.SearchFileResult{{Path: "court/seal.go", Matches: []code.SearchMatch{{Line: 12, Text: "func Seal() {", Start: 5, End: 9}}}}
|
||||
state := code.SearchPanelState{Query: "seal"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := code.SearchPanel(c, results, &state, code.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 := []code.Problem{{Severity: code.ProblemError, File: "court/seal.go", Line: 12, Col: 5, Message: "undefined: Decree"}}
|
||||
var state code.ProblemsPanelState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := code.ProblemsPanel(c, problems, &state, code.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 := []code.OutputChannel{{Name: "Build", Lines: []string{"compiling court", "\x1b[32mok\x1b[0m"}}}
|
||||
var state code.OutputPanelState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := code.OutputPanel(c, channels, &state, code.OutputPanelOptions{})
|
||||
r.Element.Height(240)
|
||||
if r.Cleared != "" {
|
||||
channels[state.Channel].Lines = nil
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSymbolOutline() {
|
||||
symbols := []code.Symbol{{Name: "Court", Kind: code.SymbolType, Line: 3, EndLine: 40, Children: []code.Symbol{
|
||||
{Name: "Seal", Kind: code.SymbolMethod, Line: 10, EndLine: 20},
|
||||
}}}
|
||||
var state code.SymbolOutlineState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := code.SymbolOutline(c, symbols, &state, code.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 := []code.Completion{{Label: "Seal", Kind: code.SymbolMethod, Detail: "func()"}, {Label: "Search", Kind: code.SymbolFunction}}
|
||||
var state code.CompletionMenuState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
in := input.TextInput(c, &text, input.TextInputOptions{Label: "Code"})
|
||||
if r := code.CompletionMenu(c, in.Input, &open, items, &state, code.CompletionMenuOptions{}); r.Accepted != nil {
|
||||
text = r.Accepted.Label
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDebugToolbar() {
|
||||
state := code.DebugPaused
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
switch code.DebugToolbar(c, state, code.DebugToolbarOptions{}).Action {
|
||||
case code.DebugContinue:
|
||||
state = code.DebugRunning
|
||||
case code.DebugStop:
|
||||
state = code.DebugStopped
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBreakpointList() {
|
||||
bps := []code.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := code.BreakpointList(c, &bps, code.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 := []code.DebugThread{{ID: "t1", Name: "main", Frames: []code.StackFrame{
|
||||
{ID: "f1", Name: "court.Seal", File: "court/seal.go", Line: 14},
|
||||
{ID: "f2", Name: "main.main", File: "main.go", Line: 9},
|
||||
}}}
|
||||
state := code.CallStackState{Current: "f1"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := code.CallStack(c, threads, &state, code.CallStackOptions{})
|
||||
r.Element.Height(200)
|
||||
if r.Changed() {
|
||||
fmt.Println("show frame", state.Current)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleVariablesPanel() {
|
||||
vars := []code.Variable{{Name: "decree", Value: "{…}", Children: []code.Variable{{Name: "id", Value: "42", Changed: true}}}, {Name: "count", Value: "7"}}
|
||||
var state code.VariablesPanelState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := code.VariablesPanel(c, vars, &state, code.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 code.HexViewerState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
code.HexViewer(c, data, &state, code.HexViewerOptions{}).Element.Height(160)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleFlamegraph() {
|
||||
root := &code.FlameNode{Name: "main", Value: 100, Children: []code.FlameNode{
|
||||
{Name: "court.Seal", Value: 60}, {Name: "ledger.Write", Value: 30},
|
||||
}}
|
||||
var state code.FlamegraphState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
code.Flamegraph(c, root, &state, code.FlamegraphOptions{Unit: "ms"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// 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 := core.Tokens(c)
|
||||
u := core.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 core.IconAction(c, icons.Must(chev), core.Msg(c, "find_widget.toggleReplace")).Clicked() && !disabled {
|
||||
s.ShowReplace = !s.ShowReplace
|
||||
}
|
||||
}
|
||||
r := input.SearchInput(c, &s.Query, input.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 input.Toggle(c, o.on, o.short, input.ToggleOptions{Label: core.Msg(c, o.key), Disabled: opts.Disabled}).Tooltip(core.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(core.Msg(c, "find_widget.invalid"), s.err)).TextColor(k.Danger).Role(ui.RoleStatus).
|
||||
FontSize(core.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 := input.TextInput(c, &s.Replacement, input.TextInputOptions{Placeholder: core.Msg(c, "find_widget.replace"), Label: core.Msg(c, "find_widget.replace"), Disabled: opts.Disabled})
|
||||
in.Element.Grow(1).MinWidth(0)
|
||||
none := len(s.matches) == 0
|
||||
if (input.Button(c, core.Msg(c, "find_widget.replaceOne"), input.ButtonOptions{Variant: input.Secondary, Disabled: none || opts.Disabled}).Clicked() || in.Submitted()) && !none && !disabled {
|
||||
res.Replaced = findReplace(text, s, false)
|
||||
}
|
||||
if input.Button(c, core.Msg(c, "find_widget.replaceAll"), input.ButtonOptions{Variant: input.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,119 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func findTester(text *string, s *FindState, opts FindWidgetOptions, replaced *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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,279 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 data.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) core.ChoiceResult {
|
||||
if s == nil {
|
||||
panic("mujica: Flamegraph needs a state")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var res core.ChoiceResult
|
||||
res.Element = CodePanel(c).Disabled(opts.Disabled)
|
||||
res.Element.Children(func() {
|
||||
CodeHeader(c, func() {
|
||||
input.SearchInput(c, &s.Query, input.SearchInputOptions{Placeholder: core.Msg(c, "flamegraph.search"), Disabled: opts.Disabled}).Element.Grow(1)
|
||||
})
|
||||
if root == nil || opts.Status != data.DataReady {
|
||||
ui.Column(c).Height(flameRow * 6).Children(func() {
|
||||
res = core.NewChoiceResult(res.Element, data.DataPlaceholder(c, opts.Status, opts.Error, core.Msg(c, "flamegraph.empty"), core.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(core.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 = core.NewChoiceResult(res.Element, 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(), core.FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
readout := s.Focus
|
||||
if *hover != nil {
|
||||
readout = *hover
|
||||
}
|
||||
text := flameReadout(c, root, readout, opts.Unit)
|
||||
ui.Text(c, text).FontSize(core.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 core.Msg(c, "flamegraph.hint")
|
||||
}
|
||||
share := 100 * n.Value / max(root.Value, 1e-12)
|
||||
return fmt.Sprintf(core.Msg(c, "flamegraph.readout"), n.Name, n.Value, unit, share)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/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) {
|
||||
core.Use(c, core.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: data.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,24 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
|
||||
)
|
||||
|
||||
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
|
||||
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
|
||||
}
|
||||
|
||||
func snapshotTheme(c *ui.Context, mode string) {
|
||||
m := core.Light
|
||||
if mode == "dark" {
|
||||
m = core.Dark
|
||||
}
|
||||
core.Use(c, core.Settings{Mode: m})
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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) core.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 := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
cw := codeTextWidth(c, strings.Repeat("0", hexPerRow*3)) / (hexPerRow * 3)
|
||||
var res core.ChoiceResult
|
||||
frame := CodePanel(c).Focusable().Disabled(opts.Disabled).Label(core.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 = core.NewChoiceResult(res.Element, 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(core.Msg(c, "hex_viewer.at"), s.Cursor, b-a))
|
||||
frame.Children(func() {
|
||||
if n == 0 {
|
||||
CodeCaption(c, core.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 := core.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,117 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
type hexProbe struct {
|
||||
data []byte
|
||||
s HexViewerState
|
||||
disabled bool
|
||||
changed int
|
||||
}
|
||||
|
||||
func (p *hexProbe) view(c *ui.Context) {
|
||||
core.Use(c, core.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,132 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/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) core.ChoiceResult {
|
||||
if s == nil {
|
||||
panic("mujica: LogViewer needs a state")
|
||||
}
|
||||
if !s.started {
|
||||
s.started, s.Follow = true, true
|
||||
}
|
||||
var res core.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 input.Toggle(c, &on, LogViewerName(c, lv), input.ToggleOptions{Disabled: opts.Disabled}).Changed() {
|
||||
s.Hidden[lv] = !on
|
||||
res = core.NewChoiceResult(res.Element, true)
|
||||
slog.Debug("mujica: log_viewer level", "level", lv, "shown", on)
|
||||
}
|
||||
}
|
||||
input.SearchInput(c, &s.Query, input.SearchInputOptions{Disabled: opts.Disabled, Placeholder: core.Msg(c, "log_viewer.highlight")}).Element.Grow(1).MinWidth(0)
|
||||
was := s.Follow
|
||||
if input.Toggle(c, &s.Follow, core.Msg(c, "log_viewer.follow"), input.ToggleOptions{Disabled: opts.Disabled}).Changed() {
|
||||
res = core.NewChoiceResult(res.Element, 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, core.Msg(c, "log_viewer.empty")).Padding(core.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 := core.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 core.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 := core.Tokens(c)
|
||||
d := core.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(core.FontSize(c, CodeSize)).
|
||||
FontWeight(700).TextColor(col).SingleLine().Shrink(0)
|
||||
ui.RichText(c, CodeMatchSpans(c, l.Text, query)...).Font(theme.MonoFont).FontSize(core.FontSize(c, CodeSize)).
|
||||
SingleLine().Grow(1).MinWidth(0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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)
|
||||
})
|
||||
})
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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) core.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 := core.Tokens(c)
|
||||
w := opts.Width
|
||||
if w == 0 {
|
||||
w = 96
|
||||
}
|
||||
n := len(lines)
|
||||
var res core.ChoiceResult
|
||||
move := func(top int) {
|
||||
top = max(0, min(top, n-view.Visible))
|
||||
if top != view.Top {
|
||||
view.Top = top
|
||||
res = core.NewChoiceResult(res.Element, 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(core.Msg(c, "minimap.label")).Description(fmt.Sprintf(core.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,118 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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,95 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/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([]input.SelectOption[int], len(channels))
|
||||
for i, ch := range channels {
|
||||
options[i] = input.SelectOption[int]{Value: i, Label: ch.Name}
|
||||
}
|
||||
if input.Select(c, &s.Channel, options, input.SelectOptions{Label: core.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 input.Toggle(c, &s.Follow, core.Msg(c, "log_viewer.follow"), input.ToggleOptions{Disabled: opts.Disabled}).Changed() && s.Follow && !was {
|
||||
s.rows.ScrollToEnd()
|
||||
}
|
||||
if core.IconAction(c, icons.Must("trash-2"), core.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, core.Msg(c, "output_panel.empty")).Padding(core.Density(c).Unit() * 4)
|
||||
return
|
||||
}
|
||||
d := core.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(core.FontSize(c, CodeSize)).Grow(1).MinWidth(0)
|
||||
})
|
||||
}).Grow(1).MinHeight(d.ControlHeight()*3).Padding(d.Unit(), 0)
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
type outputProbe struct {
|
||||
channels []OutputChannel
|
||||
s OutputPanelState
|
||||
disabled bool
|
||||
changed int
|
||||
cleared []string
|
||||
}
|
||||
|
||||
func (p *outputProbe) view(c *ui.Context) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.Settings{Mode: core.Light})
|
||||
ok = core.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,196 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// 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(core.Msg(c, problemsKeys[sv]), counts[sv])
|
||||
if input.Toggle(c, &on, name, input.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, core.Msg(c, "problems_panel.empty")).Padding(core.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 := core.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 core.IconAction(c, icons.Must(chev), fmt.Sprintf(core.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(core.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(core.Msg(c, "problems_panel.at"), p.Line, p.Col)).FontFeatures("tnum").Shrink(0)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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,188 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"github.com/ZacharyZhang-NY/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 := core.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, core.Msg(c, "process_list.empty")).Padding(core.Density(c).Unit() * 4)
|
||||
return
|
||||
}
|
||||
cols := []ui.TableColumn{
|
||||
{Title: core.Msg(c, "process_list.name"), ID: "name", Sortable: true, MinWidth: 120},
|
||||
{Title: core.Msg(c, "process_list.pid"), ID: "pid", Width: 80, Align: ui.End, Sortable: true},
|
||||
{Title: core.Msg(c, "process_list.cpu"), ID: "cpu", Width: 90, Align: ui.End, Sortable: true},
|
||||
{Title: core.Msg(c, "process_list.memory"), ID: "memory", Width: 100, Align: ui.End, Sortable: true},
|
||||
{Title: core.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 := data.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(core.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 core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "process_list.end"), p.Name)).Clicked() {
|
||||
ask(p)
|
||||
}
|
||||
}
|
||||
}).Grow(1).MinHeight(core.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 := overlay.AlertDialog(c, &s.confirm, overlay.AlertDialogOptions{
|
||||
Title: fmt.Sprintf(core.Msg(c, "process_list.confirm"), s.ending.Name, s.ending.PID),
|
||||
Description: core.Msg(c, "process_list.warning"),
|
||||
ConfirmLabel: core.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,126 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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,205 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// 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 data.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 := core.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 = input.SearchInput(c, &s.Query, input.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 := input.TextInput(c, f.v, input.TextInputOptions{Placeholder: core.Msg(c, f.key), Label: core.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 != data.DataReady {
|
||||
text := core.Msg(c, "search_panel.idle")
|
||||
if s.Query != "" {
|
||||
text = core.Msg(c, "search_panel.none")
|
||||
}
|
||||
res.Changed = data.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(core.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 := core.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 core.IconAction(c, icons.Must(chev), fmt.Sprintf(core.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,132 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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: data.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,238 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var res SymbolOutlineResult
|
||||
res.Element = CodePanel(c).Disabled(opts.Disabled)
|
||||
disabled := res.Element.IsDisabled()
|
||||
res.Element.Children(func() {
|
||||
CodeHeader(c, func() {
|
||||
input.SearchInput(c, &s.Query, input.SearchInputOptions{Placeholder: core.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, core.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 := data.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,109 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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,309 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"runtime"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
font := codeFont(c)
|
||||
cw, ch, base := terminalMetrics(font)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.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
|
||||
}
|
||||
internal.Rule(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}
|
||||
internal.Rule(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)
|
||||
internal.Rule(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,80 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// 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) core.ChoiceResult {
|
||||
if term == nil || s == nil {
|
||||
panic("mujica: TerminalSearch needs a terminal and a state")
|
||||
}
|
||||
term.init()
|
||||
fresh := terminalSearchRun(term, s)
|
||||
r := input.SearchInput(c, &s.Query, input.SearchInputOptions{Find: true, Matches: len(term.matches), Current: &s.Current,
|
||||
Disabled: opts.Disabled, Label: core.Msg(c, "terminal_search.label")})
|
||||
var res core.ChoiceResult
|
||||
res.Element = r.Element
|
||||
if r.Changed() {
|
||||
s.Current = 0
|
||||
terminalSearchRun(term, s)
|
||||
res = core.NewChoiceResult(res.Element, true)
|
||||
}
|
||||
if r.Moved() {
|
||||
res = core.NewChoiceResult(res.Element, 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,107 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func terminalSearchTester(term *TerminalState, s *TerminalSearchState, opts TerminalSearchOptions, moves *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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 code
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/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,184 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.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(core.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, core.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 := overlay.AlertDialog(c, &st.confirm, overlay.AlertDialogOptions{Title: fmt.Sprintf(core.Msg(c, "terminal_tabs.confirm"), title),
|
||||
Description: core.Msg(c, "terminal_tabs.busy"), ConfirmLabel: core.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 := core.Tokens(c)
|
||||
d := core.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(core.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, core.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 := input.TextInput(c, &st.draft, input.TextInputOptions{Label: core.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 core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "terminal_tabs.closeTab"), t.Title)).Clicked() && !disabled {
|
||||
askClose(t)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
type terminalTabsProbe struct {
|
||||
tabs []TerminalTab
|
||||
active string
|
||||
disabled bool
|
||||
changed int
|
||||
renamed, closed []string
|
||||
}
|
||||
|
||||
func (p *terminalTabsProbe) view(c *ui.Context) {
|
||||
core.Use(c, core.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,194 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Variable is a debugged value; Changed marks new values.
|
||||
type Variable struct {
|
||||
Name, Value, Type string
|
||||
Changed bool
|
||||
Children []Variable
|
||||
}
|
||||
|
||||
// VariablesPanelState is the open rows, choice and edit.
|
||||
type VariablesPanelState struct {
|
||||
outline ui.OutlineState[string]
|
||||
cursor int
|
||||
byPath map[string]*Variable
|
||||
kids map[string][]string
|
||||
roots []string
|
||||
data *Variable
|
||||
n int
|
||||
editing string
|
||||
draft string
|
||||
started bool
|
||||
}
|
||||
|
||||
// VariablesPanelOptions configure a VariablesPanel.
|
||||
type VariablesPanelOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// VariableEdit is a typed value for a dotted Path.
|
||||
type VariableEdit struct{ Path, Value string }
|
||||
|
||||
// VariablesPanelResult carries a committed edit.
|
||||
type VariablesPanelResult struct {
|
||||
Element *ui.Element
|
||||
Edited *VariableEdit
|
||||
}
|
||||
|
||||
// VariablesPanel shows an editable variable tree.
|
||||
func VariablesPanel(c *ui.Context, vars []Variable, s *VariablesPanelState, opts VariablesPanelOptions) VariablesPanelResult {
|
||||
if s == nil {
|
||||
panic("mujica: VariablesPanel needs a state")
|
||||
}
|
||||
if !s.started {
|
||||
s.started, s.cursor = true, -1
|
||||
}
|
||||
variablesIndex(vars, s)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var res VariablesPanelResult
|
||||
res.Element = CodePanel(c).Disabled(opts.Disabled)
|
||||
disabled := res.Element.IsDisabled()
|
||||
if disabled {
|
||||
s.editing = ""
|
||||
}
|
||||
res.Element.Children(func() {
|
||||
if len(vars) == 0 {
|
||||
CodeCaption(c, core.Msg(c, "variables_panel.empty")).Padding(d.Unit() * 4)
|
||||
return
|
||||
}
|
||||
s.outline.List.Selected = &s.cursor
|
||||
s.outline.List.Label = func(i int) string { return s.byPath[s.outline.Item(i)].Name }
|
||||
children := func(p string) []string { return s.kids[p] }
|
||||
restore := data.DataSelectTheme(c, 0)
|
||||
e := ui.Outline(c, &s.outline, s.roots, children, func(p string) {
|
||||
v := s.byPath[p]
|
||||
ui.Row(c).MinHeight(d.ControlHeight() - 2*c.Theme().Space(1)).Gap(d.Unit() * 2).AlignItems(ui.Center).Grow(1).MinWidth(0).Children(func() {
|
||||
CodeMono(c, v.Name).TextColor(k.Info).Shrink(0)
|
||||
if s.editing == p {
|
||||
in := input.TextInput(c, &s.draft, input.TextInputOptions{Label: core.Msg(c, "variables_panel.value")})
|
||||
in.Element.Grow(1).MinWidth(0)
|
||||
in.Input.AutoFocus()
|
||||
switch {
|
||||
case in.Input.Shortcut(0, ui.KeyEscape):
|
||||
s.editing = ""
|
||||
case in.Submitted() && !disabled:
|
||||
res.Edited = &VariableEdit{Path: p, Value: s.draft}
|
||||
s.editing = ""
|
||||
slog.Debug("mujica: variables_panel edit", "path", p)
|
||||
}
|
||||
return
|
||||
}
|
||||
variablesValue(c, v)
|
||||
})
|
||||
})
|
||||
restore()
|
||||
e.Grow(1).MinHeight(d.ControlHeight() * 3)
|
||||
internal.Ring(e, k.Focus, theme.ControlRadius)
|
||||
if disabled || s.editing != "" || s.cursor < 0 || s.cursor >= s.outline.Rows() {
|
||||
return
|
||||
}
|
||||
p := s.outline.Item(s.cursor)
|
||||
if len(s.kids[p]) == 0 && s.byPath[p].Children == nil && (e.Submitted() || e.Shortcut(0, ui.KeyF2)) {
|
||||
s.editing, s.draft = p, s.byPath[p].Value
|
||||
slog.Debug("mujica: variables_panel editing", "path", p)
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// variablesValue shows a value, marking changed ones.
|
||||
func variablesValue(c *ui.Context, v *Variable) {
|
||||
k := core.Tokens(c)
|
||||
if v.Changed {
|
||||
ui.Box(c).Size(7, 7).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, r.W), k.AccentText)
|
||||
})
|
||||
}
|
||||
val := CodeMono(c, v.Value).MinWidth(0)
|
||||
if v.Changed {
|
||||
val.FontWeight(700).TextColor(k.AccentText).Description(core.Msg(c, "variables_panel.changed"))
|
||||
}
|
||||
ui.Spacer(c)
|
||||
if v.Type != "" {
|
||||
CodeCaption(c, v.Type).Shrink(0)
|
||||
}
|
||||
}
|
||||
|
||||
// variablesIndex keys variables by dotted path.
|
||||
func variablesIndex(vars []Variable, s *VariablesPanelState) {
|
||||
var first *Variable
|
||||
if len(vars) > 0 {
|
||||
first = &vars[0]
|
||||
}
|
||||
if s.data == first && s.n == len(vars) && s.byPath != nil {
|
||||
return
|
||||
}
|
||||
s.data, s.n = first, len(vars)
|
||||
s.byPath, s.kids = map[string]*Variable{}, map[string][]string{}
|
||||
var walk func(list []Variable, prefix string) []string
|
||||
walk = func(list []Variable, prefix string) []string {
|
||||
paths := []string{}
|
||||
for i := range list {
|
||||
p := list[i].Name
|
||||
if prefix != "" {
|
||||
p = prefix + "." + p
|
||||
}
|
||||
if _, dup := s.byPath[p]; dup {
|
||||
panic("mujica: duplicate variable path " + p)
|
||||
}
|
||||
s.byPath[p] = &list[i]
|
||||
if list[i].Children != nil {
|
||||
s.kids[p] = walk(list[i].Children, p)
|
||||
}
|
||||
paths = append(paths, p)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
s.roots = walk(vars, "")
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func variablesSample() []Variable {
|
||||
return []Variable{
|
||||
{Name: "decree", Type: "court.Decree", Value: "{…}", Children: []Variable{
|
||||
{Name: "id", Value: "42", Type: "int", Changed: true},
|
||||
{Name: "title", Value: `"Of seals"`, Type: "string"},
|
||||
}},
|
||||
{Name: "count", Value: "7", Type: "int"},
|
||||
}
|
||||
}
|
||||
|
||||
type variablesProbe struct {
|
||||
vars []Variable
|
||||
s VariablesPanelState
|
||||
disabled bool
|
||||
edits []VariableEdit
|
||||
}
|
||||
|
||||
func (p *variablesProbe) view(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := VariablesPanel(c, p.vars, &p.s, VariablesPanelOptions{Disabled: p.disabled})
|
||||
r.Element.Height(300)
|
||||
if r.Edited != nil {
|
||||
p.edits = append(p.edits, *r.Edited)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariablesPanelExpandAndChanged(t *testing.T) {
|
||||
p := &variablesProbe{vars: variablesSample()}
|
||||
tt := ui.NewTester(p.view, 460, 320)
|
||||
if tt.HasText("title") {
|
||||
t.Fatal("children shown before expanding")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.HasText("title") || !tt.HasText("42") {
|
||||
t.Fatalf("Right did not expand: %q", tt.Texts())
|
||||
}
|
||||
if !p.s.byPath["decree.id"].Changed {
|
||||
t.Fatal("changed flag lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariablesPanelEditLeaf(t *testing.T) {
|
||||
p := &variablesProbe{vars: variablesSample()}
|
||||
tt := ui.NewTester(p.view, 460, 320)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.Key(0, ui.KeyF2)
|
||||
if !tt.Focused("Value") {
|
||||
t.Fatal("F2 did not open the editor")
|
||||
}
|
||||
tt.Key(ui.Cmd, ui.KeyA)
|
||||
tt.Type("8")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(p.edits) != 1 || p.edits[0] != (VariableEdit{"count", "8"}) {
|
||||
t.Fatalf("edits %v", p.edits)
|
||||
}
|
||||
// Enter on a branch does not edit it.
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if tt.Focused("Value") {
|
||||
t.Fatal("a branch opened the editor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariablesPanelEscapeAndDisabled(t *testing.T) {
|
||||
p := &variablesProbe{vars: variablesSample()}
|
||||
tt := ui.NewTester(p.view, 460, 320)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Type("x")
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if len(p.edits) != 0 || tt.Focused("Value") {
|
||||
t.Fatal("Escape committed")
|
||||
}
|
||||
p = &variablesProbe{vars: variablesSample(), disabled: true}
|
||||
tt = ui.NewTester(p.view, 460, 320)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyF2)
|
||||
if tt.Focused("Value") {
|
||||
t.Fatal("disabled panel opened the editor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariablesPanelDuplicatePanics(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("duplicate names did not panic")
|
||||
}
|
||||
}()
|
||||
variablesIndex([]Variable{{Name: "a"}, {Name: "a"}}, &VariablesPanelState{})
|
||||
}
|
||||
|
||||
func TestVariablesPanelSnapshot(t *testing.T) {
|
||||
snapshot(t, "variables_panel", 420, 180, func(c *ui.Context) {
|
||||
p := ui.Local(c.Root(), "snap", func() *variablesProbe {
|
||||
p := &variablesProbe{vars: variablesSample()}
|
||||
p.s.outline.Open.Add("decree")
|
||||
return p
|
||||
})
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
VariablesPanel(c, (*p).vars, &(*p).s, VariablesPanelOptions{}).Element.Height(156)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user