From 1334df8c32cde7d5e744c1daaa7e9ca1ffaf0c2b Mon Sep 17 00:00:00 2001 From: Robin Ince Date: Wed, 25 Mar 2026 16:01:48 +0000 Subject: [PATCH 1/6] Add formatted JSON modes to expanded reader --- internal/ui/model.go | 89 +++++++--- internal/ui/model_test.go | 114 ++++++++++++- internal/ui/reader_render.go | 303 +++++++++++++++++++++++++++++++++++ internal/ui/styles.go | 27 ++++ 4 files changed, 508 insertions(+), 25 deletions(-) create mode 100644 internal/ui/reader_render.go diff --git a/internal/ui/model.go b/internal/ui/model.go index bf348ac..9bfae05 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -180,6 +180,8 @@ type Model struct { detailCol string // column shown in detail panel detailTab int // 0=TopValues, 1=Stats, 2=Histogram readerCol string + readerModes []readerMode + readerMode readerMode readerAbsRow int readerWrap bool readerVertOff int @@ -316,6 +318,8 @@ func (m *Model) resetLoadedDataState() { m.detailCol = "" m.detailTab = 0 m.readerCol = "" + m.readerModes = nil + m.readerMode = readerModeRaw m.readerAbsRow = 0 m.readerWrap = false m.readerVertOff = 0 @@ -1000,6 +1004,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.clampTableRowCursor() if m.overlay == OverlayCellReader { m.readerAbsRow = m.tableOffset + m.tableRowCursor + if m.refreshReaderModes(true) { + m.statusMsg = "Reader format unavailable for this row; using raw" + } m.clampReaderOffsets() } if m.needsMorePreviewRows() { @@ -2418,7 +2425,9 @@ func (m *Model) openCellReader(colName, value string) { m.overlay = OverlayCellReader m.readerCol = colName m.readerAbsRow = m.currentAbsoluteRow() - m.readerWrap = defaultReaderWrap(value) + m.readerModes = readerModesForValue(value) + m.readerMode = defaultReaderMode(m.readerModes) + m.readerWrap = defaultReaderWrapForMode(m.readerMode, value) m.readerVertOff = 0 m.readerHorizOff = 0 m.statusMsg = fmt.Sprintf("Opened expanded reader for %q", colName) @@ -2427,11 +2436,47 @@ func (m *Model) openCellReader(colName, value string) { func (m *Model) closeCellReader() { m.overlay = OverlayNone m.readerCol = "" + m.readerModes = nil + m.readerMode = readerModeRaw m.readerAbsRow = 0 m.readerVertOff = 0 m.readerHorizOff = 0 } +func (m *Model) refreshReaderModes(preferCurrent bool) bool { + value, ok := m.readerCurrentValue() + prevMode := m.readerMode + m.readerModes = []readerMode{readerModeRaw} + m.readerMode = readerModeRaw + if !ok { + return false + } + + m.readerModes = readerModesForValue(value) + if preferCurrent && slices.Contains(m.readerModes, prevMode) { + m.readerMode = prevMode + return false + } + + m.readerMode = defaultReaderMode(m.readerModes) + return preferCurrent && prevMode != readerModeRaw && m.readerMode == readerModeRaw +} + +func (m *Model) cycleReaderMode() { + if len(m.readerModes) <= 1 { + m.statusMsg = "No alternate reader format" + return + } + + idx := slices.Index(m.readerModes, m.readerMode) + if idx < 0 { + idx = 0 + } + m.readerMode = m.readerModes[(idx+1)%len(m.readerModes)] + m.clampReaderOffsets() + m.statusMsg = fmt.Sprintf("Reader mode: %s", m.readerMode.label()) +} + // handleActiveColumnWidthAction keeps lowercase `w` adaptive: it toggles fit-width // for ordinary values, but opens the expanded reader when the visible column sample // indicates that widening would be a poor fit for the table layout. @@ -2755,14 +2800,7 @@ func (m Model) readerRenderedLines(bodyW int) []string { if !ok { return []string{""} } - logical := sanitizeMultilineLogicalLines(value) - - // Future: swap in structured renderers here (JSON pretty-format/highlight, - // Markdown, lists, key-value views) once the expanded reader supports them. - if m.readerWrap { - return wrapLogicalLines(logical, bodyW) - } - return sliceLogicalLines(logical, m.readerHorizOff, bodyW) + return newReaderRenderData(value, m.readerMode).renderedLines(bodyW, m.readerWrap, m.readerHorizOff) } func (m Model) maxReaderVerticalOffset() int { @@ -2784,12 +2822,7 @@ func (m Model) maxReaderHorizontalOffset() int { if !ok { return 0 } - maxLineW := 0 - for _, line := range sanitizeMultilineLogicalLines(value) { - if w := lipgloss.Width(line); w > maxLineW { - maxLineW = w - } - } + maxLineW := newReaderRenderData(value, m.readerMode).maxLineWidth() maxOff := maxLineW - innerW if maxOff < 0 { return 0 @@ -2862,9 +2895,9 @@ func (m *Model) toggleReaderWrap() { m.readerWrap = !m.readerWrap m.clampReaderOffsets() if m.readerWrap { - m.statusMsg = "Reader wrap on" + m.statusMsg = fmt.Sprintf("Reader wrap on (%s)", m.readerMode.label()) } else { - m.statusMsg = "Reader wrap off" + m.statusMsg = fmt.Sprintf("Reader wrap off (%s)", m.readerMode.label()) } } @@ -2915,6 +2948,9 @@ func (m Model) moveReaderRow(delta int) (tea.Model, tea.Cmd) { } m.readerAbsRow = m.currentAbsoluteRow() + if m.refreshReaderModes(true) { + m.statusMsg = "Reader format unavailable for this row; using raw" + } m.clampReaderOffsets() return m, nil } @@ -2933,6 +2969,8 @@ func (m Model) handleReaderKey(key string) (tea.Model, tea.Cmd) { m.scrollReaderHoriz(1) case "W": m.toggleReaderWrap() + case "F": + m.cycleReaderMode() case "n": return m.moveReaderRow(1) case "p": @@ -3397,7 +3435,7 @@ func (m Model) viewBottomBar() string { var hints string switch { case m.overlay == OverlayCellReader: - hints = "w/Esc:close ↑↓/jk:scroll ←→/hl:pan W:wrap n/p:row Space/C-f/C-b:page C-d/u:half g/G:top/bottom" + hints = "w/Esc:close ↑↓/jk:scroll ←→/hl:pan W:wrap F:format/raw n/p:row Space/C-f/C-b:page C-d/u:half g/G:top/bottom" case m.focus == FocusColumns: hints = "Ctrl+O:open jk/↑↓:move Space/C-f/C-b:page C-d/u:half gG/HML:jump m:missing-mode /:search v:sel-list x:toggle a/d/y:sel" default: @@ -3456,13 +3494,15 @@ func (m Model) viewCellReader(w, h int) string { if colType != "" { header += " " + colType } + header = readerHeaderStyle.Render(header) + header = lipgloss.JoinHorizontal(lipgloss.Left, header, " ", readerModeBadge(m.readerMode)) if m.readerWrap { - header += " wrap:on" + header = lipgloss.JoinHorizontal(lipgloss.Left, header, readerHeaderStyle.Render(" wrap:on")) } else { - header += " wrap:off" + header = lipgloss.JoinHorizontal(lipgloss.Left, header, readerHeaderStyle.Render(" wrap:off")) } - header += fmt.Sprintf(" len:%d", utf8.RuneCountInString(value)) - header = clampLineWidth(readerHeaderStyle.Render(header), w) + header = lipgloss.JoinHorizontal(lipgloss.Left, header, readerHeaderStyle.Render(fmt.Sprintf(" len:%d", utf8.RuneCountInString(value)))) + header = clampLineWidth(header, w) topEdge := readerEdgeStyle.Width(w).Render(strings.Repeat("─", max(1, w))) if m.readerVertOff == 0 { @@ -3487,13 +3527,13 @@ func (m Model) viewCellReader(w, h int) string { bodyLines := make([]string, 0, bodyH) for _, line := range lines[start:end] { - bodyLines = append(bodyLines, clampLineWidth(readerBodyStyle.Render(padDisplayRight(line, w)), w)) + bodyLines = append(bodyLines, padReaderLine(line, w, m.readerMode)) } for len(bodyLines) < bodyH { bodyLines = append(bodyLines, readerBodyStyle.Render(strings.Repeat(" ", w))) } - footer := " Scroll: ↑↓/jk Row: n/p Wrap: W Page: Space/C-f/C-b Close: Esc/w" + footer := " Scroll: ↑↓/jk Row: n/p Wrap: W F:format/raw Page: Space/C-f/C-b Close: Esc/w" footer = clampLineWidth(readerFooterStyle.Render(footer), w) return strings.Join(append([]string{header, topEdge}, append(bodyLines, bottomEdge, footer)...), "\n") @@ -4084,6 +4124,7 @@ func (m Model) viewHelp() string { {"↑/↓ or j/k", "Scroll content"}, {"←/→ or h/l", "Pan horizontally (wrap off)"}, {"n / p", "Next / previous row in same column"}, + {"F", "Cycle reader format"}, {"W", "Toggle wrap"}, {"Ctrl+F / Space", "Page down"}, {"Ctrl+B", "Page up"}, diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index a761e9f..bd47ff6 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" _ "github.com/marcboeker/go-duckdb" "github.com/robince/parqview/internal/engine" @@ -1633,13 +1634,16 @@ func TestHandleTableKeyShiftWOpensJSONReaderWithWrapOffByDefault(t *testing.T) { m.height = 12 m.tableCols = []string{"id", "payload"} m.selectedColName = "payload" - m.tableData = [][]string{{"1", strings.Repeat(`{"alpha":1,"beta":2,"gamma":"delta"}`, 4)}} + m.tableData = [][]string{{"1", `{"alpha":"` + strings.Repeat("delta", 16) + `","beta":2,"gamma":{"nested":true}}`}} updated, _ := m.handleTableKey("W") m = updated.(Model) if m.overlay != OverlayCellReader { t.Fatalf("expected expanded reader overlay, got %v", m.overlay) } + if m.readerMode != readerModeJSONPretty { + t.Fatalf("expected JSON-like payload to default to json pretty mode, got %v", m.readerMode) + } if m.readerWrap { t.Fatal("expected JSON-like payload to default to wrap:off") } @@ -1660,6 +1664,63 @@ func TestHandleTableKeyShiftWOpensJSONReaderWithWrapOffByDefault(t *testing.T) { } } +func TestHandleTableKeyShiftWKeepsInvalidJSONInRawMode(t *testing.T) { + m := newTestModel() + m.width = 70 + m.height = 12 + m.tableCols = []string{"id", "payload"} + m.selectedColName = "payload" + m.tableData = [][]string{{"1", `{"alpha":1}{"beta":2}`}} + + updated, _ := m.handleTableKey("W") + m = updated.(Model) + if m.overlay != OverlayCellReader { + t.Fatalf("expected expanded reader overlay, got %v", m.overlay) + } + if m.readerMode != readerModeRaw { + t.Fatalf("expected invalid JSON to stay in raw mode, got %v", m.readerMode) + } + + updated, _ = m.handleReaderKey("F") + m = updated.(Model) + if got := m.statusMsg; got != "No alternate reader format" { + t.Fatalf("expected no-alternate-format status, got %q", got) + } +} + +func TestHandleReaderKeyFTogglesBetweenRawAndPrettyJSON(t *testing.T) { + m := newTestModel() + m.width = 90 + m.height = 12 + m.tableCols = []string{"id", "payload"} + m.selectedColName = "payload" + m.tableData = [][]string{{"1", `{"alpha":1,"beta":"two"}`}} + + updated, _ := m.handleTableKey("W") + m = updated.(Model) + if m.readerMode != readerModeJSONPretty { + t.Fatalf("expected JSON reader mode by default, got %v", m.readerMode) + } + + updated, _ = m.handleReaderKey("F") + m = updated.(Model) + if m.readerMode != readerModeRaw { + t.Fatalf("expected F to switch to raw mode, got %v", m.readerMode) + } + if got := m.statusMsg; got != "Reader mode: raw" { + t.Fatalf("expected raw mode status, got %q", got) + } + + updated, _ = m.handleReaderKey("F") + m = updated.(Model) + if m.readerMode != readerModeJSONPretty { + t.Fatalf("expected second F to switch back to json pretty mode, got %v", m.readerMode) + } + if got := m.statusMsg; got != "Reader mode: json pretty" { + t.Fatalf("expected json pretty status, got %q", got) + } +} + func TestHandleTableKeyWFallsBackToFitWidthWhenCurrentRowHasNoCell(t *testing.T) { m := newTestModel() m.width = 90 @@ -1769,6 +1830,36 @@ func TestReaderKeepsRowNavigationExplicitAndClampsOffsets(t *testing.T) { } } +func TestReaderRowNavigationFallsBackToRawWhenNextRowCannotFormat(t *testing.T) { + m := newTestModel() + m.width = 90 + m.height = 12 + m.tableCols = []string{"id", "payload"} + m.selectedColName = "payload" + m.tableData = [][]string{ + {"1", `{"alpha":1,"beta":2}`}, + {"2", "plain text"}, + } + + updated, _ := m.handleTableKey("W") + m = updated.(Model) + if m.readerMode != readerModeJSONPretty { + t.Fatalf("expected JSON pretty mode on first row, got %v", m.readerMode) + } + + updated, cmd := m.handleReaderKey("n") + if cmd != nil { + t.Fatalf("expected no load command when moving within visible page, got %v", cmd) + } + m = updated.(Model) + if m.readerMode != readerModeRaw { + t.Fatalf("expected reader mode to fall back to raw, got %v", m.readerMode) + } + if got := m.statusMsg; got != "Reader format unavailable for this row; using raw" { + t.Fatalf("expected fallback status, got %q", got) + } +} + func TestReaderUsesLoadedPreviewOffsetWhenPagingPastVisibleWindow(t *testing.T) { m := newCmdTestModel() m.width = 80 @@ -1909,6 +2000,27 @@ func TestViewCellReaderWrapsWithinWidthAndShowsEdgeMarkers(t *testing.T) { } } +func TestViewCellReaderFormatsPrettyJSONAndShowsModeBadge(t *testing.T) { + m := newTestModel() + m.width = 90 + m.height = 14 + m.tableCols = []string{"id", "payload"} + m.selectedColName = "payload" + m.tableData = [][]string{{"1", `{"alpha":1,"beta":{"nested":true},"escaped":"\u001b[31mhello"}`}} + m.openCellReader("payload", m.tableData[0][1]) + + out := ansi.Strip(m.viewCellReader(50, 11)) + if !strings.Contains(out, "JSON") { + t.Fatalf("expected reader header to show JSON mode badge, got %q", out) + } + if !strings.Contains(out, `"alpha": 1`) { + t.Fatalf("expected reader body to pretty-print JSON, got %q", out) + } + if !strings.Contains(out, `"escaped": "\u001b[31mhello"`) { + t.Fatalf("expected escaped JSON control sequence to remain escaped, got %q", out) + } +} + func TestViewCellReaderHeaderUsesReaderAbsoluteRowID(t *testing.T) { m := newTestModel() m.width = 80 diff --git a/internal/ui/reader_render.go b/internal/ui/reader_render.go new file mode 100644 index 0000000..2b14457 --- /dev/null +++ b/internal/ui/reader_render.go @@ -0,0 +1,303 @@ +package ui + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +type readerMode int + +const ( + readerModeRaw readerMode = iota + readerModeJSONPretty +) + +func (m readerMode) label() string { + switch m { + case readerModeJSONPretty: + return "json pretty" + default: + return "raw" + } +} + +func readerModeBadge(mode readerMode) string { + switch mode { + case readerModeJSONPretty: + return readerModeJSONBadgeStyle.Render("JSON") + default: + return readerModeRawBadgeStyle.Render("RAW") + } +} + +func readerModesForValue(value string) []readerMode { + modes := []readerMode{readerModeRaw} + if _, ok := prettyJSONReaderValue(value); ok { + modes = append(modes, readerModeJSONPretty) + } + return modes +} + +func defaultReaderMode(modes []readerMode) readerMode { + for _, mode := range modes { + if mode == readerModeJSONPretty { + return readerModeJSONPretty + } + } + return readerModeRaw +} + +func defaultReaderWrapForMode(mode readerMode, value string) bool { + if mode == readerModeJSONPretty { + return false + } + return defaultReaderWrap(value) +} + +type readerRenderData struct { + logicalLines []string + ansiAware bool +} + +func newReaderRenderData(value string, mode readerMode) readerRenderData { + switch mode { + case readerModeJSONPretty: + if pretty, ok := prettyJSONReaderValue(value); ok { + return readerRenderData{ + logicalLines: highlightPrettyJSON(pretty), + ansiAware: true, + } + } + } + return readerRenderData{logicalLines: sanitizeMultilineLogicalLines(value)} +} + +func (d readerRenderData) renderedLines(bodyW int, wrap bool, horizOff int) []string { + if d.ansiAware { + if wrap { + return wrapANSILogicalLines(d.logicalLines, bodyW) + } + return sliceANSILogicalLines(d.logicalLines, horizOff, bodyW) + } + if wrap { + return wrapLogicalLines(d.logicalLines, bodyW) + } + return sliceLogicalLines(d.logicalLines, horizOff, bodyW) +} + +func (d readerRenderData) maxLineWidth() int { + maxLineW := 0 + for _, line := range d.logicalLines { + var w int + if d.ansiAware { + w = ansi.StringWidth(line) + } else { + w = lipgloss.Width(line) + } + if w > maxLineW { + maxLineW = w + } + } + return maxLineW +} + +func padReaderLine(line string, targetW int, mode readerMode) string { + if mode == readerModeJSONPretty { + return padANSIRight(line, targetW) + } + return clampLineWidth(readerBodyStyle.Render(padDisplayRight(line, targetW)), targetW) +} + +func prettyJSONReaderValue(value string) (string, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", false + } + switch trimmed[0] { + case '{', '[': + default: + return "", false + } + if !json.Valid([]byte(trimmed)) { + return "", false + } + + var buf bytes.Buffer + if err := json.Indent(&buf, []byte(trimmed), "", " "); err != nil { + return "", false + } + return buf.String(), true +} + +func highlightPrettyJSON(pretty string) []string { + lines := strings.Split(pretty, "\n") + if len(lines) == 0 { + return []string{""} + } + + styled := make([]string, 0, len(lines)) + for _, line := range lines { + styled = append(styled, highlightPrettyJSONLine(line)) + } + return styled +} + +func highlightPrettyJSONLine(line string) string { + if line == "" { + return "" + } + + var b strings.Builder + for i := 0; i < len(line); { + switch line[i] { + case ' ', '\t': + b.WriteByte(line[i]) + i++ + case '{', '}', '[', ']', ':', ',': + b.WriteString(readerJSONPunctuationStyle.Render(line[i : i+1])) + i++ + case '"': + end := scanJSONStringToken(line, i) + token := line[i:end] + if jsonStringTokenIsKey(line, end) { + b.WriteString(readerJSONKeyStyle.Render(token)) + } else { + b.WriteString(readerJSONStringStyle.Render(token)) + } + i = end + default: + if keyword := jsonKeywordTokenAt(line, i); keyword != "" { + b.WriteString(readerJSONKeywordStyle.Render(keyword)) + i += len(keyword) + continue + } + if number := jsonNumberTokenAt(line, i); number != "" { + b.WriteString(readerJSONNumberStyle.Render(number)) + i += len(number) + continue + } + b.WriteString(readerBodyStyle.Render(line[i : i+1])) + i++ + } + } + return b.String() +} + +func scanJSONStringToken(line string, start int) int { + i := start + 1 + for i < len(line) { + switch line[i] { + case '\\': + i += 2 + case '"': + return i + 1 + default: + i++ + } + } + return len(line) +} + +func jsonStringTokenIsKey(line string, end int) bool { + for i := end; i < len(line); i++ { + switch line[i] { + case ' ', '\t': + continue + case ':': + return true + default: + return false + } + } + return false +} + +func jsonKeywordTokenAt(line string, start int) string { + for _, keyword := range []string{"true", "false", "null"} { + if strings.HasPrefix(line[start:], keyword) && jsonTokenBoundary(line, start+len(keyword)) { + return keyword + } + } + return "" +} + +func jsonNumberTokenAt(line string, start int) string { + if start >= len(line) { + return "" + } + if line[start] != '-' && (line[start] < '0' || line[start] > '9') { + return "" + } + + end := start + for end < len(line) { + ch := line[end] + if (ch >= '0' && ch <= '9') || ch == '-' || ch == '+' || ch == '.' || ch == 'e' || ch == 'E' { + end++ + continue + } + break + } + if end == start || !jsonTokenBoundary(line, end) { + return "" + } + return line[start:end] +} + +func jsonTokenBoundary(line string, pos int) bool { + if pos >= len(line) { + return true + } + switch line[pos] { + case ' ', '\t', '\n', '\r', ',', ':', '}', ']': + return true + default: + return false + } +} + +func wrapANSILogicalLines(lines []string, maxW int) []string { + if maxW <= 0 { + return []string{""} + } + var out []string + for _, line := range lines { + out = append(out, strings.Split(ansi.Hardwrap(line, maxW, true), "\n")...) + } + if len(out) == 0 { + return []string{""} + } + return out +} + +func sliceANSILogicalLines(lines []string, startW, maxW int) []string { + if maxW <= 0 { + return []string{""} + } + if startW < 0 { + startW = 0 + } + out := make([]string, 0, len(lines)) + for _, line := range lines { + out = append(out, ansi.Cut(line, startW, startW+maxW)) + } + if len(out) == 0 { + return []string{""} + } + return out +} + +func padANSIRight(line string, targetW int) string { + if targetW <= 0 { + return "" + } + padding := targetW - ansi.StringWidth(line) + if padding <= 0 { + return line + } + return line + strings.Repeat(" ", padding) +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 40f418a..fb736d5 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -133,6 +133,18 @@ var ( readerBodyStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("252")) + readerModeRawBadgeStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("230")). + Background(lipgloss.Color("240")). + Padding(0, 1) + + readerModeJSONBadgeStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("235")). + Background(lipgloss.Color("80")). + Padding(0, 1) + readerFooterStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("243")) @@ -143,6 +155,21 @@ var ( Foreground(lipgloss.Color("69")). Bold(true) + readerJSONPunctuationStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("242")) + + readerJSONKeyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("117")) + + readerJSONStringStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("114")) + + readerJSONNumberStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("215")) + + readerJSONKeywordStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("173")) + // Search searchPromptStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("62")) From be1f0fd82ba6680a1f9f12add3772bab915823b6 Mon Sep 17 00:00:00 2001 From: Robin Ince Date: Wed, 25 Mar 2026 16:40:14 +0000 Subject: [PATCH 2/6] Replace single-case switch in reader render --- internal/ui/reader_render.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/ui/reader_render.go b/internal/ui/reader_render.go index 2b14457..6aea1fc 100644 --- a/internal/ui/reader_render.go +++ b/internal/ui/reader_render.go @@ -64,8 +64,7 @@ type readerRenderData struct { } func newReaderRenderData(value string, mode readerMode) readerRenderData { - switch mode { - case readerModeJSONPretty: + if mode == readerModeJSONPretty { if pretty, ok := prettyJSONReaderValue(value); ok { return readerRenderData{ logicalLines: highlightPrettyJSON(pretty), From 51cc009c0ea2abed8f7b3c1ef5eed38ae17155af Mon Sep 17 00:00:00 2001 From: Robin Ince Date: Wed, 25 Mar 2026 16:43:14 +0000 Subject: [PATCH 3/6] defer json pretty work until render --- internal/ui/model_test.go | 22 ++++++++++++++++++++++ internal/ui/reader_render.go | 15 ++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index bd47ff6..f65fd05 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -1664,6 +1664,28 @@ func TestHandleTableKeyShiftWOpensJSONReaderWithWrapOffByDefault(t *testing.T) { } } +func TestReaderModesForValueOnlyEnablesJSONPrettyForValidObjectOrArray(t *testing.T) { + cases := []struct { + name string + value string + want []readerMode + }{ + {name: "object", value: `{"alpha":1}`, want: []readerMode{readerModeRaw, readerModeJSONPretty}}, + {name: "array", value: `[1,2,3]`, want: []readerMode{readerModeRaw, readerModeJSONPretty}}, + {name: "scalar", value: `123`, want: []readerMode{readerModeRaw}}, + {name: "invalid", value: `{"alpha":1}{"beta":2}`, want: []readerMode{readerModeRaw}}, + {name: "prose", value: `hello`, want: []readerMode{readerModeRaw}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := readerModesForValue(tc.value); !slices.Equal(got, tc.want) { + t.Fatalf("readerModesForValue(%q)=%v want %v", tc.value, got, tc.want) + } + }) + } +} + func TestHandleTableKeyShiftWKeepsInvalidJSONInRawMode(t *testing.T) { m := newTestModel() m.width = 70 diff --git a/internal/ui/reader_render.go b/internal/ui/reader_render.go index 6aea1fc..a9406a9 100644 --- a/internal/ui/reader_render.go +++ b/internal/ui/reader_render.go @@ -36,7 +36,7 @@ func readerModeBadge(mode readerMode) string { func readerModesForValue(value string) []readerMode { modes := []readerMode{readerModeRaw} - if _, ok := prettyJSONReaderValue(value); ok { + if isJSONLikeValid(value) { modes = append(modes, readerModeJSONPretty) } return modes @@ -111,17 +111,22 @@ func padReaderLine(line string, targetW int, mode readerMode) string { return clampLineWidth(readerBodyStyle.Render(padDisplayRight(line, targetW)), targetW) } -func prettyJSONReaderValue(value string) (string, bool) { +func isJSONLikeValid(value string) bool { trimmed := strings.TrimSpace(value) if trimmed == "" { - return "", false + return false } switch trimmed[0] { case '{', '[': default: - return "", false + return false } - if !json.Valid([]byte(trimmed)) { + return json.Valid([]byte(trimmed)) +} + +func prettyJSONReaderValue(value string) (string, bool) { + trimmed := strings.TrimSpace(value) + if !isJSONLikeValid(trimmed) { return "", false } From 9a897a73b86828f2d0b5616612c4da1df502610c Mon Sep 17 00:00:00 2001 From: Robin Ince Date: Wed, 25 Mar 2026 16:47:49 +0000 Subject: [PATCH 4/6] cache expanded reader render data --- internal/ui/model.go | 59 ++++++++++++++++++++++++++++++------ internal/ui/model_test.go | 39 ++++++++++++++++++++++++ internal/ui/reader_render.go | 4 +++ 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index 9bfae05..0879a58 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -182,6 +182,11 @@ type Model struct { readerCol string readerModes []readerMode readerMode readerMode + readerRender readerRenderData + readerRenderFor string + readerRenderOK bool + readerRenderW int + readerRenderM readerMode readerAbsRow int readerWrap bool readerVertOff int @@ -320,6 +325,11 @@ func (m *Model) resetLoadedDataState() { m.readerCol = "" m.readerModes = nil m.readerMode = readerModeRaw + m.readerRender = readerRenderData{} + m.readerRenderFor = "" + m.readerRenderOK = false + m.readerRenderW = 0 + m.readerRenderM = readerModeRaw m.readerAbsRow = 0 m.readerWrap = false m.readerVertOff = 0 @@ -2427,6 +2437,7 @@ func (m *Model) openCellReader(colName, value string) { m.readerAbsRow = m.currentAbsoluteRow() m.readerModes = readerModesForValue(value) m.readerMode = defaultReaderMode(m.readerModes) + m.invalidateReaderRenderData() m.readerWrap = defaultReaderWrapForMode(m.readerMode, value) m.readerVertOff = 0 m.readerHorizOff = 0 @@ -2438,27 +2449,44 @@ func (m *Model) closeCellReader() { m.readerCol = "" m.readerModes = nil m.readerMode = readerModeRaw + m.invalidateReaderRenderData() m.readerAbsRow = 0 m.readerVertOff = 0 m.readerHorizOff = 0 } +func (m *Model) invalidateReaderRenderData() { + m.readerRender = readerRenderData{} + m.readerRenderFor = "" + m.readerRenderOK = false + m.readerRenderW = 0 + m.readerRenderM = readerModeRaw +} + func (m *Model) refreshReaderModes(preferCurrent bool) bool { value, ok := m.readerCurrentValue() prevMode := m.readerMode + prevModes := m.readerModes m.readerModes = []readerMode{readerModeRaw} m.readerMode = readerModeRaw if !ok { + m.invalidateReaderRenderData() return false } m.readerModes = readerModesForValue(value) if preferCurrent && slices.Contains(m.readerModes, prevMode) { m.readerMode = prevMode + if !slices.Equal(prevModes, m.readerModes) { + m.invalidateReaderRenderData() + } return false } m.readerMode = defaultReaderMode(m.readerModes) + if prevMode != m.readerMode || !slices.Equal(prevModes, m.readerModes) { + m.invalidateReaderRenderData() + } return preferCurrent && prevMode != readerModeRaw && m.readerMode == readerModeRaw } @@ -2473,6 +2501,7 @@ func (m *Model) cycleReaderMode() { idx = 0 } m.readerMode = m.readerModes[(idx+1)%len(m.readerModes)] + m.invalidateReaderRenderData() m.clampReaderOffsets() m.statusMsg = fmt.Sprintf("Reader mode: %s", m.readerMode.label()) } @@ -2795,15 +2824,29 @@ func (m Model) readerCurrentValue() (string, bool) { return m.valueForAbsoluteCell(m.readerAbsRow, colName) } -func (m Model) readerRenderedLines(bodyW int) []string { +func (m *Model) ensureReaderRenderData() readerRenderData { value, ok := m.readerCurrentValue() if !ok { - return []string{""} + m.invalidateReaderRenderData() + return readerRenderData{} + } + if m.readerRenderOK && m.readerRenderFor == value && m.readerRenderM == m.readerMode { + return m.readerRender } - return newReaderRenderData(value, m.readerMode).renderedLines(bodyW, m.readerWrap, m.readerHorizOff) + + m.readerRender = newReaderRenderData(value, m.readerMode) + m.readerRenderFor = value + m.readerRenderOK = true + m.readerRenderW = m.readerRender.maxLineWidth() + m.readerRenderM = m.readerMode + return m.readerRender +} + +func (m *Model) readerRenderedLines(bodyW int) []string { + return m.ensureReaderRenderData().renderedLines(bodyW, m.readerWrap, m.readerHorizOff) } -func (m Model) maxReaderVerticalOffset() int { +func (m *Model) maxReaderVerticalOffset() int { innerW, _ := m.readerInnerDimensions() lines := m.readerRenderedLines(innerW) maxOff := len(lines) - m.readerBodyHeight() @@ -2813,17 +2856,15 @@ func (m Model) maxReaderVerticalOffset() int { return maxOff } -func (m Model) maxReaderHorizontalOffset() int { +func (m *Model) maxReaderHorizontalOffset() int { if m.readerWrap { return 0 } innerW, _ := m.readerInnerDimensions() - value, ok := m.readerCurrentValue() - if !ok { + if !m.ensureReaderRenderData().hasContent() { return 0 } - maxLineW := newReaderRenderData(value, m.readerMode).maxLineWidth() - maxOff := maxLineW - innerW + maxOff := m.readerRenderW - innerW if maxOff < 0 { return 0 } diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index f65fd05..b612ce3 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -1743,6 +1743,45 @@ func TestHandleReaderKeyFTogglesBetweenRawAndPrettyJSON(t *testing.T) { } } +func TestReaderRenderCacheTracksModeAndValueChanges(t *testing.T) { + m := newTestModel() + m.width = 90 + m.height = 12 + m.tableCols = []string{"id", "payload"} + m.selectedColName = "payload" + m.tableData = [][]string{{"1", `{"alpha":1,"beta":"two"}`}} + + updated, _ := m.handleTableKey("W") + m = updated.(Model) + + prettyLines := m.readerRenderedLines(40) + if !m.readerRenderOK { + t.Fatal("expected reader render cache to be populated") + } + if m.readerRenderFor != `{"alpha":1,"beta":"two"}` { + t.Fatalf("expected cache to track current value, got %q", m.readerRenderFor) + } + if m.readerRenderM != readerModeJSONPretty { + t.Fatalf("expected cache to track json pretty mode, got %v", m.readerRenderM) + } + + updated, _ = m.handleReaderKey("F") + m = updated.(Model) + rawLines := m.readerRenderedLines(40) + if m.readerRenderM != readerModeRaw { + t.Fatalf("expected cache to refresh for raw mode, got %v", m.readerRenderM) + } + if slices.Equal(prettyLines, rawLines) { + t.Fatal("expected raw render lines to differ from pretty render lines") + } + + m.tableData[0][1] = `{"gamma":3}` + _ = m.readerRenderedLines(40) + if m.readerRenderFor != `{"gamma":3}` { + t.Fatalf("expected cache to refresh for changed cell value, got %q", m.readerRenderFor) + } +} + func TestHandleTableKeyWFallsBackToFitWidthWhenCurrentRowHasNoCell(t *testing.T) { m := newTestModel() m.width = 90 diff --git a/internal/ui/reader_render.go b/internal/ui/reader_render.go index a9406a9..92e0303 100644 --- a/internal/ui/reader_render.go +++ b/internal/ui/reader_render.go @@ -63,6 +63,10 @@ type readerRenderData struct { ansiAware bool } +func (d readerRenderData) hasContent() bool { + return d.logicalLines != nil +} + func newReaderRenderData(value string, mode readerMode) readerRenderData { if mode == readerModeJSONPretty { if pretty, ok := prettyJSONReaderValue(value); ok { From 78b9c1067014998f22c22eff53a71ab9f7459632 Mon Sep 17 00:00:00 2001 From: Robin Ince Date: Wed, 25 Mar 2026 19:23:23 +0000 Subject: [PATCH 5/6] refresh reader mode on paged row moves --- internal/ui/model.go | 6 ++++++ internal/ui/model_test.go | 17 +++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index 0879a58..b96a786 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -2970,6 +2970,9 @@ func (m Model) moveReaderRow(delta int) (tea.Model, tea.Cmd) { } m.tableOffset++ m.readerAbsRow = m.currentAbsoluteRow() + if m.refreshReaderModes(true) { + m.statusMsg = "Reader format unavailable for this row; using raw" + } m.clampReaderOffsets() return m, m.nextPreviewCmd() } @@ -2983,6 +2986,9 @@ func (m Model) moveReaderRow(delta int) (tea.Model, tea.Cmd) { } m.tableOffset-- m.readerAbsRow = m.currentAbsoluteRow() + if m.refreshReaderModes(true) { + m.statusMsg = "Reader format unavailable for this row; using raw" + } m.clampReaderOffsets() return m, m.nextPreviewCmd() } diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index b612ce3..960558d 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -1925,8 +1925,8 @@ func TestReaderUsesLoadedPreviewOffsetWhenPagingPastVisibleWindow(t *testing.T) m := newCmdTestModel() m.width = 80 m.height = 12 - m.tableCols = []string{"id", "body"} - m.selectedColName = "body" + m.tableCols = []string{"id", "payload"} + m.selectedColName = "payload" visibleRows := m.visibleTableRows() if visibleRows < 1 { @@ -1937,7 +1937,7 @@ func TestReaderUsesLoadedPreviewOffsetWhenPagingPastVisibleWindow(t *testing.T) for i := range rows { body := fmt.Sprintf("row-%d", i+1) if i == visibleRows-1 { - body = strings.Repeat("this is a much longer row ", 8) + body = `{"alpha":"` + strings.Repeat("json", 8) + `","beta":2}` } if i == visibleRows { body = "short" @@ -1947,9 +1947,12 @@ func TestReaderUsesLoadedPreviewOffsetWhenPagingPastVisibleWindow(t *testing.T) m.totalRows = int64(len(rows) + 1) m.tableData = rows m.tableRowCursor = visibleRows - 1 - m.openCellReader("body", rows[m.tableRowCursor][1]) + m.openCellReader("payload", rows[m.tableRowCursor][1]) m.readerWrap = false m.readerHorizOff = 25 + if m.readerMode != readerModeJSONPretty { + t.Fatalf("expected JSON pretty mode before paging, got %v", m.readerMode) + } updated, cmd := m.handleReaderKey("n") if cmd == nil { @@ -1962,6 +1965,12 @@ func TestReaderUsesLoadedPreviewOffsetWhenPagingPastVisibleWindow(t *testing.T) if got, ok := m.readerCurrentValue(); !ok || got != "short" { t.Fatalf("expected reader to show next row from loaded preview, got %q ok=%v", got, ok) } + if m.readerMode != readerModeRaw { + t.Fatalf("expected reader mode to refresh to raw for loaded next row, got %v", m.readerMode) + } + if got := m.statusMsg; got != "Reader format unavailable for this row; using raw" { + t.Fatalf("expected fallback status after paged row move, got %q", got) + } if m.readerHorizOff != 0 { t.Fatalf("expected horizontal offset to clamp before preview reload, got %d", m.readerHorizOff) } From 8fe9698834209c6d7ddd69d6cb1ce1ada90c62f1 Mon Sep 17 00:00:00 2001 From: Robin Ince Date: Wed, 25 Mar 2026 19:55:51 +0000 Subject: [PATCH 6/6] Document JSON reader updates and add sample parquet fixture --- CHANGELOG.md | 13 ++++++++ README.md | 16 +++++++++- internal/ui/model.go | 14 ++++----- testdata/gen_test_data.go | 44 +++++++++++++++++++++++++++- testdata/json_reader_sample.parquet | Bin 0 -> 2291 bytes 5 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 testdata/json_reader_sample.parquet diff --git a/CHANGELOG.md b/CHANGELOG.md index d5dbef5..1537bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Expanded reader content modes with `raw` and `json pretty` views for long cell values +- `F` in the expanded reader to cycle available content modes + +### Changed +- Expanded reader now auto-detects valid JSON object/array strings and opens them in formatted JSON mode by default + +### Fixed +- Expanded reader now refreshes content mode correctly when moving across rows, including paged row moves +- Expanded reader caches formatted render data so large JSON cells do not re-run formatting and highlighting on every render pass + ## [1.4.0] - 2026-03-24 ### Added diff --git a/README.md b/README.md index 33ecd56..4047a92 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Key features: - predicate filtering - visual indication of missing data in rows and columns, jump to next missing value - missing definition can toggle between NULL+NaN, NULL, NaN -- adaptive column width controls with an expanded reader for long text columns +- adaptive column width controls with an expanded reader for long text columns and structured JSON payloads - resize panels with the mouse - fast mouse scrolling @@ -220,6 +220,13 @@ Predicate notes: Use this overlay for long text cells, JSON payloads, and other values that do not fit comfortably in the table. +The reader supports two content modes: + +- `raw` for the sanitized original cell text +- `json pretty` for valid JSON object/array strings, with formatted indentation and light semantic color + +When a string cell contains valid JSON object/array text, the reader opens in `json pretty` mode by default. Otherwise it opens in `raw`. + | Key | Action | | --- | --- | | `Up`, `k` | Scroll up | @@ -227,6 +234,7 @@ Use this overlay for long text cells, JSON payloads, and other values that do no | `Left`, `h` | Pan left when wrap is off | | `Right`, `l` | Pan right when wrap is off | | `n`, `p` | Move to next/previous row in the same column | +| `F` | Cycle reader format (`raw` / `json pretty` when available) | | `W` | Toggle wrap | | `Space`, `Ctrl+F` | Page down | | `Ctrl+B` | Page up | @@ -236,6 +244,12 @@ Use this overlay for long text cells, JSON payloads, and other values that do no | `G` | Jump to bottom of current cell | | `Esc`, `q`, `w` | Close expanded reader | +Notes: + +- `json pretty` is available only for valid JSON object/array strings; scalar JSON values stay in `raw`. +- Pretty JSON defaults to wrap off so indentation and horizontal panning stay readable. +- If you move rows with `n` or `p`, the reader keeps the current mode when the next row supports it and falls back to `raw` otherwise. + ### File Picker Overlay | Key | Action | diff --git a/internal/ui/model.go b/internal/ui/model.go index b96a786..945bdbc 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1014,7 +1014,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.clampTableRowCursor() if m.overlay == OverlayCellReader { m.readerAbsRow = m.tableOffset + m.tableRowCursor - if m.refreshReaderModes(true) { + if m.refreshReaderModes() { m.statusMsg = "Reader format unavailable for this row; using raw" } m.clampReaderOffsets() @@ -2463,7 +2463,7 @@ func (m *Model) invalidateReaderRenderData() { m.readerRenderM = readerModeRaw } -func (m *Model) refreshReaderModes(preferCurrent bool) bool { +func (m *Model) refreshReaderModes() bool { value, ok := m.readerCurrentValue() prevMode := m.readerMode prevModes := m.readerModes @@ -2475,7 +2475,7 @@ func (m *Model) refreshReaderModes(preferCurrent bool) bool { } m.readerModes = readerModesForValue(value) - if preferCurrent && slices.Contains(m.readerModes, prevMode) { + if slices.Contains(m.readerModes, prevMode) { m.readerMode = prevMode if !slices.Equal(prevModes, m.readerModes) { m.invalidateReaderRenderData() @@ -2487,7 +2487,7 @@ func (m *Model) refreshReaderModes(preferCurrent bool) bool { if prevMode != m.readerMode || !slices.Equal(prevModes, m.readerModes) { m.invalidateReaderRenderData() } - return preferCurrent && prevMode != readerModeRaw && m.readerMode == readerModeRaw + return prevMode != readerModeRaw && m.readerMode == readerModeRaw } func (m *Model) cycleReaderMode() { @@ -2970,7 +2970,7 @@ func (m Model) moveReaderRow(delta int) (tea.Model, tea.Cmd) { } m.tableOffset++ m.readerAbsRow = m.currentAbsoluteRow() - if m.refreshReaderModes(true) { + if m.refreshReaderModes() { m.statusMsg = "Reader format unavailable for this row; using raw" } m.clampReaderOffsets() @@ -2986,7 +2986,7 @@ func (m Model) moveReaderRow(delta int) (tea.Model, tea.Cmd) { } m.tableOffset-- m.readerAbsRow = m.currentAbsoluteRow() - if m.refreshReaderModes(true) { + if m.refreshReaderModes() { m.statusMsg = "Reader format unavailable for this row; using raw" } m.clampReaderOffsets() @@ -2995,7 +2995,7 @@ func (m Model) moveReaderRow(delta int) (tea.Model, tea.Cmd) { } m.readerAbsRow = m.currentAbsoluteRow() - if m.refreshReaderModes(true) { + if m.refreshReaderModes() { m.statusMsg = "Reader format unavailable for this row; using raw" } m.clampReaderOffsets() diff --git a/testdata/gen_test_data.go b/testdata/gen_test_data.go index 61ee95f..6344ec1 100644 --- a/testdata/gen_test_data.go +++ b/testdata/gen_test_data.go @@ -60,5 +60,47 @@ func main() { os.Exit(1) } - fmt.Println("Generated testdata/sample.parquet and testdata/sample.csv") + _, err = db.Exec(` + COPY ( + SELECT * FROM ( + VALUES + ( + 1, + 'compact_object', + '{"event":"signup","user":{"id":101,"plan":"pro"},"tags":["alpha","beta"],"active":true}', + 'Short plain-text row for raw reader comparison.' + ), + ( + 2, + 'long_object', + '{"event":"page_view","path":"/atlas/studies/session/42","body":"This payload is intentionally long so the expanded reader has something real to format and pan across.","metrics":{"duration_ms":1834,"scroll_depth":0.82,"retry_count":2},"flags":[true,false,null],"notes":["first","second","third"]}', + 'This is an intentionally long plain string column entry to compare wrap behavior against the JSON payload column in the expanded reader.' + ), + ( + 3, + 'json_array', + '[{"step":"extract","ok":true},{"step":"transform","ok":true},{"step":"load","ok":false,"reason":"network timeout"}]', + 'Another regular string row with no JSON semantics.' + ), + ( + 4, + 'invalid_json', + '{"broken": true', + 'Invalid JSON in payload should stay in raw mode.' + ), + ( + 5, + 'plain_text', + 'This payload column is just prose, not JSON. It should open in raw mode and still support row-to-row reader navigation.', + 'Final plain string value.' + ) + ) AS t(id, scenario, payload, description) + ) TO 'testdata/json_reader_sample.parquet' (FORMAT PARQUET); + `) + if err != nil { + fmt.Fprintf(os.Stderr, "generate json reader parquet: %v\n", err) + os.Exit(1) + } + + fmt.Println("Generated testdata/sample.parquet, testdata/sample.csv, and testdata/json_reader_sample.parquet") } diff --git a/testdata/json_reader_sample.parquet b/testdata/json_reader_sample.parquet new file mode 100644 index 0000000000000000000000000000000000000000..33cb9a7556028fa3a62d36b5deadd0a506d9f2ae GIT binary patch literal 2291 zcmds3O>Y}T7~VB@N!+BR5^J2c64Oy*CDLXSr*0LZNT?`6v{WT(Dq1N-X1qJ`PO{!v z&CJ@dLhd~R7dW9FP=5lP;06cOAHZ+mlmq9AXZ(?mssb(`f#mhh$2;%G^FHr8yK(zL z-E+K#cgy$67@YIYInL$l1;=s9u8V)S?7xOr5l;yZM4q|cQ}>kg=4V6c3)l{2Cu5;$ z%GNFq&3UXOGHp*^jLI19YMC~fQmj9PnMk`V5mD2vH0)-UFp)M5-#0MvZQ1*6@|1}$ zOGkWYoWG)sRb)X2dh=_)@U4 z>{Epk{bne0i`p^(*-3_o*uecAmT2y$d6EEc0y%CW9@g}xXZR4Q+{kNSrEi%+ET^?D4TTIJqKYWmK)01kC7}4P} zH}r7(;_M63QgV+YiR9Y%Z}edEVbFDtL@1NIlUjUtb8QF`4BrX7CU-YKx*w1`hUi%4 zNknAEA(s-xdZZ&GK0s0%kt9UtStgYsO7<3vT)=Cbij;LldjM$w9)4Z&el4Ej;q$`L z){uvbaA-5OItIjDu*9|MY}N)AN2whYgc4wW@WjSoIf5VZ`3ibu?uUVpl_cb{_7Q!FDb9>YgiVQo|lAh3+Gu^)49`rV>POIJ>4Hu=@PU>VkL1Ev^!Q@oug zOkLV{f2#I`i2>i_gAL6)415iBumssV!}6TGG64uLWpg7?wPAhtt@Sac!3;#-7ohn3 zd}Hi7>N@9vSD5+arc<4c@^CL|HFX$6zc=A{h1o*2Afj37cvBco=#ZyO2|0`IVi#T? z)zEl;KH}iC$SlvXRw>V1ow-+YuEK!l%ve=kp;E4VTzOoZSgRIom#P+R>#8_&r)F5N zho%OHj*SkEK6fiWyR}c(s^ya|RK0MvmqFQ|?4D6R4q%}BmvZIva_w1Rt@`L6>RZ*` zna=a?6$l{t-^Fw$k*AqANal~F%8O#{yQ#J66ZbDl*Po`K7WRTeaP2RNce=a8lsN+