Fortran · 27647 bytes Raw Blame History
1 module search_prompt_module
2 use iso_fortran_env, only: input_unit, output_unit
3 use terminal_io_module
4 use editor_state_module, only: editor_state_t, cursor_t
5 use text_buffer_module
6 implicit none
7 private
8
9 public :: show_search_prompt, search_forward, search_backward
10 public :: current_search_pattern, clear_search_pattern
11 public :: find_next_match, find_prev_match, center_viewport_on_cursor
12
13 ! Module variables for search state
14 character(len=:), allocatable :: current_search_pattern
15 integer :: last_search_line = 1
16 integer :: last_search_col = 1
17
18 ! Search options
19 logical :: case_sensitive = .false.
20 logical :: whole_word = .false.
21 integer :: total_matches = 0
22 integer :: current_match_index = 0
23
24 contains
25
26 subroutine show_search_prompt(editor, buffer)
27 type(editor_state_t), intent(inout) :: editor
28 type(buffer_t), intent(inout) :: buffer
29 character(len=256) :: input_buffer
30 character(len=128) :: prompt
31 integer :: input_pos, ch
32 logical :: found
33 integer :: found_line, found_col
34 logical :: in_alt_sequence
35
36 ! Initialize
37 input_buffer = ''
38 input_pos = 0
39 in_alt_sequence = .false.
40
41 ! Build prompt with options
42 call build_search_prompt(prompt)
43
44 ! Display prompt at bottom of screen
45 call terminal_move_cursor(editor%screen_rows, 1)
46 call terminal_write(prompt)
47 call terminal_show_cursor()
48
49 ! Input loop
50 do
51 ch = terminal_read_char()
52
53 if (ch == -1) then
54 ! No input, continue
55 cycle
56 else if (ch == 27) then ! ESC
57 ! Check if this is an Alt sequence or standalone ESC
58 in_alt_sequence = .true.
59 ch = terminal_read_char()
60
61 if (ch == -1 .or. ch == 27) then
62 ! Standalone ESC - cancel search
63 if (allocated(current_search_pattern)) then
64 deallocate(current_search_pattern)
65 end if
66 exit
67 else if (ch == iachar('c') .or. ch == iachar('C')) then
68 ! Alt+C - toggle case sensitive
69 case_sensitive = .not. case_sensitive
70 call build_search_prompt(prompt)
71 call terminal_move_cursor(editor%screen_rows, 1)
72 ! Clear the entire line first to avoid duplicate text
73 call terminal_write(repeat(' ', editor%screen_cols))
74 call terminal_move_cursor(editor%screen_rows, 1)
75 call terminal_write(prompt // input_buffer(1:input_pos))
76 call terminal_move_cursor(editor%screen_rows, len_trim(prompt) + input_pos + 1)
77 in_alt_sequence = .false.
78 else if (ch == iachar('w') .or. ch == iachar('W')) then
79 ! Alt+W - toggle whole word
80 whole_word = .not. whole_word
81 call build_search_prompt(prompt)
82 call terminal_move_cursor(editor%screen_rows, 1)
83 ! Clear the entire line first to avoid duplicate text
84 call terminal_write(repeat(' ', editor%screen_cols))
85 call terminal_move_cursor(editor%screen_rows, 1)
86 call terminal_write(prompt // input_buffer(1:input_pos))
87 call terminal_move_cursor(editor%screen_rows, len_trim(prompt) + input_pos + 1)
88 in_alt_sequence = .false.
89 else
90 ! Unknown Alt sequence, ignore
91 in_alt_sequence = .false.
92 end if
93 else if (ch == 10 .or. ch == 13) then ! Enter - accept
94 if (input_pos > 0) then
95 ! Save search pattern
96 if (allocated(current_search_pattern)) then
97 deallocate(current_search_pattern)
98 end if
99 allocate(character(len=input_pos) :: current_search_pattern)
100 current_search_pattern = input_buffer(1:input_pos)
101
102 ! Count all matches first
103 call count_all_matches(buffer, current_search_pattern)
104
105 ! Search from current position
106 call find_next_match(buffer, current_search_pattern, &
107 editor%cursors(editor%active_cursor)%line, &
108 editor%cursors(editor%active_cursor)%column, &
109 found, found_line, found_col)
110
111 if (found) then
112 ! Move cursor to match and select it
113 editor%cursors(editor%active_cursor)%line = found_line
114 editor%cursors(editor%active_cursor)%column = found_col
115 editor%cursors(editor%active_cursor)%desired_column = found_col
116
117 ! Create selection for the match
118 editor%cursors(editor%active_cursor)%has_selection = .true.
119 editor%cursors(editor%active_cursor)%selection_start_line = found_line
120 editor%cursors(editor%active_cursor)%selection_start_col = found_col
121 editor%cursors(editor%active_cursor)%column = found_col + len(current_search_pattern)
122
123 ! Update last search position
124 last_search_line = found_line
125 last_search_col = found_col
126
127 ! Center viewport on match
128 call center_viewport_on_cursor(editor)
129 else
130 ! Show "not found" message briefly
131 call terminal_move_cursor(editor%screen_rows, 1)
132 call terminal_write('Pattern not found: ' // current_search_pattern)
133 call flush(output_unit)
134 ! Note: In a real implementation, we'd want a better way to show this
135 end if
136 end if
137 exit
138 else if (ch == 127 .or. ch == 8) then ! Backspace
139 if (input_pos > 0) then
140 input_pos = input_pos - 1
141 ! Redraw prompt and input
142 call terminal_move_cursor(editor%screen_rows, 1)
143 call terminal_write(prompt // input_buffer(1:input_pos) // ' ')
144 call terminal_move_cursor(editor%screen_rows, len(prompt) + input_pos + 1)
145
146 ! Incremental search with reduced pattern
147 if (input_pos > 0) then
148 call perform_incremental_search(editor, buffer, input_buffer(1:input_pos))
149 end if
150 end if
151 else if (ch >= 32 .and. ch <= 126) then ! Printable characters
152 if (input_pos < 256) then
153 input_pos = input_pos + 1
154 input_buffer(input_pos:input_pos) = char(ch)
155 call terminal_write(char(ch))
156
157 ! Incremental search - search as user types
158 if (input_pos > 0) then
159 call perform_incremental_search(editor, buffer, input_buffer(1:input_pos))
160 end if
161 end if
162 end if
163 end do
164
165 ! Clean up - hide cursor and clear prompt line
166 call terminal_hide_cursor()
167 call terminal_move_cursor(editor%screen_rows, 1)
168 call terminal_write(repeat(' ', editor%screen_cols))
169 end subroutine show_search_prompt
170
171 subroutine search_forward(editor, buffer)
172 type(editor_state_t), intent(inout) :: editor
173 type(buffer_t), intent(inout) :: buffer
174 logical :: found
175 integer :: found_line, found_col
176 integer :: start_line, start_col
177
178 if (.not. allocated(current_search_pattern)) return
179
180 ! Re-count matches in case search options changed
181 call count_all_matches(buffer, current_search_pattern)
182
183 ! Search from cursor position
184 start_line = editor%cursors(editor%active_cursor)%line
185 start_col = editor%cursors(editor%active_cursor)%column
186
187 call find_next_match(buffer, current_search_pattern, &
188 start_line, start_col, &
189 found, found_line, found_col)
190
191 if (found) then
192 ! Move cursor to match and select it
193 editor%cursors(editor%active_cursor)%line = found_line
194 editor%cursors(editor%active_cursor)%column = found_col
195 editor%cursors(editor%active_cursor)%desired_column = found_col
196
197 ! Create selection for the match
198 editor%cursors(editor%active_cursor)%has_selection = .true.
199 editor%cursors(editor%active_cursor)%selection_start_line = found_line
200 editor%cursors(editor%active_cursor)%selection_start_col = found_col
201 editor%cursors(editor%active_cursor)%column = found_col + len(current_search_pattern)
202
203 ! Update last search position
204 last_search_line = found_line
205 last_search_col = found_col
206
207 ! Update viewport
208 call center_viewport_on_cursor(editor)
209 end if
210 end subroutine search_forward
211
212 subroutine search_backward(editor, buffer)
213 type(editor_state_t), intent(inout) :: editor
214 type(buffer_t), intent(inout) :: buffer
215 logical :: found
216 integer :: found_line, found_col
217 integer :: start_line, start_col
218
219 if (.not. allocated(current_search_pattern)) return
220
221 ! Re-count matches in case search options changed
222 call count_all_matches(buffer, current_search_pattern)
223
224 ! Search backward from cursor position
225 start_line = editor%cursors(editor%active_cursor)%line
226 start_col = max(1, editor%cursors(editor%active_cursor)%column - 1)
227
228 call find_prev_match(buffer, current_search_pattern, &
229 start_line, start_col, &
230 found, found_line, found_col)
231
232 if (found) then
233 ! Move cursor to match and select it
234 editor%cursors(editor%active_cursor)%line = found_line
235 editor%cursors(editor%active_cursor)%column = found_col
236 editor%cursors(editor%active_cursor)%desired_column = found_col
237
238 ! Create selection for the match
239 editor%cursors(editor%active_cursor)%has_selection = .true.
240 editor%cursors(editor%active_cursor)%selection_start_line = found_line
241 editor%cursors(editor%active_cursor)%selection_start_col = found_col
242 editor%cursors(editor%active_cursor)%column = found_col + len(current_search_pattern)
243
244 ! Update last search position
245 last_search_line = found_line
246 last_search_col = found_col
247
248 ! Update viewport
249 call center_viewport_on_cursor(editor)
250 end if
251 end subroutine search_backward
252
253 subroutine find_next_match(buffer, pattern, start_line, start_col, &
254 found, found_line, found_col)
255 type(buffer_t), intent(in) :: buffer
256 character(len=*), intent(in) :: pattern
257 integer, intent(in) :: start_line, start_col
258 logical, intent(out) :: found
259 integer, intent(out) :: found_line, found_col
260 character(len=:), allocatable :: line
261 integer :: line_count, current_line, pos
262 integer :: search_col
263
264 found = .false.
265 found_line = 0
266 found_col = 0
267 line_count = buffer_get_line_count(buffer)
268
269 ! Search from current position to end
270 do current_line = start_line, line_count
271 line = buffer_get_line(buffer, current_line)
272
273 if (current_line == start_line) then
274 search_col = start_col + 1
275 else
276 search_col = 1
277 end if
278
279 if (search_col <= len(line)) then
280 call find_pattern_in_line(line(search_col:), pattern, pos)
281 if (pos > 0) then
282 found_col = search_col + pos - 1
283
284 ! Check whole word constraint if enabled
285 if (whole_word) then
286 if (.not. is_whole_word_match(line, found_col, len(pattern))) then
287 ! Not a whole word match, continue searching
288 search_col = found_col + 1
289 if (search_col <= len(line)) then
290 cycle
291 end if
292 end if
293 end if
294
295 found = .true.
296 found_line = current_line
297 if (allocated(line)) deallocate(line)
298 ! Update match index
299 call update_match_index(buffer, pattern, found_line, found_col)
300 return
301 end if
302 end if
303 if (allocated(line)) deallocate(line)
304 end do
305
306 ! Wrap around to beginning
307 do current_line = 1, start_line
308 line = buffer_get_line(buffer, current_line)
309
310 if (current_line == start_line) then
311 ! Search only up to start position
312 if (start_col > 1) then
313 call find_pattern_in_line(line(1:start_col-1), pattern, pos)
314 else
315 pos = 0
316 end if
317 else
318 call find_pattern_in_line(line, pattern, pos)
319 end if
320
321 if (pos > 0) then
322 ! Check whole word constraint if enabled
323 if (whole_word) then
324 if (.not. is_whole_word_match(line, pos, len(pattern))) then
325 ! Not a whole word match, skip
326 if (allocated(line)) deallocate(line)
327 cycle
328 end if
329 end if
330
331 found = .true.
332 found_line = current_line
333 found_col = pos
334 if (allocated(line)) deallocate(line)
335 ! Update match index
336 call update_match_index(buffer, pattern, found_line, found_col)
337 return
338 end if
339
340 if (allocated(line)) deallocate(line)
341 end do
342 end subroutine find_next_match
343
344 subroutine find_prev_match(buffer, pattern, start_line, start_col, &
345 found, found_line, found_col)
346 type(buffer_t), intent(in) :: buffer
347 character(len=*), intent(in) :: pattern
348 integer, intent(in) :: start_line, start_col
349 logical, intent(out) :: found
350 integer, intent(out) :: found_line, found_col
351 character(len=:), allocatable :: line
352 integer :: line_count, current_line
353 integer :: pos, last_pos, check_col
354
355 found = .false.
356 found_line = 0
357 found_col = 0
358 line_count = buffer_get_line_count(buffer)
359
360 ! Search backward from current position
361 do current_line = start_line, 1, -1
362 line = buffer_get_line(buffer, current_line)
363
364 if (current_line == start_line) then
365 check_col = min(start_col, len(line))
366 else
367 check_col = len(line)
368 end if
369
370 ! Find last occurrence before check_col
371 last_pos = 0
372 pos = 1
373 do while (pos <= check_col - len(pattern) + 1)
374 call find_pattern_in_line(line(pos:), pattern, found_col)
375 if (found_col > 0 .and. pos + found_col - 1 <= check_col) then
376 ! Check whole word constraint if enabled
377 if (whole_word) then
378 if (is_whole_word_match(line, pos + found_col - 1, len(pattern))) then
379 last_pos = pos + found_col - 1
380 end if
381 else
382 last_pos = pos + found_col - 1
383 end if
384 pos = pos + found_col ! Skip past this match
385 else
386 exit
387 end if
388 end do
389
390 if (last_pos > 0) then
391 found = .true.
392 found_line = current_line
393 found_col = last_pos
394 if (allocated(line)) deallocate(line)
395 ! Update match index
396 call update_match_index(buffer, pattern, found_line, found_col)
397 return
398 end if
399
400 if (allocated(line)) deallocate(line)
401 end do
402
403 ! Wrap around from end
404 do current_line = line_count, start_line, -1
405 if (current_line <= start_line) exit
406
407 line = buffer_get_line(buffer, current_line)
408
409 ! Find last occurrence in line
410 last_pos = 0
411 pos = 1
412 do while (pos <= len(line) - len(pattern) + 1)
413 call find_pattern_in_line(line(pos:), pattern, found_col)
414 if (found_col > 0) then
415 ! Check whole word constraint if enabled
416 if (whole_word) then
417 if (is_whole_word_match(line, pos + found_col - 1, len(pattern))) then
418 last_pos = pos + found_col - 1
419 end if
420 else
421 last_pos = pos + found_col - 1
422 end if
423 pos = pos + found_col ! Skip past this match
424 else
425 exit
426 end if
427 end do
428
429 if (last_pos > 0) then
430 found = .true.
431 found_line = current_line
432 found_col = last_pos
433 if (allocated(line)) deallocate(line)
434 ! Update match index
435 call update_match_index(buffer, pattern, found_line, found_col)
436 return
437 end if
438
439 if (allocated(line)) deallocate(line)
440 end do
441 end subroutine find_prev_match
442
443 subroutine center_viewport_on_cursor(editor)
444 type(editor_state_t), intent(inout) :: editor
445 integer :: cursor_line
446 integer :: viewport_height
447
448 cursor_line = editor%cursors(editor%active_cursor)%line
449 viewport_height = editor%screen_rows - 2 ! Account for status bar
450
451 ! Center the cursor in the viewport
452 editor%viewport_line = max(1, cursor_line - viewport_height / 2)
453 end subroutine center_viewport_on_cursor
454
455 subroutine clear_search_pattern()
456 if (allocated(current_search_pattern)) then
457 deallocate(current_search_pattern)
458 end if
459 last_search_line = 1
460 last_search_col = 1
461 end subroutine clear_search_pattern
462
463 subroutine build_search_prompt(prompt)
464 character(len=*), intent(out) :: prompt
465 character(len=64) :: options
466 character(len=32) :: count_str
467
468 options = ''
469
470 ! Build options string
471 if (case_sensitive) then
472 options = trim(options) // '[Cc]'
473 else
474 options = trim(options) // '[cc]'
475 end if
476
477 if (whole_word) then
478 options = trim(options) // '[Ww]'
479 else
480 options = trim(options) // '[ww]'
481 end if
482
483 ! Add match count if we have a pattern and matches
484 if (allocated(current_search_pattern) .and. total_matches > 0) then
485 write(count_str, '(I0,A,I0)') current_match_index, ' of ', total_matches
486 options = trim(options) // ' (' // trim(count_str) // ')'
487 end if
488
489 ! Build full prompt with ESC indicator
490 if (len_trim(options) > 0) then
491 prompt = 'Search ' // trim(options) // ' | ESC:exit: '
492 else
493 prompt = 'Search | ESC:exit: '
494 end if
495 end subroutine build_search_prompt
496
497 subroutine find_pattern_in_line(line, pattern, pos)
498 character(len=*), intent(in) :: line
499 character(len=*), intent(in) :: pattern
500 integer, intent(out) :: pos
501 character(len=:), allocatable :: search_line
502 character(len=:), allocatable :: search_pattern
503 integer :: i
504
505 pos = 0
506
507 if (len(line) == 0 .or. len(pattern) == 0) return
508 if (len(pattern) > len(line)) return
509
510 ! Handle case sensitivity
511 if (case_sensitive) then
512 ! Direct search
513 pos = index(line, pattern)
514 else
515 ! Case-insensitive search - convert both to lowercase
516 allocate(character(len=len(line)) :: search_line)
517 allocate(character(len=len(pattern)) :: search_pattern)
518
519 ! Convert line to lowercase
520 do i = 1, len(line)
521 if (iachar(line(i:i)) >= iachar('A') .and. &
522 iachar(line(i:i)) <= iachar('Z')) then
523 search_line(i:i) = char(iachar(line(i:i)) + 32)
524 else
525 search_line(i:i) = line(i:i)
526 end if
527 end do
528
529 ! Convert pattern to lowercase
530 do i = 1, len(pattern)
531 if (iachar(pattern(i:i)) >= iachar('A') .and. &
532 iachar(pattern(i:i)) <= iachar('Z')) then
533 search_pattern(i:i) = char(iachar(pattern(i:i)) + 32)
534 else
535 search_pattern(i:i) = pattern(i:i)
536 end if
537 end do
538
539 pos = index(search_line, search_pattern)
540
541 deallocate(search_line)
542 deallocate(search_pattern)
543 end if
544 end subroutine find_pattern_in_line
545
546 logical function is_whole_word_match(line, start_pos, pattern_len)
547 character(len=*), intent(in) :: line
548 integer, intent(in) :: start_pos, pattern_len
549 logical :: word_start, word_end
550 integer :: end_pos
551
552 end_pos = start_pos + pattern_len - 1
553
554 ! Check if match is at word boundary (start)
555 if (start_pos == 1) then
556 word_start = .true.
557 else
558 word_start = .not. is_word_char(line(start_pos-1:start_pos-1))
559 end if
560
561 ! Check if match is at word boundary (end)
562 if (end_pos == len(line)) then
563 word_end = .true.
564 else
565 word_end = .not. is_word_char(line(end_pos+1:end_pos+1))
566 end if
567
568 is_whole_word_match = word_start .and. word_end
569 end function is_whole_word_match
570
571 logical function is_word_char(ch)
572 character(len=1), intent(in) :: ch
573 integer :: ascii_val
574
575 ascii_val = iachar(ch)
576
577 ! Check if character is alphanumeric or underscore
578 is_word_char = (ascii_val >= iachar('A') .and. ascii_val <= iachar('Z')) .or. &
579 (ascii_val >= iachar('a') .and. ascii_val <= iachar('z')) .or. &
580 (ascii_val >= iachar('0') .and. ascii_val <= iachar('9')) .or. &
581 (ch == '_')
582 end function is_word_char
583
584 subroutine count_all_matches(buffer, pattern)
585 type(buffer_t), intent(in) :: buffer
586 character(len=*), intent(in) :: pattern
587 character(len=:), allocatable :: line
588 integer :: line_count, current_line, pos, col
589 integer :: match_count
590
591 match_count = 0
592 line_count = buffer_get_line_count(buffer)
593
594 ! Go through all lines and count matches
595 do current_line = 1, line_count
596 line = buffer_get_line(buffer, current_line)
597 col = 1
598
599 do while (col <= len(line))
600 call find_pattern_in_line(line(col:), pattern, pos)
601 if (pos > 0) then
602 pos = col + pos - 1
603 ! Check whole word constraint if enabled
604 if (whole_word) then
605 if (is_whole_word_match(line, pos, len(pattern))) then
606 match_count = match_count + 1
607 end if
608 else
609 match_count = match_count + 1
610 end if
611 col = pos + 1
612 else
613 exit
614 end if
615 end do
616
617 if (allocated(line)) deallocate(line)
618 end do
619
620 total_matches = match_count
621 current_match_index = 0 ! Will be set when we find first match
622 end subroutine count_all_matches
623
624 subroutine perform_incremental_search(editor, buffer, pattern)
625 type(editor_state_t), intent(inout) :: editor
626 type(buffer_t), intent(in) :: buffer
627 character(len=*), intent(in) :: pattern
628 logical :: found
629 integer :: found_line, found_col
630 integer :: start_line, start_col
631
632 ! Search from current cursor position
633 start_line = editor%cursors(editor%active_cursor)%line
634 start_col = editor%cursors(editor%active_cursor)%column
635
636 ! Find the first match
637 call find_next_match(buffer, pattern, start_line, start_col, &
638 found, found_line, found_col)
639
640 if (found) then
641 ! Update viewport to show the match (but don't move cursor yet)
642 editor%viewport_line = max(1, found_line - editor%screen_rows / 2)
643
644 ! Store the match position for highlighting (could be used for visual feedback)
645 last_search_line = found_line
646 last_search_col = found_col
647 end if
648 end subroutine perform_incremental_search
649
650 subroutine update_match_index(buffer, pattern, match_line, match_col)
651 type(buffer_t), intent(in) :: buffer
652 character(len=*), intent(in) :: pattern
653 integer, intent(in) :: match_line, match_col
654 character(len=:), allocatable :: line
655 integer :: line_count, current_line, pos, col
656 integer :: match_count
657
658 match_count = 0
659 line_count = buffer_get_line_count(buffer)
660
661 ! Count matches until we reach the current one
662 do current_line = 1, line_count
663 line = buffer_get_line(buffer, current_line)
664 col = 1
665
666 do while (col <= len(line))
667 call find_pattern_in_line(line(col:), pattern, pos)
668 if (pos > 0) then
669 pos = col + pos - 1
670 ! Check whole word constraint if enabled
671 if (whole_word) then
672 if (is_whole_word_match(line, pos, len(pattern))) then
673 match_count = match_count + 1
674 if (current_line == match_line .and. pos == match_col) then
675 current_match_index = match_count
676 if (allocated(line)) deallocate(line)
677 return
678 end if
679 end if
680 else
681 match_count = match_count + 1
682 if (current_line == match_line .and. pos == match_col) then
683 current_match_index = match_count
684 if (allocated(line)) deallocate(line)
685 return
686 end if
687 end if
688 col = pos + 1
689 else
690 exit
691 end if
692 end do
693
694 if (allocated(line)) deallocate(line)
695 end do
696 end subroutine update_match_index
697
698 end module search_prompt_module