Skip to content

Commit 4bfb0f9

Browse files
mike-wardclaude
andcommitted
refactor: deduplicate list_core nav/filter, add tests
Extract list_core_apply_nav and list_core_prepare to eliminate duplicated keydown dispatch and filter/clamp/ID-collect logic in combobox, command palette, and listbox. Move virtual buffer constant to list_core.v. Reset highlight on close/dismiss. Document ASCII-only fuzzy matching. Add 21 unit tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent adf5623 commit 4bfb0f9

5 files changed

Lines changed: 371 additions & 151 deletions

File tree

_list_core_test.v

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
module gui
2+
3+
fn test_fuzzy_score_exact_match() {
4+
assert list_core_fuzzy_score('open', 'open') == 0
5+
}
6+
7+
fn test_fuzzy_score_subsequence() {
8+
s := list_core_fuzzy_score('open file', 'of')
9+
assert s > 0
10+
}
11+
12+
fn test_fuzzy_score_no_match() {
13+
assert list_core_fuzzy_score('open', 'z') == -1
14+
}
15+
16+
fn test_fuzzy_score_empty_query() {
17+
assert list_core_fuzzy_score('anything', '') == 0
18+
}
19+
20+
fn test_fuzzy_score_empty_candidate() {
21+
assert list_core_fuzzy_score('', 'q') == -1
22+
}
23+
24+
fn test_fuzzy_score_case_insensitive() {
25+
// 'O' at 0, 'F' at 4 → gap 3.
26+
s1 := list_core_fuzzy_score('OpenFile', 'of')
27+
s2 := list_core_fuzzy_score('openfile', 'OF')
28+
assert s1 >= 0
29+
assert s1 == s2
30+
}
31+
32+
fn test_filter_empty_query() {
33+
items := [
34+
ListCoreItem{
35+
id: 'a'
36+
label: 'alpha'
37+
},
38+
ListCoreItem{
39+
id: 'h'
40+
label: 'heading'
41+
is_subheading: true
42+
},
43+
ListCoreItem{
44+
id: 'b'
45+
label: 'beta'
46+
},
47+
]
48+
result := list_core_filter(items, '')
49+
assert result.len == 3
50+
assert result == [0, 1, 2]
51+
}
52+
53+
fn test_filter_ranks_by_score() {
54+
items := [
55+
ListCoreItem{
56+
id: 'a'
57+
label: 'x_o_p_e_n'
58+
},
59+
ListCoreItem{
60+
id: 'b'
61+
label: 'open'
62+
},
63+
]
64+
result := list_core_filter(items, 'open')
65+
assert result.len == 2
66+
// Exact match (score 0) should come first.
67+
assert result[0] == 1
68+
assert result[1] == 0
69+
}
70+
71+
fn test_filter_skips_subheadings() {
72+
items := [
73+
ListCoreItem{
74+
id: 'h'
75+
label: 'open group'
76+
is_subheading: true
77+
},
78+
ListCoreItem{
79+
id: 'a'
80+
label: 'open file'
81+
},
82+
]
83+
result := list_core_filter(items, 'open')
84+
assert result.len == 1
85+
assert result[0] == 1
86+
}
87+
88+
fn test_visible_range_empty() {
89+
first, last := list_core_visible_range(0, 20, 100, 0)
90+
assert first == 0
91+
assert last == -1
92+
}
93+
94+
fn test_visible_range_basic() {
95+
first, last := list_core_visible_range(50, 20, 100, 0)
96+
assert first == 0
97+
assert last > 0
98+
assert last < 50
99+
}
100+
101+
fn test_visible_range_scroll() {
102+
first_a, _ := list_core_visible_range(50, 20, 100, 0)
103+
first_b, _ := list_core_visible_range(50, 20, 100, -200)
104+
assert first_b > first_a
105+
}
106+
107+
fn test_visible_range_clamp() {
108+
_, last := list_core_visible_range(10, 20, 100, -9999)
109+
assert last <= 9
110+
}
111+
112+
fn test_navigate_keys() {
113+
assert list_core_navigate(.up, 5, 2) == .move_up
114+
assert list_core_navigate(.down, 5, 2) == .move_down
115+
assert list_core_navigate(.enter, 5, 2) == .select_item
116+
assert list_core_navigate(.escape, 5, 2) == .dismiss
117+
assert list_core_navigate(.home, 5, 2) == .first
118+
assert list_core_navigate(.end, 5, 2) == .last
119+
}
120+
121+
fn test_navigate_empty() {
122+
assert list_core_navigate(.up, 0, 0) == .none
123+
assert list_core_navigate(.down, 0, 0) == .none
124+
}
125+
126+
fn test_row_height_estimate() {
127+
style := TextStyle{
128+
size: 14
129+
}
130+
pad := Padding{
131+
top: 2
132+
bottom: 3
133+
}
134+
assert list_core_row_height_estimate(style, pad) == 19
135+
}
136+
137+
fn test_to_lower_byte() {
138+
assert to_lower_byte(u8(0x41)) == u8(0x61) // A -> a
139+
assert to_lower_byte(u8(0x5A)) == u8(0x7A) // Z -> z
140+
assert to_lower_byte(u8(0x61)) == u8(0x61) // a -> a
141+
assert to_lower_byte(u8(0x30)) == u8(0x30) // 0 unchanged
142+
}
143+
144+
fn test_apply_nav_move_up() {
145+
next, changed := list_core_apply_nav(.move_up, 3, 10)
146+
assert next == 2
147+
assert changed == true
148+
}
149+
150+
fn test_apply_nav_move_up_at_top() {
151+
next, changed := list_core_apply_nav(.move_up, 0, 10)
152+
assert next == 0
153+
assert changed == false
154+
}
155+
156+
fn test_apply_nav_move_down() {
157+
next, changed := list_core_apply_nav(.move_down, 3, 10)
158+
assert next == 4
159+
assert changed == true
160+
}
161+
162+
fn test_apply_nav_move_down_at_bottom() {
163+
next, changed := list_core_apply_nav(.move_down, 9, 10)
164+
assert next == 9
165+
assert changed == false
166+
}
167+
168+
fn test_apply_nav_first() {
169+
next, changed := list_core_apply_nav(.first, 5, 10)
170+
assert next == 0
171+
assert changed == true
172+
}
173+
174+
fn test_apply_nav_last() {
175+
next, changed := list_core_apply_nav(.last, 0, 10)
176+
assert next == 9
177+
assert changed == true
178+
}
179+
180+
fn test_apply_nav_none() {
181+
next, changed := list_core_apply_nav(.none, 5, 10)
182+
assert next == 5
183+
assert changed == false
184+
}
185+
186+
fn test_prepare_filters_and_clamps() {
187+
items := [
188+
ListCoreItem{
189+
id: 'a'
190+
label: 'alpha'
191+
},
192+
ListCoreItem{
193+
id: 'b'
194+
label: 'beta'
195+
},
196+
ListCoreItem{
197+
id: 'c'
198+
label: 'gamma'
199+
},
200+
]
201+
p := list_core_prepare(items, 'al', 99)
202+
assert p.items.len == 1
203+
assert p.items[0].id == 'a'
204+
assert p.ids == ['a']
205+
assert p.hl == 0 // clamped from 99
206+
}
207+
208+
fn test_prepare_empty_query_returns_all() {
209+
items := [
210+
ListCoreItem{
211+
id: 'a'
212+
label: 'alpha'
213+
},
214+
ListCoreItem{
215+
id: 'b'
216+
label: 'beta'
217+
},
218+
]
219+
p := list_core_prepare(items, '', 1)
220+
assert p.items.len == 2
221+
assert p.hl == 1
222+
}
223+
224+
fn test_prepare_excludes_subheadings_from_ids() {
225+
items := [
226+
ListCoreItem{
227+
id: 'h'
228+
label: 'heading'
229+
is_subheading: true
230+
},
231+
ListCoreItem{
232+
id: 'a'
233+
label: 'alpha'
234+
},
235+
]
236+
p := list_core_prepare(items, '', 0)
237+
// Items includes subheading for rendering.
238+
assert p.items.len == 2
239+
// IDs excludes subheading.
240+
assert p.ids.len == 1
241+
assert p.ids[0] == 'a'
242+
}

list_core.v

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ module gui
33
// list_core.v provides pure functions shared by list_box, select,
44
// combobox, and command_palette. No state, no Window dependency.
55

6+
const list_core_virtual_buffer_rows = 2
7+
68
// ListCoreItem is the normalized item for the shared list engine.
79
// Widgets map their domain types to this before calling core fns.
810
@[minify]
@@ -194,7 +196,7 @@ fn list_core_visible_range(item_count int, row_height f32, list_height f32, scro
194196
abs_scroll := if scroll_y < 0 { -scroll_y } else { scroll_y }
195197
first := int_clamp(int(abs_scroll / row_height), 0, max_idx)
196198
visible_rows := int(list_height / row_height) + 1
197-
buf := list_box_virtual_buffer_rows
199+
buf := list_core_virtual_buffer_rows
198200
first_visible := int_max(0, first - buf)
199201
mut last_visible := int_min(max_idx, first + visible_rows + buf)
200202
if first_visible > last_visible {
@@ -221,7 +223,10 @@ fn list_core_navigate(key KeyCode, item_count int, current int) ListCoreAction {
221223

222224
// list_core_fuzzy_score scores a candidate against a query.
223225
// Returns -1 (no match) or 0+ (lower = better). Zero-alloc,
224-
// raw byte walking.
226+
// raw byte walking. Byte-level comparison; ASCII
227+
// case-insensitive. Multi-byte UTF-8 sequences (accented
228+
// chars, CJK, emoji) are compared byte-by-byte without
229+
// case folding.
225230
fn list_core_fuzzy_score(candidate string, query string) int {
226231
if query.len == 0 {
227232
return 0
@@ -298,6 +303,75 @@ struct ListCoreScored {
298303
score int
299304
}
300305

306+
// list_core_apply_nav applies a navigation action to a
307+
// highlight index. Returns new index and whether it changed.
308+
fn list_core_apply_nav(action ListCoreAction, cur int, item_count int) (int, bool) {
309+
match action {
310+
.move_up {
311+
next := if cur > 0 { cur - 1 } else { 0 }
312+
return next, next != cur
313+
}
314+
.move_down {
315+
next := if cur < item_count - 1 {
316+
cur + 1
317+
} else {
318+
item_count - 1
319+
}
320+
return next, next != cur
321+
}
322+
.first {
323+
return 0, cur != 0
324+
}
325+
.last {
326+
last := if item_count > 0 {
327+
item_count - 1
328+
} else {
329+
0
330+
}
331+
return last, cur != last
332+
}
333+
else {
334+
return cur, false
335+
}
336+
}
337+
}
338+
339+
// ListCorePrepared holds pre-computed filter results for a
340+
// frame.
341+
struct ListCorePrepared {
342+
items []ListCoreItem
343+
ids []string
344+
hl int
345+
}
346+
347+
// list_core_prepare filters items by query, clamps highlight,
348+
// and collects selectable IDs. Skips subheadings from IDs.
349+
fn list_core_prepare(items []ListCoreItem, query string, raw_highlight int) ListCorePrepared {
350+
filtered_indices := list_core_filter(items, query)
351+
mut filtered := []ListCoreItem{cap: filtered_indices.len}
352+
for idx in filtered_indices {
353+
if idx >= 0 && idx < items.len {
354+
filtered << items[idx]
355+
}
356+
}
357+
hl := if filtered.len > 0 {
358+
int_clamp(raw_highlight, 0, filtered.len - 1)
359+
} else {
360+
0
361+
}
362+
mut ids := []string{cap: filtered.len}
363+
for item in filtered {
364+
if !item.is_subheading {
365+
ids << item.id
366+
}
367+
}
368+
return ListCorePrepared{
369+
items: filtered
370+
ids: ids
371+
hl: hl
372+
}
373+
}
374+
301375
// list_core_row_height_estimate estimates row height from text
302376
// style + padding. No Window needed.
303377
fn list_core_row_height_estimate(style TextStyle, pad Padding) f32 {

0 commit comments

Comments
 (0)