wip-devtools

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 15:00:02 -04:00
1 parent 1822101db7
commit 1052f8111d
5 files changed
+226

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
package mujica
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// JsonEditorOptions configure a JsonEditor.
type JsonEditorOptions struct {
// Label names the editor; empty uses "JSON".
Label string
// MinHeight and MaxHeight bound the text; zero means 160 and 400.
MinHeight, MaxHeight float32
Disabled bool
}
// JsonEditorResult reports this frame's events and the parse state.
type JsonEditorResult struct {
Element *ui.Element
Changed bool
// Valid reports *text parses as one JSON value.
Valid bool
// Line and Column locate a parse error, 1-based; 0 when valid.
Line, Column int
}
type jsonEditorCheck struct {
src string
err string
line, column int
}
// JsonEditor edits JSON text, formats it and points at the line and column of a parse error.
func JsonEditor(c *ui.Context, text *string, opts JsonEditorOptions) JsonEditorResult {
if text == nil || opts.MinHeight < 0 || opts.MaxHeight < 0 {
panic("mujica: JsonEditor needs text and non-negative heights")
}
label := opts.Label
if label == "" {
label = msg(c, "json_editor.label")
}
minH, maxH := opts.MinHeight, opts.MaxHeight
if minH == 0 {
minH = 160
}
if maxH == 0 {
maxH = 400
}
k := Tokens(c)
u := Density(c).Unit()
var r JsonEditorResult
r.Element = ui.Column(c).Shrink(0).Gap(u * 2).Disabled(opts.Disabled)
chk := ui.Local(r.Element, "mujica.jsoneditor", func() jsonEditorCheck { return jsonEditorCheck{src: "\x00"} })
locked := r.Element.IsDisabled()
r.Element.Children(func() {
jsonEditorValidate(chk, *text)
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, label).FontWeight(600).Grow(1)
if Button(c, msg(c, "json_editor.format"), ButtonOptions{Variant: Secondary, Disabled: locked || chk.err != ""}).Clicked() && !locked {
var b bytes.Buffer
if json.Indent(&b, []byte(*text), "", " ") == nil && b.String() != *text {
*text, r.Changed = b.String(), true
slog.Debug("mujica: json_editor format")
}
}
})
in := TextArea(c, text, TextAreaOptions{Label: label, MinHeight: minH, MaxHeight: maxH, Disabled: locked, Error: chk.err})
in.Input.Font(theme.MonoFont).FontSize(FontSize(c, codeSize))
r.Changed = r.Changed || in.Changed()
jsonEditorValidate(chk, *text)
if chk.err != "" {
lines := strings.Split(*text, "\n")
if chk.line >= 1 && chk.line <= len(lines) {
jsonEditorExcerpt(c, lines[chk.line-1], chk.line, chk.column)
}
return
}
ui.Row(c).Gap(u).AlignItems(ui.Center).Role(ui.RoleStatus).Children(func() {
ui.Icon(c, icons.Must("circle-check")).Size(14, 14).TextColor(k.Success)
ui.Text(c, msg(c, "json_editor.valid")).TextColor(k.Success).FontSize(FontSize(c, theme.CaptionSize+1))
})
})
r.Valid, r.Line, r.Column = chk.err == "", chk.line, chk.column
return r
}
// jsonEditorValidate parses text once per change and locates any error.
func jsonEditorValidate(chk *jsonEditorCheck, text string) {
if chk.src == text {
return
}
chk.src, chk.err, chk.line, chk.column = text, "", 0, 0
dec := json.NewDecoder(strings.NewReader(text))
var v any
err := dec.Decode(&v)
offset := dec.InputOffset()
if err == nil && dec.More() {
err = errors.New("unexpected data after the value")
}
if err == nil {
return
}
var syn *json.SyntaxError
var typ *json.UnmarshalTypeError
switch {
case errors.As(err, &syn):
offset = syn.Offset
case errors.As(err, &typ):
offset = typ.Offset
case strings.TrimSpace(text) == "":
err, offset = errors.New("empty document"), 0
default:
offset = int64(len(text))
}
before := text[:min(int(offset), len(text))]
chk.line = strings.Count(before, "\n") + 1
chk.column = len([]rune(before[strings.LastIndex(before, "\n")+1:]))
chk.column = max(chk.column, 1)
chk.err = fmt.Sprintf("%d:%d %v", chk.line, chk.column, err)
}
// jsonEditorExcerpt shows the error line with a caret under the column.
func jsonEditorExcerpt(c *ui.Context, line string, n, col int) {
k := Tokens(c)
u := Density(c).Unit()
ui.Column(c).Padding(u*2, u*3).Radius(theme.ControlRadius).Background(k.Background).Border(theme.BorderWidth, k.Border).
Label(fmt.Sprintf(msg(c, "json_editor.at"), n, col)).Children(func() {
spans := []ui.Span{{Text: fmt.Sprintf("%4d | ", n), Color: k.TextMuted}}
toks, _ := internal.Highlight("json", line, false)
for _, t := range toks {
spans = append(spans, ui.Span{Text: t.Text, Color: codeTokenColor(k, t.Kind)})
}
ui.RichText(c, spans...).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).SingleLine()
codeMono(c, strings.Repeat(" ", 7+col-1)+"^").TextColor(k.Danger)
})
}
+75
View File
@@ -0,0 +1,75 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func jsonEditorTester(text *string, opts JsonEditorOptions, last *JsonEditorResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
*last = JsonEditor(c, text, opts)
}, 600, 460)
}
func TestJsonEditorFormatAndValid(t *testing.T) {
text := `{"hall":"east","guests":[1,2]}`
var r JsonEditorResult
tt := jsonEditorTester(&text, JsonEditorOptions{}, &r)
if !r.Valid || !tt.HasText("Valid JSON") {
t.Fatalf("valid: %+v", r)
}
_ = tt.Click("Format")
if text != "{\n \"hall\": \"east\",\n \"guests\": [\n 1,\n 2\n ]\n}" {
t.Fatalf("formatted %q", text)
}
}
func TestJsonEditorErrorLocation(t *testing.T) {
text := "{\n \"hall\": \"east\",\n \"guests\": [1, 2,]\n}"
var r JsonEditorResult
tt := jsonEditorTester(&text, JsonEditorOptions{}, &r)
if r.Valid || r.Line != 3 || r.Column != 19 {
t.Fatalf("location %+v", r)
}
if !tt.HasText(" 3 | ") || !tt.HasText(" ^") {
t.Fatalf("excerpt: %q", tt.Texts())
}
_ = tt.Click("Format") // disabled while invalid
if r.Changed {
t.Fatal("formatted invalid JSON")
}
text = "{} {}"
tt.Frame()
if r.Valid || r.Line != 1 {
t.Fatalf("trailing value: %+v", r)
}
}
func TestJsonEditorTypingAndDisabled(t *testing.T) {
text := ""
var r JsonEditorResult
tt := jsonEditorTester(&text, JsonEditorOptions{}, &r)
if r.Valid {
t.Fatal("empty text is valid")
}
tt.Key(0, ui.KeyTab) // Format is disabled while invalid
tt.Type(`[true]`)
if !r.Valid || text != "[true]" {
t.Fatalf("typed %q valid %v", text, r.Valid)
}
tt = jsonEditorTester(&text, JsonEditorOptions{Disabled: true}, &r)
tt.Key(0, ui.KeyTab)
tt.Type("x")
if text != "[true]" {
t.Fatalf("disabled edited: %q", text)
}
}
func TestJsonEditorSnapshot(t *testing.T) {
snapshot(t, "json_editor", 560, 360, func(c *ui.Context) {
text := "{\n \"hall\": \"east wing\",\n \"guests\": [1, 2,]\n}"
ui.Column(c).Padding(16).Children(func() { JsonEditor(c, &text, JsonEditorOptions{MinHeight: 120}) })
})
}
+5
View File
@@ -82,6 +82,11 @@ func init() {
"json_viewer.copy": {"Copy path", "复制路径"},
"json_viewer.items": {"%s %d items %s", "%s %d 项 %s"},
"json_editor.label": {"JSON", "JSON"},
"json_editor.format": {"Format", "格式化"},
"json_editor.valid": {"Valid JSON", "JSON 有效"},
"json_editor.at": {"Error at line %d, column %d", "错误位于第 %d 行第 %d 列"},
"alert_list.empty": {"No alerts", "没有告警"},
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB