ghostscope_ui/components/source_panel/
search.rs1use crate::action::Action;
2use crate::components::command_panel::FileCompletionCache;
3use crate::model::panel_state::{SourcePanelMode, SourcePanelState};
4
5pub struct SourceSearch;
7
8impl SourceSearch {
9 pub fn enter_search_mode(state: &mut SourcePanelState) -> Vec<Action> {
11 state.mode = SourcePanelMode::TextSearch;
12 state.search_query.clear();
13 state.search_matches.clear();
14 state.current_match = None;
15 Vec::new()
16 }
17
18 pub fn exit_search_mode(state: &mut SourcePanelState) -> Vec<Action> {
20 state.mode = SourcePanelMode::Normal;
21 state.search_query.clear();
22 state.search_matches.clear();
23 state.current_match = None;
24 Vec::new()
25 }
26
27 pub fn push_search_char(state: &mut SourcePanelState, ch: char) -> Vec<Action> {
29 if state.mode == SourcePanelMode::TextSearch {
30 state.search_query.push(ch);
31 Self::update_search_matches(state);
32 if !state.search_matches.is_empty() {
34 state.current_match = Some(0);
35 Self::jump_to_match(state, 0);
36 }
37 }
38 Vec::new()
39 }
40
41 pub fn backspace_search(state: &mut SourcePanelState) -> Vec<Action> {
43 if state.mode == SourcePanelMode::TextSearch {
44 state.search_query.pop();
45 Self::update_search_matches(state);
46 if !state.search_matches.is_empty() {
48 state.current_match = Some(0);
49 Self::jump_to_match(state, 0);
50 } else {
51 state.current_match = None;
52 }
53 }
54 Vec::new()
55 }
56
57 pub fn confirm_search(state: &mut SourcePanelState) -> Vec<Action> {
59 if state.mode == SourcePanelMode::TextSearch {
60 state.mode = SourcePanelMode::Normal;
62
63 if !state.search_matches.is_empty() && state.current_match.is_none() {
65 state.current_match = Some(0);
66 Self::jump_to_match(state, 0);
67 }
68 }
69 Vec::new()
70 }
71
72 pub fn next_match(state: &mut SourcePanelState) -> Vec<Action> {
74 if !state.search_matches.is_empty() {
75 let current = state.current_match.unwrap_or(0);
76 let next = (current + 1) % state.search_matches.len();
77 state.current_match = Some(next);
78 Self::jump_to_match(state, next);
79
80 if current == state.search_matches.len() - 1 && next == 0 {
82 tracing::info!("Search wrapped to top");
83 }
84 }
85 Vec::new()
86 }
87
88 pub fn prev_match(state: &mut SourcePanelState) -> Vec<Action> {
90 if !state.search_matches.is_empty() {
91 let current = state.current_match.unwrap_or(0);
92 let prev = if current == 0 {
93 state.search_matches.len() - 1
94 } else {
95 current - 1
96 };
97 state.current_match = Some(prev);
98 Self::jump_to_match(state, prev);
99
100 if current == 0 && prev == state.search_matches.len() - 1 {
102 tracing::info!("Search wrapped to bottom");
103 }
104 }
105 Vec::new()
106 }
107
108 pub fn enter_file_search_mode(state: &mut SourcePanelState) -> Vec<Action> {
110 state.mode = SourcePanelMode::FileSearch;
111 state.file_search_query.clear();
112 state.file_search_cursor_pos = 0;
113 state.file_search_filtered_indices.clear();
114 state.file_search_selected = 0;
115 state.file_search_scroll = 0;
116 state.file_search_message = Some("Loading files...".to_string());
117 Vec::new()
118 }
119
120 pub fn exit_file_search_mode(state: &mut SourcePanelState) -> Vec<Action> {
122 state.mode = SourcePanelMode::Normal;
123 state.file_search_query.clear();
124 state.file_search_cursor_pos = 0;
125 state.file_search_filtered_indices.clear();
126 state.file_search_selected = 0;
127 state.file_search_scroll = 0;
128 state.file_search_message = None;
129 Vec::new()
130 }
131
132 pub fn push_file_search_char(
134 state: &mut SourcePanelState,
135 cache: &FileCompletionCache,
136 ch: char,
137 ) -> Vec<Action> {
138 if state.mode == SourcePanelMode::FileSearch {
139 let mut chars: Vec<char> = state.file_search_query.chars().collect();
140 chars.insert(state.file_search_cursor_pos, ch);
141 state.file_search_query = chars.into_iter().collect();
142 state.file_search_cursor_pos += 1;
143 Self::update_file_search_results(state, cache);
144 }
145 Vec::new()
146 }
147
148 pub fn backspace_file_search(
150 state: &mut SourcePanelState,
151 cache: &FileCompletionCache,
152 ) -> Vec<Action> {
153 if state.mode == SourcePanelMode::FileSearch && state.file_search_cursor_pos > 0 {
154 let mut chars: Vec<char> = state.file_search_query.chars().collect();
155 chars.remove(state.file_search_cursor_pos - 1);
156 state.file_search_query = chars.into_iter().collect();
157 state.file_search_cursor_pos -= 1;
158 Self::update_file_search_results(state, cache);
159 }
160 Vec::new()
161 }
162
163 pub fn clear_file_search_query(
165 state: &mut SourcePanelState,
166 cache: &FileCompletionCache,
167 ) -> Vec<Action> {
168 if state.mode == SourcePanelMode::FileSearch {
169 state.file_search_query.clear();
170 state.file_search_cursor_pos = 0;
171 Self::update_file_search_results(state, cache);
172 }
173 Vec::new()
174 }
175
176 pub fn delete_word_file_search(
178 state: &mut SourcePanelState,
179 cache: &FileCompletionCache,
180 ) -> Vec<Action> {
181 if state.mode == SourcePanelMode::FileSearch && state.file_search_cursor_pos > 0 {
182 let chars: Vec<char> = state.file_search_query.chars().collect();
183 let mut start_pos = state.file_search_cursor_pos;
184
185 let is_separator = |c: char| {
187 c.is_whitespace() || c == '/' || c == '\\' || c == '.' || c == '-' || c == '_'
188 };
189
190 while start_pos > 0 && is_separator(chars[start_pos - 1]) {
192 start_pos -= 1;
193 }
194
195 while start_pos > 0 && !is_separator(chars[start_pos - 1]) {
197 start_pos -= 1;
198 }
199
200 let mut new_chars = chars[..start_pos].to_vec();
202 new_chars.extend_from_slice(&chars[state.file_search_cursor_pos..]);
203
204 state.file_search_query = new_chars.into_iter().collect();
205 state.file_search_cursor_pos = start_pos;
206 Self::update_file_search_results(state, cache);
207 }
208 Vec::new()
209 }
210
211 pub fn move_cursor_to_start(state: &mut SourcePanelState) -> Vec<Action> {
213 if state.mode == SourcePanelMode::FileSearch {
214 state.file_search_cursor_pos = 0;
215 }
216 Vec::new()
217 }
218
219 pub fn move_cursor_to_end(state: &mut SourcePanelState) -> Vec<Action> {
221 if state.mode == SourcePanelMode::FileSearch {
222 state.file_search_cursor_pos = state.file_search_query.chars().count();
223 }
224 Vec::new()
225 }
226
227 pub fn move_cursor_left(state: &mut SourcePanelState) -> Vec<Action> {
229 if state.mode == SourcePanelMode::FileSearch && state.file_search_cursor_pos > 0 {
230 state.file_search_cursor_pos -= 1;
231 }
232 Vec::new()
233 }
234
235 pub fn move_cursor_right(state: &mut SourcePanelState) -> Vec<Action> {
237 if state.mode == SourcePanelMode::FileSearch {
238 let max_pos = state.file_search_query.chars().count();
239 if state.file_search_cursor_pos < max_pos {
240 state.file_search_cursor_pos += 1;
241 }
242 }
243 Vec::new()
244 }
245
246 pub fn move_file_search_up(state: &mut SourcePanelState) -> Vec<Action> {
248 if state.mode == SourcePanelMode::FileSearch
249 && !state.file_search_filtered_indices.is_empty()
250 {
251 if state.file_search_selected > 0 {
252 state.file_search_selected -= 1;
253 } else {
254 state.file_search_selected = state.file_search_filtered_indices.len() - 1;
255 }
256 Self::ensure_file_search_visible(state);
257 }
258 Vec::new()
259 }
260
261 pub fn move_file_search_down(state: &mut SourcePanelState) -> Vec<Action> {
263 if state.mode == SourcePanelMode::FileSearch
264 && !state.file_search_filtered_indices.is_empty()
265 {
266 state.file_search_selected =
267 (state.file_search_selected + 1) % state.file_search_filtered_indices.len();
268 Self::ensure_file_search_visible(state);
269 }
270 Vec::new()
271 }
272
273 pub fn confirm_file_search(
275 state: &mut SourcePanelState,
276 cache: &FileCompletionCache,
277 ) -> Option<String> {
278 if state.mode == SourcePanelMode::FileSearch
279 && !state.file_search_filtered_indices.is_empty()
280 {
281 let real_idx = state.file_search_filtered_indices[state.file_search_selected];
282 let selected_file = cache.get_all_files().get(real_idx).cloned();
283 Self::exit_file_search_mode(state);
284 selected_file
285 } else {
286 None
287 }
288 }
289
290 pub fn set_file_search_files(
292 state: &mut SourcePanelState,
293 cache: &mut FileCompletionCache,
294 files: Vec<String>,
295 ) -> Vec<Action> {
296 cache.set_all_files(files);
297 state.file_search_message = None;
298 Self::update_file_search_results(state, cache);
299 Vec::new()
300 }
301
302 pub fn set_file_search_error(state: &mut SourcePanelState, error: String) -> Vec<Action> {
304 state.file_search_message = Some(format!("✗ {error}"));
305 state.file_search_filtered_indices.clear();
306 Vec::new()
307 }
308
309 fn update_search_matches(state: &mut SourcePanelState) {
311 let old_cursor_line = state.cursor_line;
312 let old_cursor_col = state.cursor_col;
313
314 state.search_matches.clear();
315 state.current_match = None;
316
317 if state.search_query.is_empty() {
318 return;
319 }
320
321 let query = state.search_query.to_lowercase();
322 for (line_idx, line) in state.content.iter().enumerate() {
323 let line_lower = line.to_lowercase();
324 let mut start = 0;
325 while let Some(pos) = line_lower[start..].find(&query) {
326 let match_start = start + pos;
327 let match_end = match_start + query.len();
328 state
329 .search_matches
330 .push((line_idx, match_start, match_end));
331 start = match_start + 1;
332 }
333 }
334
335 if !state.search_matches.is_empty() {
337 let mut best_match = 0;
338 for (idx, (line_idx, col_start, _)) in state.search_matches.iter().enumerate() {
339 if *line_idx > old_cursor_line
340 || (*line_idx == old_cursor_line && *col_start >= old_cursor_col)
341 {
342 best_match = idx;
343 break;
344 }
345 }
346 state.current_match = Some(best_match);
347 }
348 }
349
350 fn jump_to_match(state: &mut SourcePanelState, match_idx: usize) {
352 if let Some((line_idx, col_start, _)) = state.search_matches.get(match_idx) {
353 state.cursor_line = *line_idx;
354 state.cursor_col = *col_start; let visible_lines = 30; if state.cursor_line < state.scroll_offset {
360 state.scroll_offset = state.cursor_line;
361 } else if state.cursor_line >= state.scroll_offset + visible_lines {
362 state.scroll_offset = state
363 .cursor_line
364 .saturating_sub(visible_lines.saturating_sub(1));
365 }
366
367 if let Some(current_line) = state.content.get(state.cursor_line) {
369 let line_number_width = 5; let border_width = 2; let available_width = (state
372 .area_width
373 .saturating_sub(line_number_width + border_width))
374 as usize;
375
376 if current_line.len() <= available_width {
377 state.horizontal_scroll_offset = 0;
379 } else {
380 let scrolloff = available_width / 3; let ideal_scroll = state.cursor_col.saturating_sub(scrolloff);
385
386 let max_scroll = current_line.len().saturating_sub(available_width);
388
389 let near_end = state.cursor_col >= max_scroll.saturating_add(scrolloff);
391
392 if near_end {
393 state.horizontal_scroll_offset = max_scroll;
395 } else {
396 state.horizontal_scroll_offset = ideal_scroll.min(max_scroll);
398 }
399 }
400 }
401 }
402 }
403
404 fn update_file_search_results(state: &mut SourcePanelState, cache: &FileCompletionCache) {
406 state.file_search_filtered_indices.clear();
407 state.file_search_selected = 0;
408 state.file_search_scroll = 0;
409
410 let all_files = cache.get_all_files();
411 if state.file_search_query.is_empty() {
412 state.file_search_filtered_indices = (0..all_files.len()).collect();
414 } else {
415 let query = state.file_search_query.to_lowercase();
417 for (idx, file) in all_files.iter().enumerate() {
418 if file.to_lowercase().contains(&query) {
419 state.file_search_filtered_indices.push(idx);
420 }
421 }
422 }
423 }
424
425 fn ensure_file_search_visible(state: &mut SourcePanelState) {
427 let visible_count = 10; if state.file_search_selected < state.file_search_scroll {
430 state.file_search_scroll = state.file_search_selected;
431 } else if state.file_search_selected >= state.file_search_scroll + visible_count {
432 state.file_search_scroll = state.file_search_selected.saturating_sub(visible_count - 1);
433 }
434 }
435}