Fortran · 17512 bytes Raw Blame History
1 module command_palette_module
2 use iso_fortran_env, only: int32
3 use terminal_io_module
4 implicit none
5 private
6
7 public :: command_palette_t, command_t
8 public :: init_command_palette, cleanup_command_palette
9 public :: show_command_palette, hide_command_palette, show_command_palette_interactive
10 public :: is_command_palette_visible, command_palette_handle_key
11 public :: register_command, get_selected_command
12 public :: filter_commands, render_command_palette
13
14 integer, parameter :: MAX_COMMANDS = 100
15 integer, parameter :: MAX_VISIBLE = 10
16 integer, parameter :: PALETTE_WIDTH = 60 ! Fixed width for centered palette
17
18 type :: command_t
19 character(len=:), allocatable :: name
20 character(len=:), allocatable :: shortcut
21 character(len=:), allocatable :: category
22 character(len=:), allocatable :: command_id
23 integer :: score = 0 ! For fuzzy matching
24 end type command_t
25
26 type :: command_palette_t
27 logical :: visible = .false.
28 type(command_t), allocatable :: all_commands(:)
29 integer :: num_commands = 0
30 type(command_t), allocatable :: filtered_commands(:)
31 integer :: num_filtered = 0
32 integer :: selected_index = 1
33 integer :: scroll_offset = 0
34 character(len=256) :: search_query = ''
35 integer :: search_pos = 0
36 end type command_palette_t
37
38 ! Module-level command registry
39 type(command_t), allocatable :: command_registry(:)
40 integer :: registry_size = 0
41
42 contains
43
44 subroutine init_command_palette(palette)
45 type(command_palette_t), intent(out) :: palette
46
47 palette%visible = .false.
48 palette%num_commands = 0
49 palette%num_filtered = 0
50 palette%selected_index = 1
51 palette%scroll_offset = 0
52 palette%search_query = ''
53 palette%search_pos = 0
54
55 allocate(palette%all_commands(MAX_COMMANDS))
56 allocate(palette%filtered_commands(MAX_COMMANDS))
57 end subroutine init_command_palette
58
59 subroutine cleanup_command_palette(palette)
60 type(command_palette_t), intent(inout) :: palette
61 integer :: i
62
63 if (allocated(palette%all_commands)) then
64 do i = 1, palette%num_commands
65 if (allocated(palette%all_commands(i)%name)) deallocate(palette%all_commands(i)%name)
66 if (allocated(palette%all_commands(i)%shortcut)) deallocate(palette%all_commands(i)%shortcut)
67 if (allocated(palette%all_commands(i)%category)) deallocate(palette%all_commands(i)%category)
68 if (allocated(palette%all_commands(i)%command_id)) deallocate(palette%all_commands(i)%command_id)
69 end do
70 deallocate(palette%all_commands)
71 end if
72
73 if (allocated(palette%filtered_commands)) deallocate(palette%filtered_commands)
74 end subroutine cleanup_command_palette
75
76 subroutine register_command(name, command_id, shortcut, category)
77 character(len=*), intent(in) :: name, command_id
78 character(len=*), intent(in), optional :: shortcut, category
79 type(command_t), allocatable :: temp(:)
80 integer :: i
81
82 ! Grow registry if needed
83 if (.not. allocated(command_registry)) then
84 allocate(command_registry(20))
85 registry_size = 0
86 else if (registry_size >= size(command_registry)) then
87 allocate(temp(size(command_registry) * 2))
88 do i = 1, registry_size
89 temp(i) = command_registry(i)
90 end do
91 deallocate(command_registry)
92 command_registry = temp
93 end if
94
95 ! Add command
96 registry_size = registry_size + 1
97 command_registry(registry_size)%name = name
98 command_registry(registry_size)%command_id = command_id
99
100 if (present(shortcut)) then
101 command_registry(registry_size)%shortcut = shortcut
102 else
103 command_registry(registry_size)%shortcut = ''
104 end if
105
106 if (present(category)) then
107 command_registry(registry_size)%category = category
108 else
109 command_registry(registry_size)%category = ''
110 end if
111 end subroutine register_command
112
113 subroutine show_command_palette(palette)
114 type(command_palette_t), intent(inout) :: palette
115 integer :: i
116
117 palette%visible = .true.
118 palette%search_query = ''
119 palette%search_pos = 0
120 palette%selected_index = 1
121 palette%scroll_offset = 0
122
123 ! Load all registered commands
124 palette%num_commands = registry_size
125 do i = 1, registry_size
126 palette%all_commands(i) = command_registry(i)
127 end do
128
129 ! Initially show all commands
130 call filter_commands(palette, '')
131 end subroutine show_command_palette
132
133 subroutine hide_command_palette(palette)
134 type(command_palette_t), intent(inout) :: palette
135 palette%visible = .false.
136 end subroutine hide_command_palette
137
138 function is_command_palette_visible(palette) result(visible)
139 type(command_palette_t), intent(in) :: palette
140 logical :: visible
141 visible = palette%visible
142 end function is_command_palette_visible
143
144 subroutine filter_commands(palette, query)
145 type(command_palette_t), intent(inout) :: palette
146 character(len=*), intent(in) :: query
147 integer :: i, score
148
149 palette%num_filtered = 0
150
151 do i = 1, palette%num_commands
152 score = fuzzy_match_score(palette%all_commands(i)%name, query)
153 if (score > 0) then
154 palette%num_filtered = palette%num_filtered + 1
155 palette%filtered_commands(palette%num_filtered) = palette%all_commands(i)
156 palette%filtered_commands(palette%num_filtered)%score = score
157 end if
158 end do
159
160 ! Sort by score (simple bubble sort for now)
161 call sort_commands_by_score(palette%filtered_commands, palette%num_filtered)
162
163 ! Reset selection
164 palette%selected_index = 1
165 palette%scroll_offset = 0
166 end subroutine filter_commands
167
168 function fuzzy_match_score(text, pattern) result(score)
169 character(len=*), intent(in) :: text, pattern
170 integer :: score
171 integer :: i, j, text_len, pattern_len
172 integer :: consecutive_matches
173 character :: text_lower, pattern_lower
174
175 score = 0
176 text_len = len_trim(text)
177 pattern_len = len_trim(pattern)
178
179 ! Empty pattern matches everything
180 if (pattern_len == 0) then
181 score = 100
182 return
183 end if
184
185 j = 1
186 consecutive_matches = 0
187
188 do i = 1, text_len
189 if (j > pattern_len) exit
190
191 ! Case-insensitive comparison
192 text_lower = to_lower(text(i:i))
193 pattern_lower = to_lower(pattern(j:j))
194
195 if (text_lower == pattern_lower) then
196 score = score + 10
197 consecutive_matches = consecutive_matches + 1
198
199 ! Bonus for consecutive matches
200 if (consecutive_matches > 1) then
201 score = score + 5
202 end if
203
204 ! Bonus for matching at word start
205 if (i == 1 .or. text(i-1:i-1) == ' ') then
206 score = score + 15
207 end if
208
209 j = j + 1
210 else
211 consecutive_matches = 0
212 end if
213 end do
214
215 ! Only match if all pattern characters were found
216 if (j <= pattern_len) then
217 score = 0
218 end if
219 end function fuzzy_match_score
220
221 function to_lower(ch) result(lower)
222 character, intent(in) :: ch
223 character :: lower
224 integer :: code
225
226 code = iachar(ch)
227 if (code >= iachar('A') .and. code <= iachar('Z')) then
228 lower = achar(code + 32)
229 else
230 lower = ch
231 end if
232 end function to_lower
233
234 subroutine sort_commands_by_score(commands, count)
235 type(command_t), intent(inout) :: commands(:)
236 integer, intent(in) :: count
237 type(command_t) :: temp
238 integer :: i, j
239
240 ! Bubble sort (good enough for small lists)
241 do i = 1, count - 1
242 do j = i + 1, count
243 if (commands(j)%score > commands(i)%score) then
244 temp = commands(i)
245 commands(i) = commands(j)
246 commands(j) = temp
247 end if
248 end do
249 end do
250 end subroutine sort_commands_by_score
251
252 function get_selected_command(palette) result(cmd)
253 type(command_palette_t), intent(in) :: palette
254 type(command_t) :: cmd
255
256 if (palette%num_filtered > 0 .and. palette%selected_index <= palette%num_filtered) then
257 cmd = palette%filtered_commands(palette%selected_index)
258 else
259 cmd%command_id = ''
260 end if
261 end function get_selected_command
262
263 subroutine command_palette_handle_key(palette, key, handled)
264 type(command_palette_t), intent(inout) :: palette
265 character(len=*), intent(in) :: key
266 logical, intent(out) :: handled
267
268 handled = .true.
269
270 select case(key)
271 case('up', 'ctrl-k', 'k')
272 if (palette%selected_index > 1) then
273 palette%selected_index = palette%selected_index - 1
274 if (palette%selected_index < palette%scroll_offset + 1) then
275 palette%scroll_offset = palette%selected_index - 1
276 end if
277 end if
278
279 case('down', 'ctrl-j', 'j')
280 if (palette%selected_index < palette%num_filtered) then
281 palette%selected_index = palette%selected_index + 1
282 if (palette%selected_index > palette%scroll_offset + MAX_VISIBLE) then
283 palette%scroll_offset = palette%selected_index - MAX_VISIBLE
284 end if
285 end if
286
287 case('esc')
288 call hide_command_palette(palette)
289
290 case default
291 handled = .false.
292 end select
293 end subroutine command_palette_handle_key
294
295 subroutine render_command_palette(palette, screen_cols)
296 type(command_palette_t), intent(in) :: palette
297 integer, intent(in) :: screen_cols
298 integer :: i, visible_start, visible_end, row, start_col, start_row
299 integer :: content_width, display_width
300 character(len=256) :: line, category_tag
301 type(command_t) :: cmd
302 character(len=:), allocatable :: border_top, border_bottom
303 ! ANSI escape codes
304 character(len=*), parameter :: ESC = char(27)
305 character(len=*), parameter :: CYAN = ESC // '[36m'
306 character(len=*), parameter :: YELLOW = ESC // '[33m'
307 character(len=*), parameter :: INVERSE = ESC // '[7m'
308 character(len=*), parameter :: RESET = ESC // '[0m'
309
310 ! Calculate centering - top-center like VSCode
311 content_width = min(PALETTE_WIDTH, screen_cols - 4)
312 start_col = max(1, (screen_cols - content_width) / 2)
313 start_row = 2 ! Start near top (below tab bar if present)
314
315 ! Build border strings
316 border_top = '┌' // repeat('─', content_width - 2) // '┐'
317 border_bottom = '└' // repeat('─', content_width - 2) // '┘'
318
319 ! Draw top border
320 call terminal_move_cursor(start_row, start_col)
321 call terminal_write(border_top)
322
323 ! Draw header line with cyan title
324 row = start_row + 1
325 call terminal_move_cursor(row, start_col)
326 call terminal_write('│')
327 call terminal_write(CYAN // ' Command Palette' // RESET)
328 display_width = 16 ! " Command Palette" visible length
329 call terminal_write(repeat(' ', max(0, content_width - 2 - display_width)))
330 call terminal_write('│')
331
332 ! Draw search query line with yellow prompt
333 row = row + 1
334 call terminal_move_cursor(row, start_col)
335 call terminal_write('│')
336 call terminal_write(YELLOW // ' > ' // RESET)
337 call terminal_write(trim(palette%search_query))
338 display_width = 3 + len_trim(palette%search_query) ! " > " + query length
339 call terminal_write(repeat(' ', max(0, content_width - 2 - display_width)))
340 call terminal_write('│')
341
342 ! Draw separator
343 row = row + 1
344 call terminal_move_cursor(row, start_col)
345 call terminal_write('├' // repeat('─', content_width - 2) // '┤')
346
347 ! Calculate visible range
348 visible_start = palette%scroll_offset + 1
349 visible_end = min(visible_start + MAX_VISIBLE - 1, palette%num_filtered)
350
351 ! Draw commands
352 do i = visible_start, visible_end
353 row = row + 1
354 cmd = palette%filtered_commands(i)
355
356 call terminal_move_cursor(row, start_col)
357 call terminal_write('│')
358
359 ! Build line with category, name, and shortcut (for display_width calculation)
360 if (len_trim(cmd%category) > 0) then
361 write(category_tag, '(A,A,A)') '[', trim(cmd%category), '] '
362 else
363 category_tag = ''
364 end if
365
366 write(line, '(A,A,A)') ' ', trim(category_tag), trim(cmd%name)
367
368 ! Add shortcut if available
369 if (len_trim(cmd%shortcut) > 0) then
370 write(line, '(A,A,A)') trim(line), ' ', trim(cmd%shortcut)
371 end if
372
373 ! Calculate visible width (actual characters, no ANSI codes)
374 display_width = len_trim(line)
375
376 ! Ensure it fits
377 if (display_width > content_width - 2) then
378 display_width = content_width - 2
379 end if
380
381 ! Calculate padding
382 display_width = max(0, min(display_width, content_width - 2))
383
384 if (i == palette%selected_index) then
385 ! Highlight selected item with inverse colors
386 call terminal_write(INVERSE)
387 call terminal_write(line(1:display_width))
388 call terminal_write(repeat(' ', max(0, content_width - 2 - display_width)))
389 call terminal_write(RESET)
390 else
391 call terminal_write(line(1:display_width))
392 call terminal_write(repeat(' ', max(0, content_width - 2 - display_width)))
393 end if
394
395 call terminal_write('│')
396 end do
397
398 ! Fill remaining visible slots with empty rows
399 do i = visible_end + 1, visible_start + MAX_VISIBLE - 1
400 row = row + 1
401 call terminal_move_cursor(row, start_col)
402 call terminal_write('│' // repeat(' ', content_width - 2) // '│')
403 end do
404
405 ! Draw bottom border
406 row = row + 1
407 call terminal_move_cursor(row, start_col)
408 call terminal_write(border_bottom)
409
410 ! Position cursor at end of search query (inside the box)
411 call terminal_move_cursor(start_row + 2, start_col + 4 + palette%search_pos)
412 end subroutine render_command_palette
413
414 function show_command_palette_interactive(palette, screen_cols) result(selected_cmd_id)
415 use input_handler_module, only: get_key_input
416 type(command_palette_t), intent(inout) :: palette
417 integer, intent(in) :: screen_cols
418 character(len=:), allocatable :: selected_cmd_id
419 character(len=32) :: key_input
420 integer :: ch, status
421 logical :: handled
422 type(command_t) :: cmd
423
424 call show_command_palette(palette)
425 call render_command_palette(palette, screen_cols)
426
427 do
428 call get_key_input(key_input, status)
429 if (status /= 0) cycle
430
431 ! Handle special keys
432 if (key_input == 'enter') then
433 cmd = get_selected_command(palette)
434 if (allocated(cmd%command_id) .and. len_trim(cmd%command_id) > 0) then
435 selected_cmd_id = cmd%command_id
436 call hide_command_palette(palette)
437 return
438 end if
439 else if (key_input == 'esc') then
440 selected_cmd_id = ''
441 call hide_command_palette(palette)
442 return
443 else if (key_input == 'backspace') then
444 if (palette%search_pos > 0) then
445 palette%search_query(palette%search_pos:palette%search_pos) = ' '
446 palette%search_pos = palette%search_pos - 1
447 call filter_commands(palette, trim(palette%search_query(1:palette%search_pos)))
448 end if
449 else
450 ! Try navigation keys
451 call command_palette_handle_key(palette, key_input, handled)
452 if (.not. handled .and. len_trim(key_input) == 1) then
453 ! Regular character - add to search
454 ch = iachar(key_input(1:1))
455 if (ch >= 32 .and. ch < 127 .and. palette%search_pos < 255) then
456 palette%search_pos = palette%search_pos + 1
457 palette%search_query(palette%search_pos:palette%search_pos) = key_input(1:1)
458 call filter_commands(palette, trim(palette%search_query(1:palette%search_pos)))
459 end if
460 end if
461 end if
462
463 call render_command_palette(palette, screen_cols)
464 end do
465 end function show_command_palette_interactive
466
467 end module command_palette_module
468