package webui import ( "sort" "strconv" "strings" "unicode" "kjol/wasmruntime/vdom" ) // Port of web/kit/FuzzyMatch.tsx. // // The fuzzy-scoring/matching logic (Forrest Smith's fts_fuzzy_match, Sublime- // style subsequence scoring) is pure and is ported directly to Go below. The // component keeps the API + structure + Tailwind and models query / open / // highlighted state as plain value props + callbacks. // // NOTE: the exported matcher `fuzzyMatch` would collide (case-folded) with the // `FuzzyMatch` component in Go, so it is renamed to FuzzyMatchOne here. The // other matchers keep their names (Go-cased): FuzzyMatchTypoTolerant, // FuzzySegments, RankFuzzyMatches. // NOTE: positions are rune indices (the TSX used UTF-16 code-unit indices); // these agree for BMP text and are what FuzzySegments consumes here. // ============================================================================ // Fuzzy matching (Sublime-style subsequence scoring) // ============================================================================ // FuzzyMatchResult is a scored match with the matched character positions. type FuzzyMatchResult struct { Score int Positions []int } // FuzzySegment is a run of text flagged matched or unmatched (for highlighting). type FuzzySegment struct { Text string Match bool } // FuzzyRankedItem is one ranked option: its value, score, and highlight segments. type FuzzyRankedItem struct { Value string Score int Segments []FuzzySegment } const ( fuzzySequentialBonus = 15 fuzzySeparatorBonus = 30 fuzzyCamelBonus = 30 fuzzyFirstLetterBonus = 15 fuzzyLeadingPenalty = -5 fuzzyMaxLeadingPenalty = -15 fuzzyUnmatchedPenalty = -1 fuzzyRecursionLimit = 10 fuzzyTransposePenalty = -20 fuzzyExactSubstrBonus = 100 // Per swapped "&"<->"and" token. Each swap lengthens the matched run by two // characters, worth up to 2*fuzzySequentialBonus to the longer "and" spelling; // penalize each swap by more than that so the literal spelling the user typed // always ranks above the substituted one. (This lowers rank, not visibility — // the substituted match still appears in the results.) fuzzyAndAmpersandPen = -(2*fuzzySequentialBonus + 20) // -50 ) // FuzzyOptions turns on extra, penalized match passes. The zero value is exact- // only, so passing no options leaves the base matcher unchanged. type FuzzyOptions struct { // AndAmpersand treats the word "and" and the symbol "&" as interchangeable, // so "First Bank and Trust" also finds "First Bank & Trust" (and vice versa). // The exact spelling still wins — the substituted form is penalized, not free. AndAmpersand bool } func fuzzyOpt(opts []FuzzyOptions) FuzzyOptions { if len(opts) > 0 { return opts[0] } return FuzzyOptions{} } func fuzzyIsLower(c rune) bool { return c >= 'a' && c <= 'z' } func fuzzyIsUpper(c rune) bool { return c >= 'A' && c <= 'Z' } func fuzzyIsSeparator(c rune) bool { return c == ' ' || c == '_' || c == '-' } func fuzzyMax(a, b int) int { if a > b { return a } return b } func fuzzyScore(target []rune, matches []int) int { score := 100 score += fuzzyMax(fuzzyMaxLeadingPenalty, fuzzyLeadingPenalty*matches[0]) score += fuzzyUnmatchedPenalty * (len(target) - len(matches)) for i := 0; i < len(matches); i++ { curr := matches[i] if i > 0 && curr == matches[i-1]+1 { score += fuzzySequentialBonus } if curr == 0 { score += fuzzyFirstLetterBonus } else { prev := target[curr-1] if fuzzyIsLower(prev) && fuzzyIsUpper(target[curr]) { score += fuzzyCamelBonus } if fuzzyIsSeparator(prev) { score += fuzzySeparatorBonus } } } return score } func fuzzyRecurse(query, target []rune, qi, ti int, matches []int, rec *int) []int { *rec++ if *rec >= fuzzyRecursionLimit { return nil } var best []int for qi < len(query) && ti < len(target) { if unicode.ToLower(query[qi]) == unicode.ToLower(target[ti]) { skipped := fuzzyRecurse(query, target, qi, ti+1, append([]int(nil), matches...), rec) if skipped != nil && (best == nil || fuzzyScore(target, skipped) > fuzzyScore(target, best)) { best = skipped } matches = append(matches, ti) qi++ } ti++ } if qi < len(query) { return best // query not fully consumed -> this path failed } if best == nil || fuzzyScore(target, matches) > fuzzyScore(target, best) { return matches } return best } // FuzzyMatchOne scores query against target, or returns nil if the query is // empty or is not a subsequence of target. (Port of the TSX `fuzzyMatch`.) func FuzzyMatchOne(query, target string) *FuzzyMatchResult { if query == "" { return nil } qr := []rune(query) tr := []rune(target) rec := 0 matches := fuzzyRecurse(qr, tr, 0, 0, nil, &rec) if matches == nil { return nil } score := fuzzyScore(tr, matches) // A contiguous substring hit ("bankof" in "Bankof") should outrank a // word-boundary match split across tokens ("Bank of America"). The bonus is // constant per query/target, so it lives here rather than in the per- // alignment scorer the recursion uses to pick match positions. if strings.Contains(strings.ToLower(target), strings.ToLower(query)) { score += fuzzyExactSubstrBonus } return &FuzzyMatchResult{Score: score, Positions: matches} } func fuzzyIsWordByte(c byte) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c >= 0x80 } func fuzzyByteAt(s string, i int) byte { if i < 0 || i >= len(s) { return 0 } return s[i] } // fuzzyReplaceAndWord replaces every standalone "and" (case-insensitive) in s // with repl, leaving substrings like "brand" or "island" untouched, and reports // how many it replaced. "and", "&" and the ASCII separators are all single-byte, // so a byte scan suffices. func fuzzyReplaceAndWord(s, repl string) (string, int) { var b strings.Builder n := 0 for i := 0; i < len(s); { isAnd := i+3 <= len(s) && (s[i] == 'a' || s[i] == 'A') && (s[i+1] == 'n' || s[i+1] == 'N') && (s[i+2] == 'd' || s[i+2] == 'D') if isAnd && !fuzzyIsWordByte(fuzzyByteAt(s, i-1)) && !fuzzyIsWordByte(fuzzyByteAt(s, i+3)) { b.WriteString(repl) n++ i += 3 continue } b.WriteByte(s[i]) i++ } return b.String(), n } // fuzzyVariant is an alternative spelling of the query plus the number of tokens // swapped to produce it, so the penalty can scale with how far it strayed. type fuzzyVariant struct { text string swaps int } // fuzzyAndAmpersandVariants returns alternative spellings of query with the word // "and" and the symbol "&" swapped for each other, excluding query itself. These // are the candidates the matcher tries (penalized per swap) so "... and ..." can // still hit a "... & ..." target and vice versa, while the exact spelling ranks // first. func fuzzyAndAmpersandVariants(query string) []fuzzyVariant { var out []fuzzyVariant if amp, n := fuzzyReplaceAndWord(query, "&"); n > 0 { out = append(out, fuzzyVariant{amp, n}) } if n := strings.Count(query, "&"); n > 0 { out = append(out, fuzzyVariant{strings.ReplaceAll(query, "&", "and"), n}) } return out } // FuzzyMatchTypoTolerant also tries every single adjacent-swap variant of the // query (subsequence matching can't tolerate a transposed typo like "teh" vs // "the"), penalizing transposed hits so exact matches still rank first. With // opts.AndAmpersand set, the "and"/"&" spellings are tried the same way — an // extra penalized pass, not a free substitution. func FuzzyMatchTypoTolerant(query, target string, opts ...FuzzyOptions) *FuzzyMatchResult { best := FuzzyMatchOne(query, target) qr := []rune(query) for i := 0; i < len(qr)-1; i++ { swapped := string(qr[:i]) + string(qr[i+1]) + string(qr[i]) + string(qr[i+2:]) m := FuzzyMatchOne(swapped, target) if m == nil { continue } score := m.Score + fuzzyTransposePenalty if best == nil || score > best.Score { best = &FuzzyMatchResult{Score: score, Positions: m.Positions} } } if fuzzyOpt(opts).AndAmpersand { for _, variant := range fuzzyAndAmpersandVariants(query) { m := FuzzyMatchOne(variant.text, target) if m == nil { continue } score := m.Score + variant.swaps*fuzzyAndAmpersandPen if best == nil || score > best.Score { best = &FuzzyMatchResult{Score: score, Positions: m.Positions} } } } return best } // FuzzySegments splits text into alternating matched / unmatched runs for // highlighting, using the given (rune-index) positions. func FuzzySegments(text string, positions []int) []FuzzySegment { matched := make(map[int]bool, len(positions)) for _, p := range positions { matched[p] = true } tr := []rune(text) var segments []FuzzySegment buf := "" bufMatch := matched[0] for i := 0; i < len(tr); i++ { isMatch := matched[i] if isMatch != bufMatch { if buf != "" { segments = append(segments, FuzzySegment{Text: buf, Match: bufMatch}) } buf = "" bufMatch = isMatch } buf += string(tr[i]) } if buf != "" { segments = append(segments, FuzzySegment{Text: buf, Match: bufMatch}) } return segments } // RankFuzzyMatches ranks options against query, best score first, with // highlight segments. Returns nil for an empty query. maxResults <= 0 means no // limit. This is the headless entry point. func RankFuzzyMatches(query string, options []string, maxResults int, opts ...FuzzyOptions) []FuzzyRankedItem { q := strings.TrimSpace(query) if q == "" { return nil } var out []FuzzyRankedItem for _, value := range options { m := FuzzyMatchTypoTolerant(q, value, opts...) if m != nil { out = append(out, FuzzyRankedItem{Value: value, Score: m.Score, Segments: FuzzySegments(value, m.Positions)}) } } sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score }) if maxResults > 0 && len(out) > maxResults { out = out[:maxResults] } return out } // ============================================================================ // Component // ============================================================================ // FuzzyMatchDisplay selects how FuzzyMatch renders its results. type FuzzyMatchDisplay = string const ( FuzzyDisplayList = "list" // inline highlighted results below the input (default) FuzzyDisplayDropdown = "dropdown" // ComboBox-style autocomplete popover FuzzyDisplayNone = "none" // render only the input (headless) ) const fuzzyInputCls = "bg-surface block w-full border border-line-strong rounded-default shadow-xs text-sm p-2 h-[38px] focus:outline-2 focus:outline-offset-1 focus:outline-sky-500" // Mirror FormCombobox's dropdown styling: neutral hover / highlight. const fuzzyDropdownCls = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto" const fuzzyDropdownOptionCls = "w-full text-left p-2 text-sm cursor-pointer flex items-center justify-between gap-2 bg-transparent border-none hover:bg-surface-raised whitespace-nowrap" const fuzzyDropdownOptionHighlightCls = "bg-surface-raised" // FuzzyMatchProps configures the FuzzyMatch component. Query / Open / Highlighted // are caller-held state (read at the call site); the On* callbacks report changes. type FuzzyMatchProps struct { Options []string Display FuzzyMatchDisplay // default FuzzyDisplayList ShowScores bool MaxResults int AndAmpersand bool // treat "and" and "&" as interchangeable (see FuzzyOptions) Placeholder string Class string ListClass string Query string // current query text Open bool // dropdown open (Display == dropdown) Highlighted int // highlighted result index (dropdown keyboard selection) OnSelect func(value string, item FuzzyRankedItem) OnQueryChange func(query string) OnResults func(results []FuzzyRankedItem) // see NOTE: not auto-invoked } // fuzzyHighlight renders a segment list as sky-highlighted / plain spans. func fuzzyHighlight(segments []FuzzySegment) []*vdom.VNode { out := make([]*vdom.VNode, 0, len(segments)) for _, seg := range segments { if seg.Match { out = append(out, vdom.Span(vdom.Attr("class", "text-sky-700 dark:text-sky-400 font-semibold"), vdom.Text(seg.Text))) } else { out = append(out, vdom.Span(vdom.Text(seg.Text))) } } return out } // fuzzyScoreBadge renders the debug score badge, or nil when disabled. func fuzzyScoreBadge(show bool, score int) *vdom.VNode { if !show { return nil } return vdom.Span(vdom.Attr("class", "ml-3 shrink-0 text-ss text-ink-faint"), vdom.Text(strconv.Itoa(score))) } // fuzzyListView renders the inline "list" display. Before the user types, all // options show unranked so the searchable set is visible up front. func fuzzyListView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode { var items []FuzzyRankedItem if strings.TrimSpace(p.Query) != "" { items = results } else { for _, v := range p.Options { items = append(items, FuzzyRankedItem{Value: v, Score: 0, Segments: []FuzzySegment{{Text: v, Match: false}}}) } } outer := []vdom.Mod{vdom.Attr("class", cx("mt-3", pick(p.ListClass, "h-72 overflow-y-auto")))} if len(items) > 0 { ul := []vdom.Mod{vdom.Attr("class", "flex flex-col gap-0.5")} for _, r := range items { r := r li := []vdom.Mod{ vdom.Attr("class", "flex items-center justify-between gap-3 px-2 py-1 rounded-default cursor-pointer hover:bg-surface-raised"), } if p.OnSelect != nil { li = append(li, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) })) } li = append(li, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-sm text-ink")}, fuzzyHighlight(r.Segments))...)) if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil { li = append(li, badge) } ul = append(ul, vdom.Li(li...)) } outer = append(outer, vdom.Ul(ul...)) } else if strings.TrimSpace(p.Query) != "" { outer = append(outer, vdom.P(vdom.Attr("class", "text-sm text-ink-muted italic"), vdom.Text(`No matches for "`+p.Query+`".`))) } return vdom.Div(outer...) } // fuzzyDropdownView renders the "dropdown" display's option panel. // // NOTE: the TSX portaled this to document.body with `position: fixed` coords // from getBoundingClientRect + a resize/scroll effect; here it's a statically // `absolute` panel anchored under the input (the container is `relative`). func fuzzyDropdownView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode { mods := []vdom.Mod{ vdom.Attr("data-floating-content", "true"), vdom.Attr("class", cx("absolute left-0 right-0 top-full mt-1 z-[200]", fuzzyDropdownCls)), } for i, r := range results { r := r cls := fuzzyDropdownOptionCls if i == p.Highlighted { cls = cx(fuzzyDropdownOptionCls, fuzzyDropdownOptionHighlightCls) } btnMods := []vdom.Mod{ vdom.Attr("type", "button"), vdom.Attr("class", cls), } if p.OnSelect != nil { btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) })) } btnMods = append(btnMods, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-ink")}, fuzzyHighlight(r.Segments))...)) if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil { btnMods = append(btnMods, badge) } mods = append(mods, vdom.Button(btnMods...)) } return vdom.Div(mods...) } // FuzzyMatch renders a fuzzy-search input with inline ("list"), autocomplete // ("dropdown"), or headless ("none") result display. // // NOTE: keyboard navigation (ArrowUp/Down/Enter/Escape) is dropped — the vdom // Event exposes no key. In dropdown mode, updating Query and closing Open on // select, opening on focus/typing, and outside-click dismissal are the caller's // responsibility (report them via the On* callbacks). OnResults is retained for // API parity but is not auto-invoked; headless callers should call // RankFuzzyMatches directly. func FuzzyMatch(p FuzzyMatchProps) *vdom.VNode { display := p.Display if display == "" { display = FuzzyDisplayList } results := RankFuzzyMatches(p.Query, p.Options, p.MaxResults, FuzzyOptions{AndAmpersand: p.AndAmpersand}) inputMods := []vdom.Mod{ vdom.Attr("type", "text"), vdom.Attr("class", fuzzyInputCls), vdom.Prop("value", p.Query), vdom.Attr("placeholder", pick(p.Placeholder, "Search...")), } if p.OnQueryChange != nil { inputMods = append(inputMods, vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) { p.OnQueryChange(e.Value()) })) } mods := []vdom.Mod{ vdom.Attr("class", cx("relative", p.Class)), vdom.Input(inputMods...), } switch display { case FuzzyDisplayList: mods = append(mods, fuzzyListView(p, results)) case FuzzyDisplayDropdown: if p.Open && len(results) > 0 { mods = append(mods, fuzzyDropdownView(p, results)) } } return vdom.Div(mods...) }