diff --git a/json_editor.go b/json_editor.go new file mode 100644 index 0000000..eb66d78 --- /dev/null +++ b/json_editor.go @@ -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) + }) +} diff --git a/json_editor_test.go b/json_editor_test.go new file mode 100644 index 0000000..ebed49e --- /dev/null +++ b/json_editor_test.go @@ -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}) }) + }) +} diff --git a/messages_devtools.go b/messages_devtools.go index abe4d0e..5a02788 100644 --- a/messages_devtools.go +++ b/messages_devtools.go @@ -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", "没有告警"}, }) } diff --git a/testdata/snapshots/darwin/json_editor-dark.png b/testdata/snapshots/darwin/json_editor-dark.png new file mode 100644 index 0000000..8c843a6 Binary files /dev/null and b/testdata/snapshots/darwin/json_editor-dark.png differ diff --git a/testdata/snapshots/darwin/json_editor-light.png b/testdata/snapshots/darwin/json_editor-light.png new file mode 100644 index 0000000..59b92e9 Binary files /dev/null and b/testdata/snapshots/darwin/json_editor-light.png differ