diff --git a/.gitignore b/.gitignore index de46a2b..c51cd77 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ .rtk/ .aider* .headroom/ -.claude/.headroom_wrap_marker.json +.claude/ # OS / editor .DS_Store diff --git a/internal/tui/screens/settings/detail.go b/internal/tui/screens/settings/detail.go new file mode 100644 index 0000000..707ce61 --- /dev/null +++ b/internal/tui/screens/settings/detail.go @@ -0,0 +1,240 @@ +package settings + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/z19r/tihole/internal/theme" +) + +const ( + // detailBreakpoint is the minimum inner width at which the config view + // splits into a table plus a side detail panel; below it the panel stacks + // beneath the table instead. + detailBreakpoint = 88 + detailMinW = 34 + detailMaxW = 60 + detailLabelW = 8 + detailMinBodyH = 8 + // detailMinPanelW is the narrowest width worth drawing a bordered panel in + // (2 border + 4 padding + a little content). Below it we drop the panel + // rather than render wider than the container and wrap the whole layout. + detailMinPanelW = 12 +) + +// configSplit divides the inner width between the config table and the detail +// panel. Wide terminals get a side-by-side split; narrow ones keep a full-width +// table and stack the panel beneath it (so detailW is the full width). +func configSplit(w int) (tableW, detailW int, horizontal bool) { + if w < detailBreakpoint { + return w, w, false + } + detailW = clampInt(w*2/5, detailMinW, detailMaxW) + tableW = w - detailW - 1 // one-column surface gutter between the panes + if tableW < 40 { + tableW = 40 + detailW = w - tableW - 1 + } + return tableW, detailW, true +} + +// showDetail reports whether there is enough vertical room to render the detail +// panel at all. +func showDetail(bodyH int) bool { return bodyH >= detailMinBodyH } + +// detailStackH is the height of the detail panel when it stacks beneath the +// table on narrow terminals, always leaving a few rows for the table itself. +func detailStackH(bodyH int) int { + // Reserve enough rows for the description plus the value block, while + // always + // leaving the table a few rows of its own. + h := clampInt(bodyH-6, 9, 16) + if h > bodyH-3 { + h = bodyH - 3 + } + return clampMin(h, 1) +} + +// treeHeight returns the height to give the config table, reserving room for a +// stacked detail panel on narrow terminals. +func treeHeight(w, bodyH int) int { + if !showDetail(bodyH) { + return bodyH + } + if _, _, horizontal := configSplit(w); horizontal { + return bodyH + } + return bodyH - detailStackH(bodyH) +} + +// renderLeafDetail draws the metadata panel for a single config leaf, filling a +// w×h surface block so it aligns cleanly beside or beneath the table. It +// mirrors what Pi-hole's web UI shows per setting: description, accepted input, +// default, +// current value, enumerated options and whether the value has been changed. +func renderLeafDetail(th *theme.Theme, l leaf, w, h int) string { + // Too narrow (or short) to draw a bordered panel without overflowing the + // container and wrapping the layout: yield a blank surface block instead. + if w < detailMinPanelW || h < 3 { + return lipgloss.Place( + w, h, lipgloss.Left, lipgloss.Top, "", surfaceWhitespace(th), + ) + } + // The panel (border + padding) must never render wider than the width it is + // placed into: on a narrow, stacked layout the outer surface render would + // wrap the overflow and shift every row below it. Cap against w. + panelW := clampInt(w-4, detailMinPanelW, detailMaxW) + if panelW > w { + panelW = w + } + // Border adds 2 columns, the horizontal padding (1,2) adds 4; the text sits + // in what's left, and the panel is 4 rows taller than its content (2 border + // + 2 vertical padding). + innerW := clampMin(panelW-6, 1) + maxContent := clampMin(h-4, 1) + + header := []string{ + th.AccentStyle().Bold(true).Render(truncate(l.path, innerW)), + th.SubtleStyle().Render(strings.Repeat("─", innerW)), + } + + meta := []string{ + detailRow(th, "Type", typeLabel(l), th.TextStyle(), innerW), + detailRow(th, "Default", stringifyValue(l.defaultVal), + th.SubtleStyle(), innerW), + detailRow(th, "Current", stringifyValue(l.value), + valueStyle(th, l.value), innerW), + } + + var allowed []string + if items := allowedInputs(l); len(items) > 0 { + options := lipgloss.NewStyle().Width(innerW). + Foreground(th.Text).Render(strings.Join(items, " · ")) + allowed = []string{"", th.SubtleStyle().Render("Allowed"), options} + } + + statusVal := th.SubtleStyle().Render("unchanged from default") + if l.modified { + statusVal = th.WarnStyle().Bold(true).Render("modified") + } + status := []string{"", detailRowRaw(th, "Status", statusVal)} + + // Assemble within the height budget. The description is the headline the + // user came for, so it takes priority: it claims the rows it needs (capped + // so the value block still fits when there's room for both), and the + // Allowed/Status blocks are what drop first when space is tight. + lines := append([]string{}, header...) + used := len(header) + if desc := strings.TrimSpace(l.description); desc != "" { + budget := maxContent - used - 1 // 1 for the trailing blank + if budget > len(meta)+2 { + budget -= len(meta) // leave room for the value block too + } + if budget >= 1 { + descLines := wrapLines(th, desc, innerW, budget) + lines = append(lines, descLines...) + lines = append(lines, "") + used += len(descLines) + 1 + } + } + fits := func(block []string) bool { return used+len(block) <= maxContent } + if fits(meta) { + lines = append(lines, meta...) + used += len(meta) + } + if len(allowed) > 0 && fits(allowed) { + lines = append(lines, allowed...) + used += len(allowed) + } + if fits(status) { + lines = append(lines, status...) + } + + panel := th.PanelStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(th.Border). + Padding(1, 2). + Width(panelW). + MaxHeight(h). + Render(strings.Join(lines, "\n")) + + return lipgloss.Place( + w, h, lipgloss.Left, lipgloss.Top, panel, surfaceWhitespace(th), + ) +} + +// wrapLines word-wraps s to width w and returns at most max lines, appending an +// ellipsis to the last line when the text is clipped. It wraps and truncates on +// the plain text, then applies the foreground, so styling never straddles a cut +// escape sequence. +func wrapLines(th *theme.Theme, s string, w, max int) []string { + all := strings.Split(lipgloss.NewStyle().Width(w).Render(s), "\n") + if len(all) > max { + all = all[:max] + last := strings.TrimRight(all[max-1], " ") + all[max-1] = truncate(last, clampMin(w-2, 1)) + " " + ellipsis + } + st := lipgloss.NewStyle().Foreground(th.Text) + for i := range all { + all[i] = st.Render(all[i]) + } + return all +} + +// detailRow renders a "label value" line, styling each part and truncating +// the value to fit the panel width. +func detailRow( + th *theme.Theme, label, value string, vs lipgloss.Style, panelW int, +) string { + lbl := th.SubtleStyle().Render(fmt.Sprintf("%-*s", detailLabelW, label)) + avail := clampMin(panelW-detailLabelW-1, 4) + return lbl + " " + vs.Render(truncate(value, avail)) +} + +// detailRowRaw renders a labeled line whose value is already styled. +func detailRowRaw(th *theme.Theme, label, rendered string) string { + lbl := th.SubtleStyle().Render(fmt.Sprintf("%-*s", detailLabelW, label)) + return lbl + " " + rendered +} + +// typeLabel returns a human description of the leaf's accepted input, +// preferring +// FTL's own type string and falling back to the Go value's kind. +func typeLabel(l leaf) string { + if s := strings.TrimSpace(l.dataType); s != "" { + return s + } + switch l.value.(type) { + case bool: + return "boolean (true · false)" + case float64, int, int64: + return "number" + case string: + return "text" + case []any: + return "list" + default: + return "value" + } +} + +// allowedInputs extracts the enumerated allowed values for a leaf, if FTL +// provided them. Each entry may be a bare scalar or a {item,description} map. +func allowedInputs(l leaf) []string { + if len(l.allowed) == 0 { + return nil + } + out := make([]string, 0, len(l.allowed)) + for _, a := range l.allowed { + if m, ok := a.(map[string]any); ok { + if item, ok := m["item"]; ok { + out = append(out, stringifyValue(item)) + } + continue + } + out = append(out, stringifyValue(a)) + } + return out +} diff --git a/internal/tui/screens/settings/settings.go b/internal/tui/screens/settings/settings.go index 358d539..258b332 100644 --- a/internal/tui/screens/settings/settings.go +++ b/internal/tui/screens/settings/settings.go @@ -174,8 +174,9 @@ func (m *Model) Help() []key.Binding { func (m *Model) SetSize(w, h int) { m.w, m.h = w, h - m.tree.SetColumns(treeColumns(computeTreeWidths(w))) - m.tree.SetWidth(w) + tableW, _, _ := configSplit(w) + m.tree.SetColumns(treeColumns(computeTreeWidths(tableW))) + m.tree.SetWidth(tableW) m.connTable.SetColumns(connColumns(computeConnWidths(w))) m.connTable.SetWidth(w) @@ -183,7 +184,7 @@ func (m *Model) SetSize(w, h int) { if bodyH < 1 { bodyH = 1 } - m.tree.SetHeight(bodyH) + m.tree.SetHeight(treeHeight(w, bodyH)) // Reserve two lines for the theme summary under the instances table. connH := bodyH - 2 if connH < 1 { @@ -458,7 +459,8 @@ func (m *Model) applyFilter() { // syncTreeRows rebuilds the tree table rows from the visible set and theme, // clamping the cursor to the new bounds. func (m *Model) syncTreeRows() { - widths := computeTreeWidths(m.w) + tableW, _, _ := configSplit(m.w) + widths := computeTreeWidths(tableW) idx := m.tree.Cursor() m.tree.SetStyles(components.TableStyles(m.ctx.Theme)) m.tree.SetRows(treeRows(m.ctx.Theme, m.visible, widths)) @@ -588,7 +590,33 @@ func (m *Model) renderBody(th *theme.Theme) string { surfaceWhitespace(th), ) } - return m.tree.View() + return m.renderConfigTree(th, bodyH) +} + +// renderConfigTree composes the config table with a live detail panel for the +// highlighted leaf: side-by-side on wide terminals, stacked on narrow ones, and +// table-only when there's no vertical room or no current selection. +func (m *Model) renderConfigTree(th *theme.Theme, bodyH int) string { + tableView := m.tree.View() + l, ok := m.selectedLeaf() + if !ok || !showDetail(bodyH) { + return tableView + } + + tableW, detailW, horizontal := configSplit(m.w) + if horizontal { + panel := renderLeafDetail(th, l, detailW, bodyH) + gutter := lipgloss.NewStyle().Background(th.Surface). + Width(1).Height(bodyH).Render("") + table := lipgloss.Place( + tableW, bodyH, lipgloss.Left, lipgloss.Top, + tableView, surfaceWhitespace(th), + ) + return lipgloss.JoinHorizontal(lipgloss.Top, table, gutter, panel) + } + + panel := renderLeafDetail(th, l, m.w, detailStackH(bodyH)) + return strings.Join([]string{tableView, panel}, "\n") } func (m *Model) renderFooter(th *theme.Theme) string { diff --git a/internal/tui/screens/settings/settings_test.go b/internal/tui/screens/settings/settings_test.go index 813c9c3..c1349cb 100644 --- a/internal/tui/screens/settings/settings_test.go +++ b/internal/tui/screens/settings/settings_test.go @@ -7,6 +7,7 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/z19r/tihole/internal/config" "github.com/z19r/tihole/internal/pihole" @@ -174,6 +175,113 @@ func TestFlattenConfigCollapsesDetailedLeaves(t *testing.T) { } } +func TestFlattenConfigCapturesLeafMetadata(t *testing.T) { + // Arrange: a detailed leaf carrying the full metadata set FTL returns. + tree := map[string]any{ + "dns": map[string]any{ + "blocking": map[string]any{ + "mode": map[string]any{ + "value": "NULL", + "type": "string", + "description": "How blocked queries are answered", + "default": "NULL", + "modified": true, + "allowed": []any{ + map[string]any{ + "item": "NULL", + "description": "0.0.0.0", + }, + map[string]any{"item": "NXDOMAIN"}, + "IP", + }, + }, + }, + }, + } + + // Act + leaves := flattenConfig(tree) + + // Assert + if len(leaves) != 1 { + t.Fatalf("expected 1 leaf, got %d: %+v", len(leaves), leaves) + } + l := leaves[0] + if l.description != "How blocked queries are answered" { + t.Fatalf("description not captured: %q", l.description) + } + if l.dataType != "string" { + t.Fatalf("type not captured: %q", l.dataType) + } + if l.defaultVal != "NULL" { + t.Fatalf("default not captured: %v", l.defaultVal) + } + if !l.modified { + t.Fatalf("modified flag not captured") + } + if got := allowedInputs(l); len(got) != 3 || + got[0] != "NULL" || got[1] != "NXDOMAIN" || got[2] != "IP" { + t.Fatalf("allowed inputs not captured: %v", got) + } +} + +func TestRenderLeafDetailShowsMetadata(t *testing.T) { + // Arrange + th := theme.Gloss() + l := leaf{ + path: "dns.blocking.mode", + value: "NULL", + description: "How blocked queries are answered", + dataType: "string", + defaultVal: "NULL", + allowed: []any{map[string]any{"item": "NXDOMAIN"}}, + modified: true, + } + + // Act + out := renderLeafDetail(th, l, 48, 20) + + // Assert + for _, want := range []string{ + "dns.blocking.mode", "How blocked queries", + "Type", "Default", "Current", "Allowed", "NXDOMAIN", "modified", + } { + if !strings.Contains(out, want) { + t.Fatalf("detail panel missing %q in:\n%s", want, out) + } + } +} + +func TestRenderLeafDetailNeverExceedsNarrowWidth(t *testing.T) { + // Arrange: a narrow (sub-24-col) but tall stacked layout, where the old + // 20-col panelW floor would render wider than the container and make the + // outer surface wrap — shifting every row below the panel. + th := theme.Gloss() + l := leaf{ + path: "dns.blocking.mode", + value: "NULL", + description: "How blocked queries are answered by the resolver", + dataType: "string", + defaultVal: "NULL", + modified: true, + } + + // Act + for _, w := range []int{4, 8, 14, 20, 23} { + out := renderLeafDetail(th, l, w, 16) + + // Assert: no rendered line is wider than the allotted width. + for i, line := range strings.Split(out, "\n") { + if lw := lipgloss.Width(line); lw > w { + t.Fatalf( + "w=%d line %d width %d exceeds container:\n%s", + w, i, lw, out, + ) + } + } + } +} + func TestParseLeafValueRoundTripsByType(t *testing.T) { // Arrange / Act / Assert — bool b, err := parseLeafValue(true, "false") diff --git a/internal/tui/screens/settings/tree.go b/internal/tui/screens/settings/tree.go index 0a31980..49f9bc4 100644 --- a/internal/tui/screens/settings/tree.go +++ b/internal/tui/screens/settings/tree.go @@ -15,10 +15,18 @@ import ( ) // leaf is a single scalar entry in the flattened config tree, addressed by its -// dotted path (e.g. "dns.blocking.active"). +// dotted path (e.g. "dns.blocking.active"). When the tree is fetched with +// detailed=true, FTL annotates each leaf with the same metadata Pi-hole's web +// UI renders — description, type, default, allowed values and a modified flag — +// which we carry through for the detail panel. type leaf struct { - path string - value any + path string + value any + description string + dataType string + defaultVal any + allowed []any + modified bool } // flattenConfig walks a nested config tree into a sorted, flat list of scalar @@ -42,7 +50,7 @@ func walkTree(prefix string, m map[string]any, out *[]leaf) { } if child, ok := v.(map[string]any); ok { if isDetailLeaf(child) { - *out = append(*out, leaf{path: path, value: child["value"]}) + *out = append(*out, newDetailLeaf(path, child)) } else { walkTree(path, child, out) } @@ -52,13 +60,36 @@ func walkTree(prefix string, m map[string]any, out *[]leaf) { } } +// newDetailLeaf builds a leaf from a detailed descriptor map, carrying through +// whatever metadata FTL supplied. Missing keys leave their zero values. +func newDetailLeaf(path string, m map[string]any) leaf { + l := leaf{path: path, value: m["value"]} + if s, ok := m["description"].(string); ok { + l.description = s + } + if s, ok := m["type"].(string); ok { + l.dataType = s + } + if d, ok := m["default"]; ok { + l.defaultVal = d + } + if a, ok := m["allowed"].([]any); ok { + l.allowed = a + } + if b, ok := m["modified"].(bool); ok { + l.modified = b + } + return l +} + // isDetailLeaf reports whether a map is a detailed-leaf descriptor (carries a // "value" plus at least one metadata key) rather than a nested config object. func isDetailLeaf(m map[string]any) bool { if _, ok := m["value"]; !ok { return false } - for _, k := range []string{"type", "description", "default", "flags", "modified"} { + meta := []string{"type", "description", "default", "flags", "modified"} + for _, k := range meta { if _, ok := m[k]; ok { return true } diff --git a/site/index.html b/site/index.html index 3d83715..6e4a57f 100644 --- a/site/index.html +++ b/site/index.html @@ -29,8 +29,42 @@
- - +