471 lines
16 KiB
Go
471 lines
16 KiB
Go
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
|
|
fuzzyAndAmpersandPen = -20
|
|
)
|
|
|
|
// 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", "&"
|
|
// and the ASCII separators are all single-byte, so a byte scan suffices.
|
|
func fuzzyReplaceAndWord(s, repl string) string {
|
|
var b strings.Builder
|
|
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)
|
|
i += 3
|
|
continue
|
|
}
|
|
b.WriteByte(s[i])
|
|
i++
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// 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) so "... and ..." can still hit
|
|
// a "... & ..." target and vice versa, while the exact spelling ranks first.
|
|
func fuzzyAndAmpersandVariants(query string) []string {
|
|
var out []string
|
|
if amp := fuzzyReplaceAndWord(query, "&"); amp != query {
|
|
out = append(out, amp)
|
|
}
|
|
if and := strings.ReplaceAll(query, "&", "and"); and != query {
|
|
out = append(out, and)
|
|
}
|
|
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, target)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
score := m.Score + 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...)
|
|
}
|