diff --git a/service/values_service.go b/service/values_service.go index c984e5e..28ac5ca 100644 --- a/service/values_service.go +++ b/service/values_service.go @@ -22,11 +22,17 @@ const ( saveFilePerm = 0o644 ) +// TypeNull is the Type-tag string the flattener emits for `key: ~` / +// `key: null` leaves. Exported so UI-layer code can compare against it +// (detecting nullified leaves on file load, serializing nullified keys +// back to `~`, rendering the "null" pill label) without re-declaring the +// literal in every consumer package. +const TypeNull = "null" + const ( typeString = "string" typeBool = "bool" typeNumber = "number" - typeNull = "null" typeUnknown = "unknown" typeMap = "map" typeList = "list" @@ -817,7 +823,7 @@ func flattenValues(source string, vals map[string]any) *domain.ValuesFile { // pass that literal string through to the YAML encoder, replacing // the original `null` / `~` with the four-character "". // Empty-string Value with Type=null round-trips through - // convertValue's typeNull case back to a real nil. + // convertValue's TypeNull case back to a real nil. if typedVal == nil { value = "" } @@ -882,7 +888,7 @@ func inferType(v any) string { case int, int64, float64: return typeNumber case nil: - return typeNull + return TypeNull default: return typeUnknown } diff --git a/service/values_types.go b/service/values_types.go index 8757887..38b3cda 100644 --- a/service/values_types.go +++ b/service/values_types.go @@ -134,6 +134,14 @@ func (e FlatValueEntry) IsFocusable() bool { return !e.IsSection() && !e.IsComment() } +// IsNullLeaf reports whether this entry is an explicit-null scalar (the +// flattener emits Type="null" for `key: ~` or `key: null` in the source). +// Used by the UI layer to detect overlay-side nullifications without +// stringly-comparing the package-private type tag. +func (e FlatValueEntry) IsNullLeaf() bool { + return e.Kind == EntryKindLeaf && e.Type == TypeNull +} + // IsEmptyContainer reports whether this entry is a container key with no // children — flattenValues emits "{}" for an empty mapping and "[]" for an // empty sequence, distinguishable from a populated-container section header diff --git a/service/yaml_serialize.go b/service/yaml_serialize.go index cb06f4a..4d885a0 100644 --- a/service/yaml_serialize.go +++ b/service/yaml_serialize.go @@ -312,12 +312,12 @@ func PatchNodeTree(root *yaml.Node, entries []OverrideEntry, indent int, docs Do // scalarsEquivalent reports whether the tree's effective scalar text and the // override entry's value mean the same thing for round-trip purposes. For -// typeNull the three YAML representations (`null`, `~`, blank `""`) all encode +// TypeNull the three YAML representations (`null`, `~`, blank `""`) all encode // the same value, so treating them as equal here keeps PatchNodeTree's unedited // branch from rewriting (and re-styling) a null leaf the user didn't touch. // All other types fall back to literal string equality. func scalarsEquivalent(treeValue, entryValue, entryType string) bool { - if entryType == typeNull && isYAMLNullLiteral(treeValue) && isYAMLNullLiteral(entryValue) { + if entryType == TypeNull && isYAMLNullLiteral(treeValue) && isYAMLNullLiteral(entryValue) { return true } @@ -325,7 +325,7 @@ func scalarsEquivalent(treeValue, entryValue, entryType string) bool { } func isYAMLNullLiteral(s string) bool { - return s == "" || s == "~" || s == typeNull + return s == "" || s == "~" || s == TypeNull } // applyDocFoots writes each (leafKey -> footText) pair as the FootComment of diff --git a/service/yaml_serialize_test.go b/service/yaml_serialize_test.go index b8a11a9..c9fb662 100644 --- a/service/yaml_serialize_test.go +++ b/service/yaml_serialize_test.go @@ -366,8 +366,8 @@ keep: x // Mimic flattenValues' nil handling — Value="" for both null entries. entries := []OverrideEntry{ - {Key: "usePasswordFiles", Value: "", Type: typeNull}, - {Key: "explicitNull", Value: "", Type: typeNull}, + {Key: "usePasswordFiles", Value: "", Type: TypeNull}, + {Key: "explicitNull", Value: "", Type: TypeNull}, {Key: keepKey, Value: "x", Type: typeString}, } diff --git a/service/yaml_tree.go b/service/yaml_tree.go index d9cc362..a34ab9b 100644 --- a/service/yaml_tree.go +++ b/service/yaml_tree.go @@ -656,7 +656,7 @@ func descendSequence(current *yaml.Node, seg, next keySegment) (*yaml.Node, erro for len(current.Content) <= seg.index { current.Content = append(current.Content, &yaml.Node{ - Kind: yaml.ScalarNode, Tag: yamlTagNull, Value: typeNull, + Kind: yaml.ScalarNode, Tag: yamlTagNull, Value: TypeNull, }) } @@ -711,7 +711,7 @@ func setLeaf(current *yaml.Node, seg keySegment, value any) error { for len(current.Content) <= seg.index { current.Content = append(current.Content, &yaml.Node{ - Kind: yaml.ScalarNode, Tag: yamlTagNull, Value: typeNull, + Kind: yaml.ScalarNode, Tag: yamlTagNull, Value: TypeNull, }) } @@ -791,8 +791,8 @@ func convertValue(value, typ string) any { return b } - case typeNull: - if value == typeNull || value == "~" || value == "" { + case TypeNull: + if value == TypeNull || value == "~" || value == "" { return nil } } diff --git a/ui/page/values_controller.go b/ui/page/values_controller.go index 60579bf..8e1261e 100644 --- a/ui/page/values_controller.go +++ b/ui/page/values_controller.go @@ -216,6 +216,7 @@ func (vc *ValuesController) Callbacks() ValuesPageCallbacks { OnUnlockCell: vc.onUnlockCell, OnAnchorRename: vc.onAnchorRename, OnAnchorDelete: vc.onAnchorDelete, + OnCellNullify: vc.onCellNullify, } } @@ -629,9 +630,15 @@ func (vc *ValuesController) populateColumnOverrides(colIdx int) { return } + // Fresh load: NullifiedKeys from a previous file are no longer + // applicable. The disk-authoritative pass below re-seeds the map + // from any `key: ~` entries it finds. + col.NullifiedKeys = nil + // Build lookup from custom values (flat key match). customMap := make(map[string]string, len(col.CustomValues.Entries)) commentMap := make(map[string]string, len(col.CustomValues.Entries)) + nullKeys := make(map[string]bool) for _, e := range col.CustomValues.Entries { // Include scalar-leaf entries unconditionally — even when Value is @@ -648,6 +655,16 @@ func (vc *ValuesController) populateColumnOverrides(colIdx int) { if e.Comment != "" { commentMap[e.Key] = e.Comment } + + // Record explicit-null leaves so the UI can render them as the + // "null" pill instead of plain "~" / empty editor text. An + // overlay that nulls a key the chart declares as a mapping + // (`section: ~`) is flattened as a leaf with Type="null", so + // this catches both leaf-level and section-level nullifications + // in a single pass. + if e.IsNullLeaf() { + nullKeys[e.Key] = true + } } col.EnsureEditors(len(vc.State.Entries)) @@ -694,6 +711,25 @@ func (vc *ValuesController) populateColumnOverrides(colIdx int) { } col.RebuildOverrideFlags() + + // Seed NullifiedKeys for every key the overlay marked as explicit + // null on disk, and force their override flag so the row tint + // reflects the user's intent. The flag wouldn't survive + // RebuildOverrideFlags on its own — null leaves carry empty editor + // text, which RebuildOverrideFlags treats as "no override". + if len(nullKeys) > 0 { + for i, entry := range vc.State.Entries { + if i >= len(col.OverrideEditors) { + break + } + + if nullKeys[entry.Key] { + col.MarkNullified(entry.Key) + col.MarkOverride(i, true) + } + } + } + col.DrainPendingChanges = true col.ValuesModified = false } diff --git a/ui/page/values_controller_actions.go b/ui/page/values_controller_actions.go index 9efa808..4d826e2 100644 --- a/ui/page/values_controller_actions.go +++ b/ui/page/values_controller_actions.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" "gioui.org/layout" @@ -251,7 +252,7 @@ func (vc *ValuesController) onSaveColumnValues(colIdx int) { } yamlText, err := state.OverridesToYAML( - vc.State.Entries, col.OverrideEditors, col.YAMLIndent(), tree, docs, loadedValues, + vc.State.Entries, col.OverrideEditors, col.YAMLIndent(), tree, docs, loadedValues, col.NullifiedKeys, ) if err != nil { vc.NotifState.Show(err.Error(), state.NotificationError, time.Now()) @@ -737,6 +738,317 @@ func (vc *ValuesController) onAnchorRename(colIdx int, oldName, newName string) } } +// onCellNullify is the OnCellNullify callback handler. It marks the cell at +// flatKey in column colIdx as explicitly null, clears any anchor reach the +// cell participates in (so dangling aliases don't survive the save), and +// republishes the column's YAML through the same path commitOverrideUpdate +// uses for normal edits. A second click on an already-nullified cell flips +// it back to the chart default. +// +// Section rows take a different path: the entire subtree gets dropped from +// the override file and the section becomes a single null leaf at its key. +// All anchors declared inside the subtree are deleted so external aliases +// pointing in are severed (each former alias keeps a literal copy of its +// last resolved value). +func (vc *ValuesController) onCellNullify(colIdx int, flatKey string) { + if colIdx < 0 || colIdx >= state.MaxCustomColumns || flatKey == "" { + return + } + + col := &vc.State.Columns[colIdx] + + entryIdx, entry, found := vc.findEntry(flatKey) + if !found { + return + } + + switch { + case col.IsNullifiedDirect(flatKey): + // Direct flag: toggle off — clear the editor and the flag. + vc.unnullifyCell(col, entryIdx, entry) + case col.IsNullifiedCovered(flatKey): + // Covered by an ancestor section nullify: clicking the button on + // a descendant breaks out of the section's null reach so this + // cell becomes editable. The ancestor's other descendants stay + // covered until the user toggles each one explicitly. + col.ClearNullified(flatKey) + default: + vc.nullifyCell(col, entryIdx, entry, flatKey) + } + + col.ValuesModified = true + + vc.publishColumnYAML(col, colIdx) + + if vc.Window != nil { + vc.Window.Invalidate() + } +} + +// findEntry returns the index, entry, and presence flag for the given flat +// key in the unified entry list. Linear scan; same approach as +// cellValueAndType — Entries is small and the call sites are user-driven. +func (vc *ValuesController) findEntry(flatKey string) (int, service.FlatValueEntry, bool) { + for i, entry := range vc.State.Entries { + if entry.Key == flatKey { + return i, entry, true + } + } + + return 0, service.FlatValueEntry{}, false +} + +// nullifyCell applies a nullify to either a leaf or a section row. Splits +// here rather than in onCellNullify so the toggle path (unnullifyCell) +// stays narrow and self-contained. +func (vc *ValuesController) nullifyCell( + col *state.CustomColumnState, + entryIdx int, + entry service.FlatValueEntry, + flatKey string, +) { + if entry.IsSection() { + vc.nullifySection(col, flatKey) + + return + } + + vc.severAnchorAtKey(col, flatKey) + + if entryIdx < len(col.OverrideEditors) { + // Preserve whatever head comment the user (or the loaded file) + // has on this cell so the "why is this null" annotation + // survives the toggle. The serializer ignores editor text once + // NullifiedKeys carries the key, but the UI reads the editor + // text to surface the comment next to the null pill. When the + // cell carries no comment yet, fall back to the canonical + // "null" literal so auto-clear's null-text guard still + // recognises the cell as nullified. + ed := &col.OverrideEditors[entryIdx] + + head := state.ExtractLeadingComment(ed.Text()) + if head != "" { + ed.SetText(formatNullCellEditor(head)) + } else if ed.Text() == "" { + ed.SetText(service.TypeNull) + } + + col.MarkOverride(entryIdx, true) + } + + col.MarkNullified(flatKey) +} + +// formatNullCellEditor reconstructs the editor text for a nullified cell +// that carries a head comment. Each comment line keeps its `# ` prefix; +// the value line is the canonical "null" literal so auto-clear's +// null-aware guard treats programmatic SetText events as no-ops. +func formatNullCellEditor(head string) string { + if head == "" { + return service.TypeNull + } + + // Pre-size the buffer: head's bytes + ("# " + "\n") per line plus the + // trailing null literal. strings.Count is one scan instead of two; + // cheaper than re-growing the builder a couple of times for typical + // multi-line head blocks. + const perLineOverhead = len("# ") + len("\n") + + lines := strings.Count(head, "\n") + 1 + + var b strings.Builder + + b.Grow(len(head) + lines*perLineOverhead + len(service.TypeNull)) + + for _, line := range strings.Split(head, "\n") { + b.WriteString("# ") + b.WriteString(line) + b.WriteByte('\n') + } + + b.WriteString(service.TypeNull) + + return b.String() +} + +// unnullifyCell removes the explicit-null flag and reverts the cell (or +// section) to whatever it would have been without the nullify: empty +// editor text → chart default surfaces through. For a section, descendant +// editors stay cleared (we wiped them on nullify) but the user can edit +// them again now that the ancestor flag is gone. +func (vc *ValuesController) unnullifyCell( + col *state.CustomColumnState, + entryIdx int, + entry service.FlatValueEntry, +) { + col.ClearNullified(entry.Key) + + if entry.IsSection() { + return + } + + if entryIdx < len(col.OverrideEditors) { + col.OverrideEditors[entryIdx].SetText("") + col.MarkOverride(entryIdx, false) + } +} + +// nullifySection wipes the entire subtree from the override-file output: +// every descendant editor is cleared, every anchor declared inside the +// subtree is deleted (severing external aliases), and the section flat +// key is added to NullifiedKeys so collectOverrides emits a single +// `key: ~` at that path. +func (vc *ValuesController) nullifySection(col *state.CustomColumnState, sectionKey string) { + vc.severAnchorsInSubtree(col, sectionKey) + + for i, e := range vc.State.Entries { + if i >= len(col.OverrideEditors) { + break + } + + if e.Key == sectionKey || !flatKeyHasAncestor(e.Key, sectionKey) { + continue + } + + col.OverrideEditors[i].SetText("") + col.MarkOverride(i, false) + } + + // Section's own anchor (if it had one — `master: &masterConfig` style) + // is severed too. Re-uses severAnchorAtKey so the Anchors map gets + // re-extracted in one place. + vc.severAnchorAtKey(col, sectionKey) + + col.MarkNullified(sectionKey) +} + +// severAnchorAtKey removes whichever anchor or alias annotation lives on +// the cell at flatKey. Anchor sources call DeleteAnchor (severs every +// alias to a literal copy); aliases call ClearNodeAlias (deep-copies the +// target value into the alias slot in place). Refreshes the Anchors map +// when something changed so badges update next frame. No-op when no +// custom values are loaded or the cell has no anchor annotation. +func (vc *ValuesController) severAnchorAtKey(col *state.CustomColumnState, flatKey string) { + if col.CustomValues == nil || col.CustomValues.NodeTree == nil { + return + } + + info, ok := col.CustomValues.Anchors[flatKey] + if !ok || info.Role == service.AnchorRoleNone { + return + } + + var err error + + switch info.Role { + case service.AnchorRoleAnchor: + err = service.DeleteAnchor(col.CustomValues.NodeTree, info.Name) + case service.AnchorRoleAlias: + err = service.ClearNodeAlias(col.CustomValues.NodeTree, flatKey) + case service.AnchorRoleNone: + } + + if err != nil { + vc.NotifState.Show("Nullify: "+err.Error(), state.NotificationError, time.Now()) + + return + } + + col.CustomValues.Anchors = service.ExtractAnchors(col.CustomValues.NodeTree) +} + +// severAnchorsInSubtree walks the column's Anchors map and removes every +// anchor whose flat key sits inside the subtree rooted at sectionKey +// (excluding the section key itself — that goes through severAnchorAtKey). +// External aliases pointing into the subtree are severed by DeleteAnchor; +// internal alias-only annotations get cleared by ClearNodeAlias so the +// surviving tree has no alias references to nodes that are about to be +// dropped. +func (vc *ValuesController) severAnchorsInSubtree(col *state.CustomColumnState, sectionKey string) { + if col.CustomValues == nil || col.CustomValues.NodeTree == nil || len(col.CustomValues.Anchors) == 0 { + return + } + + // Snapshot keys first — DeleteAnchor mutates the tree in place, and + // ExtractAnchors at the end will rebuild the map; iterating the + // original map while it's being indirectly invalidated is fine, but + // the snapshot makes the loop bound stable. + descendants := make([]string, 0, len(col.CustomValues.Anchors)) + for key := range col.CustomValues.Anchors { + if key != sectionKey && flatKeyHasAncestor(key, sectionKey) { + descendants = append(descendants, key) + } + } + + for _, key := range descendants { + info, ok := col.CustomValues.Anchors[key] + if !ok { + continue + } + + var err error + + switch info.Role { + case service.AnchorRoleAnchor: + err = service.DeleteAnchor(col.CustomValues.NodeTree, info.Name) + case service.AnchorRoleAlias: + err = service.ClearNodeAlias(col.CustomValues.NodeTree, key) + case service.AnchorRoleNone: + } + + if err != nil { + vc.NotifState.Show("Nullify section: "+err.Error(), state.NotificationError, time.Now()) + + return + } + } + + if len(descendants) > 0 { + col.CustomValues.Anchors = service.ExtractAnchors(col.CustomValues.NodeTree) + } +} + +// publishColumnYAML regenerates the column's YAML and routes it through +// onColumnOverrideChanged — the same path commitOverrideUpdate uses on +// editor changes — so the parse-and-republish state machine stays in +// sync after a controller-initiated mutation. +func (vc *ValuesController) publishColumnYAML(col *state.CustomColumnState, colIdx int) { + var ( + tree *yaml.Node + docs service.DocComments + loadedValues map[string]string + ) + + if col.CustomValues != nil { + tree = col.CustomValues.NodeTree + docs = col.DocCommentsForSave() + loadedValues = state.LoadedValuesMap(col.CustomValues) + } + + yamlText, err := state.OverridesToYAML( + vc.State.Entries, col.OverrideEditors, col.YAMLIndent(), + tree, docs, loadedValues, col.NullifiedKeys, + ) + vc.onColumnOverrideChanged(colIdx, yamlText, err) +} + +// flatKeyHasAncestor reports whether `child` has `ancestor` somewhere on +// its parent chain. Honours FlatKey escapes via domain.FlatKey.Parent so +// keys with literal '.' or '[' segments still walk correctly. +func flatKeyHasAncestor(child, ancestor string) bool { + if child == "" || ancestor == "" || child == ancestor { + return false + } + + for k := domain.FlatKey(child).Parent(); k != ""; k = k.Parent() { + if string(k) == ancestor { + return true + } + } + + return false +} + // onAnchorDelete removes an anchor and severs every alias pointing at it. // Editor texts for those cells remain unchanged (PatchNodeTree's equality // check keeps them when they match the now-literal tree value). diff --git a/ui/page/values_page.go b/ui/page/values_page.go index e92a09a..ed15ead 100644 --- a/ui/page/values_page.go +++ b/ui/page/values_page.go @@ -174,6 +174,12 @@ type ValuesPageCallbacks struct { // it, replacing aliases with literal copies of the anchored value so no // data is lost. OnAnchorDelete func(colIdx int, anchorName string) + + // OnCellNullify fires when the user clicks the in-cell nullify button. + // Controller marks the cell (or section) as explicitly null, severs any + // anchor reach the cell participates in, and republishes the column's + // YAML. A second click on an already-nullified cell un-nullifies it. + OnCellNullify func(colIdx int, flatKey string) } // ValuesPage renders the unified override editor: default values on the left, @@ -365,6 +371,7 @@ func (p *ValuesPage) Layout(gtx layout.Context) layout.Dimensions { p.Table.OnCellContextMenu = p.openAnchorMenu p.Table.OnAnchorBadgeClicked = p.openAliasesOfDialog p.Table.OnAnchoredCellEdit = p.openUnlockDialog + p.Table.OnCellNullify = p.OnCellNullify p.Table.SearchQuery = p.Search.Editor.Text() // Build columnEditors slice for search filter. diff --git a/ui/state/editor_state.go b/ui/state/editor_state.go index 4b66967..798dde1 100644 --- a/ui/state/editor_state.go +++ b/ui/state/editor_state.go @@ -2,11 +2,13 @@ package state import ( "fmt" + "slices" "strings" "gioui.org/widget" "gopkg.in/yaml.v3" + "github.com/qdeck-app/qdeck/domain" "github.com/qdeck-app/qdeck/service" ) @@ -53,6 +55,10 @@ func LoadedValuesMap(fv *service.FlatValues) map[string]string { // save — a zero DocComments leaves the output without any banner or foot // blocks. Returns empty string with nil error when no overrides AND no doc // comments are present. +// +// nullified is the column's NullifiedKeys map: each entry whose key is +// directly listed emits as `key: ~`, and every strict-descendant entry is +// dropped so a nullified section produces a single null leaf in the output. func OverridesToYAML( entries []service.FlatValueEntry, editors []widget.Editor, @@ -60,8 +66,9 @@ func OverridesToYAML( tree *yaml.Node, docs service.DocComments, loadedValues map[string]string, + nullified map[string]bool, ) (string, error) { - overrides := collectOverrides(entries, editors, loadedValues) + overrides := collectOverrides(entries, editors, loadedValues, nullified) if len(overrides) == 0 && docs.Head == "" && docs.Foot == "" && len(docs.Foots) == 0 { return "", nil } @@ -80,27 +87,54 @@ func OverridesToYAML( // into separate OverrideEntry fields so the serializer can write the block // above the key and the inline comment next to the value, matching the style // the user typed. +// +// Nullified keys are emitted as `key: ~` (Type=null) regardless of inferred +// type. Strict descendants of any nullified key are dropped — saving a +// nullified section produces a single null at the section's path. Nullified +// keys not represented in entries (e.g. an orphan section the chart doesn't +// declare) are appended at the end so the user's nullify still round-trips. func collectOverrides( entries []service.FlatValueEntry, editors []widget.Editor, loadedValues map[string]string, + nullified map[string]bool, ) []service.OverrideEntry { count := 0 + seenNullified := 0 for i := range entries { if i >= len(editors) { break } + key := entries[i].Key + if hasNullifiedAncestor(nullified, key) { + continue + } + + if nullified[key] { + count++ + seenNullified++ + + continue + } + if !entries[i].IsFocusable() { continue } - if entryHasContent(StripYAMLComments(editors[i].Text()), entries[i].Key, loadedValues) { + if entryHasContent(StripYAMLComments(editors[i].Text()), key, loadedValues) { count++ } } + // Nullified keys with no matching entry (e.g. orphan section paths) still + // need to round-trip. They contribute to the final entry count. + orphanNullified := len(nullified) - seenNullified + if orphanNullified > 0 { + count += orphanNullified + } + if count == 0 { return nil } @@ -112,6 +146,21 @@ func collectOverrides( break } + if hasNullifiedAncestor(nullified, entry.Key) { + continue + } + + if nullified[entry.Key] { + result = append(result, service.OverrideEntry{ + Key: entry.Key, + Value: "~", + Type: service.TypeNull, + HeadComment: entry.Comment, + }) + + continue + } + raw := editors[i].Text() // Round-trip fast path: when the raw editor text matches what @@ -172,9 +221,59 @@ func collectOverrides( }) } + if orphanNullified > 0 { + seen := make(map[string]bool, len(nullified)) + + for i := range entries { + if nullified[entries[i].Key] { + seen[entries[i].Key] = true + } + } + + // Sort orphan keys so the saved YAML is byte-stable across runs. + // Map-range order is randomized; without this, every save would + // shuffle orphan-nullified lines and produce spurious git diffs. + orphans := make([]string, 0, orphanNullified) + + for key := range nullified { + if seen[key] || hasNullifiedAncestor(nullified, key) { + continue + } + + orphans = append(orphans, key) + } + + slices.Sort(orphans) + + for _, key := range orphans { + result = append(result, service.OverrideEntry{ + Key: key, + Value: "~", + Type: service.TypeNull, + }) + } + } + return result } +// hasNullifiedAncestor reports whether any strict ancestor of flatKey is +// present in the nullified map. The key itself is not consulted — callers +// check `nullified[key]` directly when they need the exact match. +func hasNullifiedAncestor(nullified map[string]bool, flatKey string) bool { + if len(nullified) == 0 || flatKey == "" { + return false + } + + for parent := domain.FlatKey(flatKey).Parent(); parent != ""; parent = parent.Parent() { + if nullified[string(parent)] { + return true + } + } + + return false +} + // loadFormForEditor reconstructs the exact text the load step writes into an // editor cell for (comment, value): each comment line prefixed `# ` and // terminated by `\n`, then value verbatim. Mirror of diff --git a/ui/state/editor_state_test.go b/ui/state/editor_state_test.go index 63bbaaa..8d8f098 100644 --- a/ui/state/editor_state_test.go +++ b/ui/state/editor_state_test.go @@ -8,11 +8,11 @@ import ( "github.com/qdeck-app/qdeck/service" ) -// typeNull / typeString mirror the canonical values the service package -// stores in FlatValueEntry.Type. Test-only flat keys are also lifted into -// constants here so goconst stays quiet. +// typeString mirrors the canonical value the service package stores in +// FlatValueEntry.Type for string leaves. Null leaves use service.TypeNull +// directly. Test-only flat keys are lifted into constants here so goconst +// stays quiet. const ( - typeNull = "null" typeString = "string" flatKeyImageRegistry = "global.imageRegistry" @@ -42,7 +42,7 @@ func TestCollectOverrides_HashInsideStringNotSplit(t *testing.T) { loaded := map[string]string{flatKeyAuthPwd: literal} - overrides := collectOverrides(entries, editors, loaded) + overrides := collectOverrides(entries, editors, loaded, nil) if len(overrides) != 1 { t.Fatalf("expected 1 override, got %d", len(overrides)) @@ -67,7 +67,7 @@ func TestCollectOverrides_RoundTripEmptyAndNullValues(t *testing.T) { entries := []service.FlatValueEntry{ {Key: flatKeyImageRegistry, Value: "", Type: typeString, Kind: service.EntryKindLeaf}, - {Key: flatKeyUsePasswordFls, Value: "", Type: typeNull, Kind: service.EntryKindLeaf}, + {Key: flatKeyUsePasswordFls, Value: "", Type: service.TypeNull, Kind: service.EntryKindLeaf}, } editors := make([]widget.Editor, 2) @@ -81,7 +81,7 @@ func TestCollectOverrides_RoundTripEmptyAndNullValues(t *testing.T) { flatKeyUsePasswordFls: "", } - overrides := collectOverrides(entries, editors, loaded) + overrides := collectOverrides(entries, editors, loaded, nil) if len(overrides) != 2 { t.Fatalf("expected 2 overrides, got %d", len(overrides)) @@ -121,7 +121,7 @@ func TestCollectOverrides_EmptyEditorWithNonEmptyDefaultDropped(t *testing.T) { // should still drop the entry. loaded := map[string]string{flatKeyImageTag: flatKeyImageTagValue} - overrides := collectOverrides(entries, editors, loaded) + overrides := collectOverrides(entries, editors, loaded, nil) if len(overrides) != 0 { t.Errorf("cleared cell should drop entry; got %d overrides", len(overrides)) @@ -158,7 +158,7 @@ func TestCollectOverrides_ChartMergedAliasPreserved(t *testing.T) { // at this key (the alias-resolved value). loaded := map[string]string{flatKeyAuthPwd: loadedAliased} - overrides := collectOverrides(entries, editors, loaded) + overrides := collectOverrides(entries, editors, loaded, nil) if len(overrides) != 1 { t.Fatalf("expected 1 override, got %d", len(overrides)) @@ -194,7 +194,7 @@ func TestCollectOverrides_EmptyDefaultDropped(t *testing.T) { // file doesn't touch. loaded := map[string]string{"some.other.key": "x"} - overrides := collectOverrides(entries, editors, loaded) + overrides := collectOverrides(entries, editors, loaded, nil) if len(overrides) != 0 { t.Errorf("chart defaults shouldn't leak into save; got %d overrides", len(overrides)) @@ -252,7 +252,7 @@ func TestCollectOverrides_MultiLineLiteralBlockPreservesHashContent(t *testing.T loaded := map[string]string{key: literalValue} - overrides := collectOverrides(entries, editors, loaded) + overrides := collectOverrides(entries, editors, loaded, nil) if len(overrides) != 1 { t.Fatalf("expected 1 override, got %d", len(overrides)) diff --git a/ui/state/values_state.go b/ui/state/values_state.go index 86b7039..8a56a63 100644 --- a/ui/state/values_state.go +++ b/ui/state/values_state.go @@ -111,6 +111,14 @@ type CustomColumnState struct { // GitChanges maps flat keys to their git change status (added/modified vs HEAD). // nil when git comparison is not available or file is not tracked. GitChanges map[string]domain.GitChangeStatus + + // NullifiedKeys marks flat keys (leaves AND sections) the user has + // explicitly nullified via the in-cell nullify button. The serializer + // emits each entry as `key: ~` regardless of its inferred type, and + // drops every descendant of a nullified section so the saved YAML is a + // single null at the section root. Session-only — not rebuilt on file + // reload; the saved `~` round-trips through type=null leaves. + NullifiedKeys map[string]bool } // EnsureEditors grows the override editor slice only when data exceeds capacity. @@ -166,6 +174,52 @@ func (c *CustomColumnState) MarkOverride(idx int, has bool) { } } +// MarkNullified records flatKey as explicitly null. Lazy-allocates the map. +func (c *CustomColumnState) MarkNullified(flatKey string) { + if flatKey == "" { + return + } + + if c.NullifiedKeys == nil { + c.NullifiedKeys = make(map[string]bool) + } + + c.NullifiedKeys[flatKey] = true +} + +// IsNullifiedDirect reports whether flatKey itself is in NullifiedKeys (exact +// match). Used by the editor renderer to style only the nullified cell, not +// its descendants — covered descendants render through IsNullifiedCovered. +func (c *CustomColumnState) IsNullifiedDirect(flatKey string) bool { + return c.NullifiedKeys[flatKey] +} + +// IsNullifiedCovered reports whether flatKey is itself nullified OR has any +// nullified ancestor that masks it. Used by the renderer to gray out cells +// whose value is going to be discarded by an ancestor section nullify. +func (c *CustomColumnState) IsNullifiedCovered(flatKey string) bool { + if c.NullifiedKeys[flatKey] { + return true + } + + return hasNullifiedAncestor(c.NullifiedKeys, flatKey) +} + +// ClearNullified drops flatKey AND any ancestor key that covered it. Called +// when the user types into a cell that was previously masked by a section +// nullify, so the user's keystrokes aren't silently dropped at save time. +func (c *CustomColumnState) ClearNullified(flatKey string) { + if len(c.NullifiedKeys) == 0 || flatKey == "" { + return + } + + delete(c.NullifiedKeys, flatKey) + + for parent := domain.FlatKey(flatKey).Parent(); parent != ""; parent = parent.Parent() { + delete(c.NullifiedKeys, string(parent)) + } +} + // RebuildOverrideFlags rescans all editors and rebuilds the override flags. // Call after bulk SetText operations (e.g. file load). func (c *CustomColumnState) RebuildOverrideFlags() { @@ -191,6 +245,7 @@ func (c *CustomColumnState) Reset() { c.FileDropActive = false c.EditorParseError = "" c.GitChanges = nil + c.NullifiedKeys = nil for i := range c.OverrideEditors { c.OverrideEditors[i].SetText("") diff --git a/ui/state/values_state_test.go b/ui/state/values_state_test.go new file mode 100644 index 0000000..9a55a34 --- /dev/null +++ b/ui/state/values_state_test.go @@ -0,0 +1,138 @@ +package state + +import "testing" + +const ( + keyMaster = "master" + keyMasterPersistence = "master.persistence" +) + +func TestMarkNullified_LazyAllocAndSet(t *testing.T) { + var c CustomColumnState + + if c.NullifiedKeys != nil { + t.Fatalf("expected nil map before first MarkNullified") + } + + c.MarkNullified("auth.password") + + if c.NullifiedKeys == nil { + t.Fatalf("expected lazy allocation on first MarkNullified") + } + + if !c.NullifiedKeys["auth.password"] { + t.Errorf("expected auth.password to be flagged") + } +} + +func TestMarkNullified_EmptyKeyIgnored(t *testing.T) { + var c CustomColumnState + + c.MarkNullified("") + + if c.NullifiedKeys != nil { + t.Errorf("empty key should not trigger lazy alloc; got %v", c.NullifiedKeys) + } +} + +func TestIsNullifiedDirect_ExactMatchOnly(t *testing.T) { + c := CustomColumnState{NullifiedKeys: map[string]bool{keyMasterPersistence: true}} + + if !c.IsNullifiedDirect(keyMasterPersistence) { + t.Errorf("expected direct hit on exact key") + } + + if c.IsNullifiedDirect("master.persistence.size") { + t.Errorf("descendant should not be direct-nullified") + } + + if c.IsNullifiedDirect(keyMaster) { + t.Errorf("ancestor should not be direct-nullified") + } +} + +func TestIsNullifiedCovered_HitsAncestor(t *testing.T) { + c := CustomColumnState{NullifiedKeys: map[string]bool{keyMasterPersistence: true}} + + cases := []struct { + key string + want bool + }{ + {keyMasterPersistence, true}, // direct + {"master.persistence.size", true}, // child + {"master.persistence.access.modes", true}, // grandchild + {keyMaster, false}, // ancestor of nullified key — not covered + {"replica.persistence", false}, // sibling subtree + {"", false}, + } + + for _, tc := range cases { + if got := c.IsNullifiedCovered(tc.key); got != tc.want { + t.Errorf("IsNullifiedCovered(%q) = %v, want %v", tc.key, got, tc.want) + } + } +} + +func TestIsNullifiedCovered_EmptyMap(t *testing.T) { + var c CustomColumnState + + if c.IsNullifiedCovered("anything") { + t.Errorf("empty NullifiedKeys should never report covered") + } +} + +func TestClearNullified_DropsKeyAndAncestors(t *testing.T) { + c := CustomColumnState{NullifiedKeys: map[string]bool{ + keyMaster: true, + keyMasterPersistence: true, + "replica": true, + }} + + c.ClearNullified("master.persistence.size") + + if c.NullifiedKeys[keyMaster] { + t.Errorf("ClearNullified should drop ancestor %q", keyMaster) + } + + if c.NullifiedKeys[keyMasterPersistence] { + t.Errorf("ClearNullified should drop ancestor %q", keyMasterPersistence) + } + + if !c.NullifiedKeys["replica"] { + t.Errorf("ClearNullified should NOT touch unrelated key 'replica'") + } +} + +func TestClearNullified_NoOpOnEmpty(t *testing.T) { + var c CustomColumnState + + c.ClearNullified("anything") // should not panic, should not alloc. + + if c.NullifiedKeys != nil { + t.Errorf("ClearNullified on nil map should stay nil") + } +} + +func TestClearNullified_EmptyKeyIgnored(t *testing.T) { + c := CustomColumnState{NullifiedKeys: map[string]bool{keyMaster: true}} + + c.ClearNullified("") + + if !c.NullifiedKeys[keyMaster] { + t.Errorf("ClearNullified('') should be a no-op") + } +} + +func TestClearNullified_HonorsFlatKeyEscapes(t *testing.T) { + // Quoted segment with embedded dot — Parent walk must stop at the + // quoted boundary and NOT split on the inner dot. + c := CustomColumnState{NullifiedKeys: map[string]bool{ + `weird."foo.bar"`: true, + }} + + c.ClearNullified(`weird."foo.bar".child`) + + if c.NullifiedKeys[`weird."foo.bar"`] { + t.Errorf("escape-aware ancestor walk should have dropped weird.\"foo.bar\"") + } +} diff --git a/ui/widget/button.go b/ui/widget/button.go index 673f31d..97be0fe 100644 --- a/ui/widget/button.go +++ b/ui/widget/button.go @@ -110,10 +110,7 @@ func LayoutButton( // Outline (everything except primary). if style != ButtonPrimary { - paintRect(gtx, image.Rect(0, 0, w, hairline), cs.border) - paintRect(gtx, image.Rect(0, h-hairline, w, h), cs.border) - paintRect(gtx, image.Rect(0, 0, hairline, h), cs.border) - paintRect(gtx, image.Rect(w-hairline, 0, w, h), cs.border) + paintHairlineBorder(gtx, w, h, hairline, cs.border) } // Focus-visible ring: 2dp Override outline, 1dp outside the diff --git a/ui/widget/extras_filter_pill.go b/ui/widget/extras_filter_pill.go index e66184b..5db01c5 100644 --- a/ui/widget/extras_filter_pill.go +++ b/ui/widget/extras_filter_pill.go @@ -60,10 +60,7 @@ func LayoutExtrasFilterPill( paint.PaintOp{}.Add(gtx.Ops) bgStack.Pop() - paintRect(gtx, image.Rect(0, 0, w, hairline), borderColor) - paintRect(gtx, image.Rect(0, h-hairline, w, h), borderColor) - paintRect(gtx, image.Rect(0, 0, hairline, h), borderColor) - paintRect(gtx, image.Rect(w-hairline, 0, w, h), borderColor) + paintHairlineBorder(gtx, w, h, hairline, borderColor) pointer.CursorPointer.Add(gtx.Ops) diff --git a/ui/widget/null_pill.go b/ui/widget/null_pill.go new file mode 100644 index 0000000..10208ce --- /dev/null +++ b/ui/widget/null_pill.go @@ -0,0 +1,97 @@ +package widget + +import ( + "image" + + "gioui.org/font" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget/material" + + "github.com/qdeck-app/qdeck/service" + "github.com/qdeck-app/qdeck/ui/theme" +) + +const ( + nullPillPaddingH unit.Dp = 6 + // nullPillPaddingV stays at zero so the pill's outer height equals + // the label's line box exactly — same as the editor reports for an + // empty single-line cell. Adding vertical padding here makes the + // row grow by 2*padV on nullify, which the user notices as a vertical + // jump. + nullPillPaddingV unit.Dp = 0 +) + +// LayoutNullPill renders the explicit-null chip: an italic muted "null" +// label inside a faint amber pill with a thin border. This is the visual +// signal that the cell (or section) has been nullified and will round-trip +// to a YAML null scalar — distinct from a literal string "null" or "~" +// the user could type, which renders as plain editor text. +// +// compact shrinks the horizontal padding so the chip fits inside narrow +// override columns without horizontal clipping. +func LayoutNullPill(gtx layout.Context, th *material.Theme, compact bool) layout.Dimensions { + padH := gtx.Dp(nullPillPaddingH) + if compact { + padH /= 2 //nolint:mnd // half-width padding for narrow columns. + } + + padV := gtx.Dp(nullPillPaddingV) + + hairline := gtx.Dp(theme.Default.HairlineWidth) + if hairline < 1 { + hairline = 1 + } + + lbl := material.Body2(th, service.TypeNull) + lbl.Color = theme.Default.Muted + lbl.Font.Style = font.Italic + lbl.MaxLines = 1 + // Match the editor's text size so a cell switching between an empty + // editor and the null pill keeps the same line-height — otherwise + // the row collapses vertically on nullify and the trailing button + // visibly hops upward. + lbl.TextSize = viewerEditorTextSize + + // Measure the label at its natural width by dropping the caller's + // Min.X for the recording — otherwise the label would expand to + // fill Min.X and our `contentDims + 2*padH` formula would overflow + // the slot by 2*padH. The chrome reapplies Min.X below. + measureGtx := gtx + measureGtx.Constraints.Min = image.Point{} + + measure := op.Record(gtx.Ops) + contentDims := LayoutLabel(measureGtx, lbl) + contentCall := measure.Stop() + + w := contentDims.Size.X + 2*padH //nolint:mnd // 2 sides of horizontal padding. + h := contentDims.Size.Y + 2*padV //nolint:mnd // 2 sides of vertical padding. + + // Expand the pill's chrome to honor the caller's Min.X. Leaf cells + // set Min.X = Max.X (the Flexed slot width) before calling so the + // pill's bg/border spans the same horizontal footprint as the + // editor would; the trailing badge + nullify button then sit at + // the identical x in both states. Section rows leave Min.X at the + // default 0 so the chip keeps its compact width. + if w < gtx.Constraints.Min.X { + w = gtx.Constraints.Min.X + } + + radius := h / 2 //nolint:mnd // half-height = full radius for pill shape. + + bgStack := clip.UniformRRect(image.Rectangle{Max: image.Pt(w, h)}, radius).Push(gtx.Ops) + paint.ColorOp{Color: theme.Default.OverrideBg}.Add(gtx.Ops) + paint.PaintOp{}.Add(gtx.Ops) + bgStack.Pop() + + paintHairlineBorder(gtx, w, h, hairline, theme.Default.Border) + + tr := op.Offset(image.Pt(padH, padV)).Push(gtx.Ops) + contentCall.Add(gtx.Ops) + tr.Pop() + + return layout.Dimensions{Size: image.Pt(w, h)} +} diff --git a/ui/widget/nullify_icon.go b/ui/widget/nullify_icon.go new file mode 100644 index 0000000..443d097 --- /dev/null +++ b/ui/widget/nullify_icon.go @@ -0,0 +1,174 @@ +package widget + +import ( + "image" + "image/color" + + "gioui.org/font" + "gioui.org/gesture" + "gioui.org/io/pointer" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" + "gioui.org/widget/material" + + "github.com/qdeck-app/qdeck/ui/theme" +) + +const ( + // nullifyBtnPaddingH is the horizontal padding inside the button + // chrome — leaves enough room around the "~" glyph that it doesn't + // touch the rounded border. + nullifyBtnPaddingH unit.Dp = 3 + + // nullifyBtnPaddingV stays at zero so the button's outer height is + // driven by the glyph's own line height, matching the surrounding + // editor cells and not expanding the row. + nullifyBtnPaddingV unit.Dp = 0 + + // nullifyBtnRadius is the corner radius. Smaller than the null pill + // (which is fully rounded) so the button reads as actionable rather + // than as a status badge. + nullifyBtnRadius unit.Dp = 3 + + // nullifyBtnGlyphSize matches the editor-cell text size + // (viewerEditorTextSize = 14sp), so the button height equals the + // cell's natural line box and the row doesn't grow when the button + // appears on hover. + nullifyBtnGlyphSize unit.Sp = 14 +) + +// layoutNullifyButton renders the inline "~" nullify button. +func layoutNullifyButton( + gtx layout.Context, + th *material.Theme, + click *gesture.Click, + hover *gesture.Hover, + hovered, active bool, +) layout.Dimensions { + cs := pickNullifyButtonColors(active, hovered) + + padH := gtx.Dp(nullifyBtnPaddingH) + padV := gtx.Dp(nullifyBtnPaddingV) + radius := gtx.Dp(nullifyBtnRadius) + + hairline := gtx.Dp(theme.Default.HairlineWidth) + if hairline < 1 { + hairline = 1 + } + + // Render the "~" label into a recorded macro first so we know the + // button's natural size before we paint the chrome. + measureGtx := gtx + measureGtx.Constraints.Min = image.Point{} + + contentRec := op.Record(gtx.Ops) + contentDims := nullifyButtonGlyph(measureGtx, th, cs.text) + contentCall := contentRec.Stop() + + w := contentDims.Size.X + 2*padH //nolint:mnd // 2 sides of horizontal padding. + h := contentDims.Size.Y + 2*padV //nolint:mnd // 2 sides of vertical padding. + + // Background fill. + bgStack := clip.UniformRRect(image.Rectangle{Max: image.Pt(w, h)}, radius).Push(gtx.Ops) + paint.ColorOp{Color: cs.fill}.Add(gtx.Ops) + paint.PaintOp{}.Add(gtx.Ops) + bgStack.Pop() + + paintHairlineBorder(gtx, w, h, hairline, cs.border) + + // Place the recorded label inside the padded interior. + tr := op.Offset(image.Pt(padH, padV)).Push(gtx.Ops) + contentCall.Add(gtx.Ops) + tr.Pop() + + // Hit area: hover (cursor + color), click (action), CursorPointer + // last so it wins over the row's CursorText on the same point. + area := clip.Rect{Max: image.Pt(w, h)}.Push(gtx.Ops) + hover.Add(gtx.Ops) + click.Add(gtx.Ops) + pointer.CursorPointer.Add(gtx.Ops) + area.Pop() + + return layout.Dimensions{Size: image.Pt(w, h)} +} + +// nullifyButtonGlyph renders the "~" glyph at the configured glyph size. +func nullifyButtonGlyph(gtx layout.Context, th *material.Theme, c color.NRGBA) layout.Dimensions { + lbl := material.Body2(th, "~") + lbl.Color = c + lbl.Font.Weight = font.Medium + lbl.MaxLines = 1 + lbl.Alignment = text.Middle + lbl.TextSize = nullifyBtnGlyphSize + + return LayoutLabel(gtx, lbl) +} + +// layoutNullifyButtonPlaceholder returns dimensions matching what the +// real nullify button would occupy, without painting any chrome or +// registering the hover / click gestures. Reserving this footprint +// keeps the surrounding editor text and anchor badge stable when the +// button toggles between hidden and visible on row hover. +func layoutNullifyButtonPlaceholder(gtx layout.Context, th *material.Theme) layout.Dimensions { + padH := gtx.Dp(nullifyBtnPaddingH) + padV := gtx.Dp(nullifyBtnPaddingV) + + // Measure the glyph's natural size by recording it into a macro + // and discarding the recording — the simplest way to match the + // real button's pixel dimensions without duplicating shaper work. + measureGtx := gtx + measureGtx.Constraints.Min = image.Point{} + + rec := op.Record(gtx.Ops) + contentDims := nullifyButtonGlyph(measureGtx, th, theme.Default.Transparent) + _ = rec.Stop() + + w := contentDims.Size.X + 2*padH //nolint:mnd // 2 sides of horizontal padding. + h := contentDims.Size.Y + 2*padV //nolint:mnd // 2 sides of vertical padding. + + return layout.Dimensions{Size: image.Pt(w, h)} +} + +// nullifyButtonColors bundles the three colors the button frame uses so +// the (active, hovered) state matrix collapses to a single switch. +type nullifyButtonColors struct { + fill color.NRGBA + border color.NRGBA + text color.NRGBA +} + +func pickNullifyButtonColors(active, hovered bool) nullifyButtonColors { + switch { + case active && hovered: + // Engaged + hovered: stronger border to invite the un-nullify + // click without changing the bg (which already reads as "this + // cell is in null state"). + return nullifyButtonColors{ + fill: theme.Default.OverrideBg, + border: theme.Default.Override, + text: theme.Default.Override, + } + case active: + return nullifyButtonColors{ + fill: theme.Default.OverrideBg, + border: theme.Default.Override, + text: theme.Default.Override, + } + case hovered: + return nullifyButtonColors{ + fill: theme.Default.Bg2, + border: theme.Default.BorderStrong, + text: theme.Default.Override, + } + default: + return nullifyButtonColors{ + fill: theme.Default.Bg, + border: theme.Default.Border, + text: theme.Default.Muted2, + } + } +} diff --git a/ui/widget/override_table.go b/ui/widget/override_table.go index 1e42dd1..4d9e0c7 100644 --- a/ui/widget/override_table.go +++ b/ui/widget/override_table.go @@ -24,12 +24,13 @@ import ( // Yaml type-name literals shared between overrideHint and the bool-switch // dispatch. Declared here (their primary consumer) so they survive the -// type_tag.go deletion that removed their previous home. +// type_tag.go deletion that removed their previous home. The null tag +// lives on service.TypeNull — re-declaring it locally would create two +// sources of truth for the same protocol value. const ( typeNameString = "string" typeNameNumber = "number" typeNameBool = "bool" - typeNameNull = "null" typeNameUnknown = "unknown" ) @@ -85,8 +86,24 @@ const ( overrideBadgePaddingH unit.Dp = 4 // horizontal padding inside an anchor badge chip overrideBadgePaddingV unit.Dp = 1 // vertical padding inside an anchor badge chip overrideBadgeRadius unit.Dp = 2 // corner radius of an anchor badge chip + overrideNullifyBtnGap unit.Dp = 4 // left gap between the editor and the inline nullify button + // overrideNullifyBtnTrail keeps the button out from under the + // list's scrollbar gutter (overrideScrollbarWidth, 10dp), with a + // few extra dp of breathing room so the chrome doesn't visually + // touch the scrollbar's override-marker strip. + overrideNullifyBtnTrail unit.Dp = overrideScrollbarWidth + 4 + overrideNullifyCompactWidth unit.Dp = 80 // narrow-column threshold; below this the null pill uses compact padding ) +// overrideNullifyButtonInset is the fixed Left/Right inset around the +// in-cell nullify button. Hoisted to package scope so the row-layout +// callback doesn't reconstruct the struct literal on every frame for +// every visible cell. +var overrideNullifyButtonInset = layout.Inset{ + Left: overrideNullifyBtnGap, + Right: overrideNullifyBtnTrail, +} + // OverrideTable renders a unified table with default values on the left and // editable override editors on the right. Supports up to MaxCustomColumns // independent editor columns side by side. Uses a single virtualized list so @@ -134,6 +151,8 @@ type OverrideTable struct { defaultBadgeClicks []gesture.Click columnBadgeClicks [state.MaxCustomColumns][]gesture.Click rightClickTargets [state.MaxCustomColumns][]rightClickTarget + nullifyClicks [state.MaxCustomColumns][]gesture.Click + nullifyHovers [state.MaxCustomColumns][]gesture.Hover // Switch states for bool-typed override cells. Indexed by [col][rowIndex] // (filtered row index, not entry index — matches the rest of the @@ -241,6 +260,14 @@ type OverrideTable struct { // the anchor so subsequent typing goes through. Not firing this callback // — or doing nothing in it — silently ignores edits on anchored cells. OnAnchoredCellEdit func(col int, flatKey string) + + // OnCellNullify fires when the user clicks the in-cell nullify button. + // Carries the column and the cell's flat key. The page-level handler + // distinguishes leaf vs section by the entry kind, severs any anchor + // reach the cell participates in, and updates NullifiedKeys so the + // serializer emits `key: ~`. A second click on an already-nullified + // cell un-nullifies it. + OnCellNullify func(col int, flatKey string) } func (t *OverrideTable) ensureHovers(count int) { @@ -303,6 +330,18 @@ func (t *OverrideTable) ensureRightClickTargets(count int) { } } +func (t *OverrideTable) ensureNullifyClicks(count int) { + for c := range state.MaxCustomColumns { + if count > len(t.nullifyClicks[c]) { + t.nullifyClicks[c] = append(t.nullifyClicks[c], make([]gesture.Click, count-len(t.nullifyClicks[c]))...) + } + + if count > len(t.nullifyHovers[c]) { + t.nullifyHovers[c] = append(t.nullifyHovers[c], make([]gesture.Hover, count-len(t.nullifyHovers[c]))...) + } + } +} + func (t *OverrideTable) ratio() float32 { if t.ColumnRatio <= 0 { return overrideDefaultRatio @@ -371,7 +410,7 @@ func overrideHint(entryType string) string { return "click to override (number)" case typeNameBool: return "click to override (bool)" - case typeNameNull: + case service.TypeNull: return "click to override (null)" case typeNameUnknown: return "click to override (unknown)" @@ -393,6 +432,7 @@ func (t *OverrideTable) Layout( t.ensureBadgeClicks(len(filteredIndices)) t.ensureSwitchStates(len(filteredIndices)) t.ensureRightClickTargets(len(filteredIndices)) + t.ensureNullifyClicks(len(filteredIndices)) t.HoveredRow = overrideNoHover @@ -807,12 +847,19 @@ func (t *OverrideTable) layoutRightColumns( badgeInfo := t.columnAnchorInfo(col, entryKey) colAnchors := t.columnAnchors(col) + showNullify := t.shouldShowNullifyButton(col, rowIndex, entryKey) + // Drain the button's hover events once at the cell level so + // the same boolean drives the button color AND the help-text + // tooltip render below — calling Update twice would split + // events between the two consumers. + btnHovered := t.nullifyHovers[col][rowIndex].Update(gtx.Source) return layout.Inset{Left: overridePaddingH}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { t.drainRightClicks(gtx, col, rowIndex, entryKey) + t.drainNullifyClicks(gtx, col, rowIndex, entryKey) - dims := layout.Flex{Axis: layout.Horizontal, Alignment: layout.Start}.Layout(gtx, + dims := layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return t.layoutEditorCell(gtx, col, entryIdx, rowIndex, hint, entryType, entryKey, entries) }), @@ -822,6 +869,12 @@ func (t *OverrideTable) layoutRightColumns( &t.columnBadgeClicks[col][rowIndex], col, entryKey, ) }), + // Nullify button sits at the cell's right edge after + // the badge so its position is identical whether or + // not the row carries an anchor/alias annotation. + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return t.layoutNullifyButtonSlot(gtx, col, rowIndex, entryKey, showNullify, btnHovered, overrideNullifyButtonInset) + }), ) pass := pointer.PassOp{}.Push(gtx.Ops) @@ -847,10 +900,13 @@ func (t *OverrideTable) layoutRightColumns( return layout.Flex{}.Layout(gtx, children[:n]...) } -// layoutSectionBadges renders just the anchor/alias badge for each active -// override column on a section row. Section rows have no editor cells, so -// the badge sits where the editor would be, aligned to the right edge of -// each column so it lines up with regular-row badges below. +// layoutSectionBadges renders the anchor/alias badge — and, when the +// section is directly nullified or hovered, the null pill plus the +// nullify button — for each active override column on a section row. +// Section rows have no editor cells; this is the only place a section +// can advertise "I'm null" inside the table. The button is shown when +// the row is hovered OR the section is already nullified, so users can +// always toggle off without having to re-hover the right cell. func (t *OverrideTable) layoutSectionBadges( gtx layout.Context, rowIndex int, @@ -861,6 +917,7 @@ func (t *OverrideTable) layoutSectionBadges( var children [state.MaxCustomColumns * 2]layout.FlexChild n := 0 + hovered := t.HoveredRow == rowIndex for c := range g.count { col := c @@ -873,12 +930,31 @@ func (t *OverrideTable) layoutSectionBadges( badgeInfo := t.columnAnchorInfo(col, entryKey) colAnchors := t.columnAnchors(col) + direct := t.cellNullifiedDirect(col, entryKey) + showNullify := hovered || direct + btnHovered := t.nullifyHovers[col][rowIndex].Update(gtx.Source) return layout.Inset{Left: overridePaddingH}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Start}.Layout(gtx, + t.drainNullifyClicks(gtx, col, rowIndex, entryKey) + + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + // Flexed(1) reserves the cell's full body width + // for either the null pill (when direct-nullified) + // or empty space. Returning zero-size dimensions + // here would let Flex place the badge + button + // flush against the left edge of the cell — keep + // the slot width so the trailing rigids stay + // pinned to the right. layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return layout.Dimensions{} + if !direct { + return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, 0)} + } + + pillDims := LayoutNullPill(gtx, t.Theme, gtx.Constraints.Max.X < gtx.Dp(overrideNullifyCompactWidth)) + pillDims.Size.X = gtx.Constraints.Max.X + + return pillDims }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return t.layoutAnchorBadge( @@ -886,6 +962,12 @@ func (t *OverrideTable) layoutSectionBadges( &t.columnBadgeClicks[col][rowIndex], col, entryKey, ) }), + // Match the leaf-cell ordering — button last so it + // pins to the right regardless of whether a badge + // is present on this section row. + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return t.layoutNullifyButtonSlot(gtx, col, rowIndex, entryKey, showNullify, btnHovered, overrideNullifyButtonInset) + }), ) }) }) @@ -902,6 +984,97 @@ func (t *OverrideTable) layoutSectionBadges( return layout.Flex{}.Layout(gtx, children[:n]...) } +// layoutNullifyButtonSlot returns a zero-size dimensions when the button is +// hidden and otherwise lays out the icon inside the supplied inset. Shared +// between the leaf-cell and section-row paths so both call sites stay in +// sync on color tokens and click target wiring. `hovered` is the hover +// state from gesture.Hover.Update — the caller does the Update so it can +// reuse the same value to drive the tooltip render that lives outside +// the button's local frame. +func (t *OverrideTable) layoutNullifyButtonSlot( + gtx layout.Context, + col, rowIndex int, + entryKey string, + show, hovered bool, + inset layout.Inset, +) layout.Dimensions { + return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + // Always report a stable footprint so the editor text and the + // anchor badge don't shift sideways when the button toggles + // between hidden and visible on hover. + if !show { + return layoutNullifyButtonPlaceholder(gtx, t.Theme) + } + + return layoutNullifyButton( + gtx, + t.Theme, + &t.nullifyClicks[col][rowIndex], + &t.nullifyHovers[col][rowIndex], + hovered, + t.cellNullifiedDirect(col, entryKey), + ) + }) +} + +// drainNullifyClicks consumes click events from the per-cell nullify button +// gesture. Fires OnCellNullify on the trailing-edge click (mouse-up inside +// the icon area) so a drag started on the icon doesn't accidentally +// nullify. Each frame may produce at most one click — extra events get +// drained but ignored. +func (t *OverrideTable) drainNullifyClicks(gtx layout.Context, col, rowIndex int, entryKey string) { + if t.OnCellNullify == nil || rowIndex >= len(t.nullifyClicks[col]) { + return + } + + for { + ev, ok := t.nullifyClicks[col][rowIndex].Update(gtx.Source) + if !ok { + break + } + + if ev.Kind == gesture.KindClick { + t.OnCellNullify(col, entryKey) + } + } +} + +// cellNullifiedDirect reports whether the given cell is currently flagged as +// explicitly nullified — controls the active-state coloring of the nullify +// button. Returns false when the column has no state attached (e.g. an +// extra column slot that hasn't been wired up). +func (t *OverrideTable) cellNullifiedDirect(col int, flatKey string) bool { + cs := t.ColumnStates[col] + if cs == nil { + return false + } + + return cs.IsNullifiedDirect(flatKey) +} + +// cellNullifiedCovered reports whether the given cell is itself nullified +// or sits below a nullified ancestor section. Drives the null-pill render +// in editor cells — both states get the same chip so a section nullify +// reads as one coherent block. +func (t *OverrideTable) cellNullifiedCovered(col int, flatKey string) bool { + cs := t.ColumnStates[col] + if cs == nil { + return false + } + + return cs.IsNullifiedCovered(flatKey) +} + +// shouldShowNullifyButton reports whether the in-cell nullify button is +// currently visible: either the row is hovered (transient affordance on +// pointer-over) or the cell is already directly nullified (keep the +// toggle-off action reachable without re-hovering). Shared by the +// leaf-cell path; the section-row path inlines the equivalent expression +// because it also needs the direct flag for the pill render. +func (t *OverrideTable) shouldShowNullifyButton(col, rowIndex int, flatKey string) bool { + return t.HoveredRow == rowIndex || t.cellNullifiedDirect(col, flatKey) +} + // drawRowDecorations renders the divider line, sub-column dividers, tree guides, // and horizontal separator for a single row. func (t *OverrideTable) drawRowDecorations( diff --git a/ui/widget/override_table_cells.go b/ui/widget/override_table_cells.go index 8b120ce..02feb76 100644 --- a/ui/widget/override_table_cells.go +++ b/ui/widget/override_table_cells.go @@ -7,10 +7,12 @@ import ( "gioui.org/font" "gioui.org/layout" "gioui.org/op" + "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "github.com/qdeck-app/qdeck/service" + "github.com/qdeck-app/qdeck/ui/state" "github.com/qdeck-app/qdeck/ui/theme" ) @@ -28,14 +30,19 @@ func (t *OverrideTable) layoutEditorCell( ) layout.Dimensions { editors := t.ColumnEditors[col] - // Bool dispatch: render an inline pill switch instead of the text - // editor. The editor remains the source of truth — the switch reads - // its current value from editor text and writes back via SetText on - // toggle. Multi-line bool overrides (rare but possible if a YAML - // scalar literal block is hand-pasted) fall through to the text path - // to preserve fidelity. - if entryType == typeNameBool && !strings.Contains(editors[entryIdx].Text(), "\n") { - return t.layoutBoolSwitchCell(gtx, col, entryIdx, rowIndex, entryKey, entries) + // Nullify dispatch: a directly-nullified cell, or any cell masked by + // a nullified ancestor section, renders as the "null" pill chip. + // Replaces both the text editor and the bool switch — the underlying + // editor still exists for state, but the pill is the only visible + // affordance. Auto-clear on user keystrokes (processEditorChanges) + // drops the flag so the cell re-renders as a normal editor next + // frame; the in-cell nullify button is the primary toggle path. + // + // Any head comment the user typed (or that the loaded file carried + // next to the `~` value) is surfaced above the pill so the "why + // null?" explanation stays visible without un-nullifying first. + if t.cellNullifiedCovered(col, entryKey) { + return t.layoutNullCell(gtx, state.ExtractLeadingComment(editors[entryIdx].Text())) } // Number-typed cells get an input filter so non-numeric keystrokes are @@ -49,6 +56,18 @@ func (t *OverrideTable) layoutEditorCell( editors[entryIdx].Filter = "" } + // Bool dispatch: render an inline pill switch instead of the text + // editor. The editor remains the source of truth — the switch reads + // its current value from editor text and writes back via SetText on + // toggle. Multi-line bool overrides (rare but possible if a YAML + // scalar literal block is hand-pasted) fall through to the text path + // to preserve fidelity. Skipped above when the cell is nullified, so + // a covered descendant of a nullified section never shows a bool + // toggle — only the null chip. + if entryType == typeNameBool && !strings.Contains(editors[entryIdx].Text(), "\n") { + return t.layoutBoolSwitchCell(gtx, col, entryIdx, rowIndex, entryKey, entries) + } + ed := material.Editor(t.Theme, &editors[entryIdx], hint) ed.TextSize = viewerEditorTextSize @@ -123,7 +142,17 @@ func (t *OverrideTable) layoutBoolSwitchCell( return layout.Dimensions{} } + // Capture the Flexed slot width up-front. LayoutSwitch reports just + // the pill's 22dp natural width back to the parent Flex, which then + // places the badge + nullify button immediately after the pill + // (left side of the cell) instead of against the cell's right edge. + // Padding the returned dim to the full slot width keeps the button + // pinned to the right, matching string/number cells. + slotW := gtx.Constraints.Max.X + newValue, dims := LayoutSwitch(gtx, &t.switchStates[col][rowIndex], current) + dims.Size.X = slotW + if newValue == current { return dims } @@ -138,11 +167,92 @@ func (t *OverrideTable) layoutBoolSwitchCell( editors[entryIdx].SetText(FormatBoolValue(newValue)) t.drainEditorEvents(gtx, &editors[entryIdx]) + + if t.ColumnStates[col] != nil && t.ColumnStates[col].IsNullifiedCovered(entryKey) { + t.ColumnStates[col].ClearNullified(entryKey) + } + t.commitOverrideUpdate(gtx, col, entryIdx, entryKey, entries) return dims } +// nullCellCommentGap is the vertical gap between the head comment and +// the null pill on a nullified cell. Tight — the two read as a single +// annotated affordance. +const nullCellCommentGap unit.Dp = 2 + +// layoutNullCell renders a nullified cell's right-side affordance. +// When the cell carries a head comment, it appears as italic muted +// text on the row above the null pill so the "why is this null?" +// explanation stays visible without un-nullifying. Empty-comment cells +// render just the pill. +// +// The reported dim.Size.X is padded to the Flexed slot width so the +// trailing anchor badge + nullify button stay pinned to the same +// x-coord they occupied before the cell was nullified. Without the +// pad, Gio's Flex would place those rigids immediately after the +// pill (which is much narrower than the slot), and the button would +// visibly "jump" leftward on click. +func (t *OverrideTable) layoutNullCell(gtx layout.Context, headComment string) layout.Dimensions { + slotW := gtx.Constraints.Max.X + compact := slotW < gtx.Dp(overrideNullifyCompactWidth) + + // Force the pill chrome to span the full Flexed slot so the cell's + // visible content edge matches what an empty editor would draw — + // the trailing badge + nullify button then land at the identical + // x-coord whether the cell is in its empty or nullified state. + gtx.Constraints.Min.X = slotW + + var dims layout.Dimensions + + if headComment == "" { + dims = LayoutNullPill(gtx, t.Theme, compact) + } else { + dims = layout.Flex{Axis: layout.Vertical, Alignment: layout.Start}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layoutNullCellComment(gtx, t.Theme, headComment) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: nullCellCommentGap}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return LayoutNullPill(gtx, t.Theme, compact) + }) + }), + ) + } + + if dims.Size.X < slotW { + dims.Size.X = slotW + } + + return dims +} + +// layoutNullCellComment renders the head-comment caption above the null +// pill — re-prefixes each line with `# ` so the visual matches how the +// comment would appear if the cell were edited as text. +func layoutNullCellComment(gtx layout.Context, th *material.Theme, head string) layout.Dimensions { + var b strings.Builder + + lines := strings.Split(head, "\n") + for i, line := range lines { + if i > 0 { + b.WriteByte('\n') + } + + b.WriteString("# ") + b.WriteString(line) + } + + lbl := material.Body2(th, b.String()) + lbl.Color = theme.Default.Muted + lbl.Font.Style = font.Italic + lbl.TextSize = viewerEditorTextSize + lbl.MaxLines = len(lines) + + return LayoutLabel(gtx, lbl) +} + // layoutMissingDefault renders the negative-space placeholder for an // extra (override-only) row's default cell. Fires for both leaf and // section rows — sections have no value editor on the left, so the diff --git a/ui/widget/override_table_events.go b/ui/widget/override_table_events.go index 9bc9af6..16fe1b3 100644 --- a/ui/widget/override_table_events.go +++ b/ui/widget/override_table_events.go @@ -96,6 +96,22 @@ func (t *OverrideTable) processEditorChanges( t.drainEditorEvents(gtx, &editors[entryIdx]) } + // User typed into a cell that was masked by a nullified ancestor + // (or was itself flagged null). Drop the flag so the keystrokes + // land in the saved file instead of being silently discarded by + // collectOverrides' descendant-skip rule. Skip when the text is + // the canonical null literal OR empty — those arrive as + // ChangeEvents after the controller's own SetText calls + // (nullifyCell sets "null", nullifySection clears descendants to + // ""), not real user edits. A real "user breaks out of a + // nullified section" gesture types real content into the cell. + if t.ColumnStates[c] != nil && t.ColumnStates[c].IsNullifiedCovered(entryKey) { + trimmed := strings.TrimSpace(state.StripYAMLComments(editors[entryIdx].Text())) + if trimmed != "" && trimmed != "~" && trimmed != service.TypeNull { + t.ColumnStates[c].ClearNullified(entryKey) + } + } + t.commitOverrideUpdate(gtx, c, entryIdx, entryKey, entries) } } @@ -132,10 +148,13 @@ func (t *OverrideTable) commitOverrideUpdate( tree *yaml.Node docs service.DocComments loadedValues map[string]string + nullified map[string]bool ) if cs := t.ColumnStates[c]; cs != nil { indent = cs.YAMLIndent() + nullified = cs.NullifiedKeys + if cs.CustomValues != nil { tree = cs.CustomValues.NodeTree docs = cs.DocCommentsForSave() @@ -143,7 +162,7 @@ func (t *OverrideTable) commitOverrideUpdate( } } - yamlText, yamlErr := state.OverridesToYAML(entries, editors, indent, tree, docs, loadedValues) + yamlText, yamlErr := state.OverridesToYAML(entries, editors, indent, tree, docs, loadedValues, nullified) t.OnChanged(c, yamlText, yamlErr) } @@ -160,7 +179,10 @@ func (t *OverrideTable) drainEditorEvents(gtx layout.Context, ed *widget.Editor) // handleCellClick focuses the correct column editor when a right-cell click occurs, // or copies the field key to clipboard when the left key area is clicked. For -// section rows the whole row copies the key since there are no value cells. +// section rows the left panel (key + colon) copies the key; the right panel +// hosts inline action affordances (anchor badges, the nullify button) and +// must NOT trigger a copy on click — otherwise pressing those buttons +// silently puts the section path on the clipboard, which surprises users. func (t *OverrideTable) handleCellClick( gtx layout.Context, index int, @@ -182,8 +204,11 @@ func (t *OverrideTable) handleCellClick( } clickX := ev.Position.X - if section || clickX < g.rightStart { - // Left-side click (or anywhere on a section row): copy the key path. + if clickX < g.rightStart { + // Left-side click (key + default-value area): copy the key + // path. Applies to both sections and leaves — sections have + // a wide key area thanks to the section colon and indent, + // so users still have plenty of target to click for copy. gtx.Execute(clipboard.WriteCmd{ Type: "text/plain", Data: io.NopCloser(strings.NewReader(entryKey)), @@ -196,6 +221,13 @@ func (t *OverrideTable) handleCellClick( continue } + // Right-side click: focus the editor under the cursor. Skip for + // section rows — they have no editor; the right area is for + // inline actions whose own gesture handlers process the click. + if section { + continue + } + // Determine which column was clicked. denom := g.colW + g.subDivW if denom <= 0 { @@ -217,11 +249,12 @@ func (t *OverrideTable) handleCellClick( } } - // Show pointer cursor over the left key area (click-to-copy). For section - // rows the entire row is clickable, so the pointer cursor spans it. + // Show pointer cursor over the left key area (click-to-copy). The + // right side is left to its inline action widgets (badges, buttons) + // to register their own cursors. keyW := g.leftW / 2 //nolint:mnd // key column is half of the left panel if section { - keyW = g.rightStart + rightW + keyW = g.rightStart } keyArea := clip.Rect{Max: image.Pt(keyW, rowH)}.Push(gtx.Ops) diff --git a/ui/widget/paint_primitives.go b/ui/widget/paint_primitives.go index b4e2f8d..3913825 100644 --- a/ui/widget/paint_primitives.go +++ b/ui/widget/paint_primitives.go @@ -35,6 +35,19 @@ func paintRect(gtx layout.Context, r image.Rectangle, c color.NRGBA) { stack.Pop() } +// paintHairlineBorder draws a four-edge inset border of `hairline` width +// inside the rectangle (0,0)→(w,h). Used to approximate a stroked outline +// on rounded-rect chrome at sizes where a true clip.Stroke around an RRect +// would be overkill — the corners read square at hairline thickness anyway. +// Replaces the open-coded "four paintRect calls" idiom that lived in +// null_pill, nullify_icon, extras_filter_pill, and button. +func paintHairlineBorder(gtx layout.Context, w, h, hairline int, c color.NRGBA) { + paintRect(gtx, image.Rect(0, 0, w, hairline), c) + paintRect(gtx, image.Rect(0, h-hairline, w, h), c) + paintRect(gtx, image.Rect(0, 0, hairline, h), c) + paintRect(gtx, image.Rect(w-hairline, 0, w, h), c) +} + // paintRowBg fills a full-width rectangle of the given height with the specified color. func paintRowBg(gtx layout.Context, height int, c color.NRGBA) { rect := clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, height)}.Push(gtx.Ops)