Skip to content

Commit a8d25b7

Browse files
tobiclaude
andcommitted
feat: fuzzy search, improved dialogs, version 1.7.0
- Integrate Fuzzy class for fuzzy matching with scoring - Refactor delete dialog: items left-aligned, centered confirmation - Refactor rename dialog to use dedicated screen like delete - Add echo for cd path in shell scripts - Bump version to 1.7.0 Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 72fb285 commit a8d25b7

File tree

5 files changed

+665
-300
lines changed

5 files changed

+665
-300
lines changed

lib/fuzzy.rb

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# frozen_string_literal: true
2+
3+
# Fuzzy string matching with scoring and highlight positions
4+
#
5+
# Usage:
6+
# entries = [
7+
# { text: "2024-01-15-project", base_score: 3.2 },
8+
# { text: "2024-02-20-another", base_score: 1.5 },
9+
# ]
10+
# fuzzy = Fuzzy.new(entries)
11+
#
12+
# # Get all matches
13+
# fuzzy.match("proj").each do |entry, positions, score|
14+
# puts "#{entry[:text]} score=#{score} highlight=#{positions.inspect}"
15+
# end
16+
#
17+
# # Limit results
18+
# fuzzy.match("proj").limit(10).each { |entry, positions, score| ... }
19+
#
20+
class Fuzzy
21+
Entry = Data.define(:data, :text, :text_lower, :base_score)
22+
23+
def initialize(entries)
24+
@entries = entries.map do |e|
25+
text = e[:text] || e["text"]
26+
Entry.new(
27+
data: e,
28+
text: text,
29+
text_lower: text.downcase,
30+
base_score: e[:base_score] || e["base_score"] || 0.0
31+
)
32+
end
33+
end
34+
35+
# Returns a MatchResult enumerator for the query
36+
def match(query)
37+
MatchResult.new(@entries, query.to_s)
38+
end
39+
40+
# Enumerator wrapper that supports .limit() and .each
41+
class MatchResult
42+
include Enumerable
43+
44+
def initialize(entries, query)
45+
@entries = entries
46+
@query = query
47+
@query_lower = query.downcase
48+
@query_chars = @query_lower.chars
49+
@limit = nil
50+
end
51+
52+
# Set maximum number of results
53+
def limit(n)
54+
@limit = n
55+
self
56+
end
57+
58+
# Iterate over matches: yields (entry_data, highlight_positions, score)
59+
def each(&block)
60+
return enum_for(:each) unless block_given?
61+
62+
results = []
63+
64+
@entries.each do |entry|
65+
score, positions = calculate_match(entry)
66+
next if score.nil? # No match
67+
68+
results << [entry.data, positions, score]
69+
end
70+
71+
# Sort by score descending
72+
results.sort_by! { |_, _, score| -score }
73+
74+
# Apply limit
75+
results = results.first(@limit) if @limit
76+
77+
results.each(&block)
78+
end
79+
80+
private
81+
82+
def calculate_match(entry)
83+
positions = []
84+
score = entry.base_score
85+
86+
# Empty query = match all with base score only
87+
if @query.empty?
88+
return [score, positions]
89+
end
90+
91+
text_lower = entry.text_lower
92+
text_len = text_lower.length
93+
query_len = @query_chars.length
94+
95+
last_pos = -1
96+
query_idx = 0
97+
98+
i = 0
99+
while i < text_len
100+
break if query_idx >= query_len
101+
102+
if text_lower[i] == @query_chars[query_idx]
103+
positions << i
104+
105+
# Base match point
106+
score += 1.0
107+
108+
# Word boundary bonus (start of string or after non-alphanumeric)
109+
is_boundary = (i == 0) || text_lower[i - 1].match?(/[^a-z0-9]/)
110+
score += 1.0 if is_boundary
111+
112+
# Proximity bonus (consecutive chars score higher)
113+
if last_pos >= 0
114+
gap = i - last_pos - 1
115+
score += 2.0 / Math.sqrt(gap + 1)
116+
end
117+
118+
last_pos = i
119+
query_idx += 1
120+
end
121+
122+
i += 1
123+
end
124+
125+
# Not all query chars matched = no match
126+
return nil if query_idx < query_len
127+
128+
# Density bonus: prefer shorter spans
129+
score *= (query_len.to_f / (last_pos + 1)) if last_pos >= 0
130+
131+
# Length penalty: shorter strings score higher
132+
score *= (10.0 / (entry.text.length + 10.0))
133+
134+
[score, positions]
135+
end
136+
end
137+
end

0 commit comments

Comments
 (0)