| 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 |