| 1 | ! Fortress Navigator Integration for fac |
| 2 | ! Main API for opening file/directory navigator (Ctrl-O) |
| 3 | |
| 4 | module fortress_navigator_module |
| 5 | use iso_fortran_env, only: output_unit, input_unit |
| 6 | use fortress_fs_module |
| 7 | use fortress_display_module |
| 8 | use terminal_io_module, only: terminal_read_char, terminal_write, terminal_move_cursor |
| 9 | use favorites_module, only: favorites_add |
| 10 | implicit none |
| 11 | private |
| 12 | |
| 13 | public :: open_fortress_navigator |
| 14 | |
| 15 | ! Navigation state |
| 16 | character(len=MAX_PATH), dimension(MAX_FILES) :: current_files, parent_files |
| 17 | logical, dimension(MAX_FILES) :: current_is_dir, parent_is_dir |
| 18 | logical, dimension(MAX_FILES) :: current_is_exec, parent_is_exec |
| 19 | integer :: current_count, parent_count |
| 20 | integer :: selected, parent_selected |
| 21 | integer :: scroll_offset, parent_scroll_offset |
| 22 | |
| 23 | contains |
| 24 | |
| 25 | !> Main entry point: open fortress navigator and return selection |
| 26 | !! @param selected_path - Output: selected file/directory path (empty if cancelled) |
| 27 | !! @param is_directory - Output: true if selected item is directory |
| 28 | !! @param cancelled - Output: true if user pressed ESC/q |
| 29 | !! @param initial_path - Input (optional): starting directory |
| 30 | subroutine open_fortress_navigator(selected_path, is_directory, cancelled, initial_path) |
| 31 | character(len=:), allocatable, intent(out) :: selected_path |
| 32 | logical, intent(out) :: is_directory, cancelled |
| 33 | character(len=*), intent(in), optional :: initial_path |
| 34 | character(len=MAX_PATH) :: current_dir, parent_dir, temp_dir, last_dir, last_parent |
| 35 | character(len=1) :: key |
| 36 | integer :: rows, cols, ios, last_selected, last_scroll |
| 37 | logical :: running, dir_changed, first_draw, need_redraw |
| 38 | |
| 39 | ! Initialize state |
| 40 | selected = 1 |
| 41 | parent_selected = -1 |
| 42 | scroll_offset = 0 |
| 43 | parent_scroll_offset = 0 |
| 44 | running = .true. |
| 45 | cancelled = .false. |
| 46 | last_dir = "" |
| 47 | last_parent = "" |
| 48 | first_draw = .true. |
| 49 | need_redraw = .true. |
| 50 | last_selected = -1 |
| 51 | last_scroll = -1 |
| 52 | |
| 53 | ! Set initial directory |
| 54 | if (present(initial_path)) then |
| 55 | current_dir = initial_path |
| 56 | else |
| 57 | current_dir = get_pwd() |
| 58 | end if |
| 59 | |
| 60 | ! Get terminal size once (assumes terminal doesn't resize during navigation) |
| 61 | call get_term_size(rows, cols) |
| 62 | |
| 63 | ! Main navigation loop |
| 64 | do while (running) |
| 65 | ! Only refresh directory listings if directory changed |
| 66 | dir_changed = (current_dir /= last_dir) |
| 67 | if (dir_changed) then |
| 68 | parent_dir = get_parent_path(current_dir) |
| 69 | |
| 70 | ! Only refresh parent if it changed too |
| 71 | if (parent_dir /= last_parent) then |
| 72 | call get_file_list(parent_dir, parent_files, parent_is_dir, parent_is_exec, parent_count) |
| 73 | last_parent = parent_dir |
| 74 | end if |
| 75 | |
| 76 | call get_file_list(current_dir, current_files, current_is_dir, current_is_exec, current_count) |
| 77 | last_dir = current_dir |
| 78 | else |
| 79 | ! Just update parent_dir for consistency |
| 80 | parent_dir = get_parent_path(current_dir) |
| 81 | end if |
| 82 | |
| 83 | ! Find current directory in parent listing |
| 84 | parent_selected = find_in_parent(current_dir, parent_files, parent_count) |
| 85 | |
| 86 | ! Bounds check |
| 87 | if (selected < 1) selected = 1 |
| 88 | if (selected > current_count) selected = current_count |
| 89 | if (current_count == 0) selected = 1 |
| 90 | |
| 91 | ! Adjust scroll offsets |
| 92 | call adjust_scroll(selected, scroll_offset, rows - 4) |
| 93 | call adjust_scroll(parent_selected, parent_scroll_offset, rows - 4) |
| 94 | |
| 95 | ! Check if we need to redraw (directory changed, selection changed, or scroll changed) |
| 96 | need_redraw = dir_changed .or. first_draw .or. & |
| 97 | selected /= last_selected .or. scroll_offset /= last_scroll |
| 98 | |
| 99 | ! Render interface only if something changed |
| 100 | if (need_redraw) then |
| 101 | call draw_fortress_interface(rows, cols, current_dir, & |
| 102 | current_files, current_is_dir, current_is_exec, current_count, & |
| 103 | parent_files, parent_is_dir, parent_count, & |
| 104 | selected, parent_selected, scroll_offset, parent_scroll_offset, first_draw) |
| 105 | |
| 106 | ! Update tracking variables |
| 107 | last_selected = selected |
| 108 | last_scroll = scroll_offset |
| 109 | if (first_draw) first_draw = .false. |
| 110 | end if |
| 111 | |
| 112 | ! Read key using raw terminal input |
| 113 | ! Note: terminal_read_char is non-blocking, returns -1 if no input |
| 114 | ios = terminal_read_char() |
| 115 | if (ios < 0) then |
| 116 | ! No input available - With conditional redraw optimization above, |
| 117 | ! we won't redraw unnecessarily, so this tight loop is acceptable |
| 118 | cycle |
| 119 | end if |
| 120 | key = achar(ios) |
| 121 | |
| 122 | ! Handle input |
| 123 | select case (key) |
| 124 | case (char(27)) ! ESC |
| 125 | ! Check if arrow key or standalone ESC |
| 126 | if (check_arrow_key(key)) then |
| 127 | call handle_arrow_key(key, selected, current_dir, temp_dir, current_files, & |
| 128 | current_is_dir, current_count) |
| 129 | else |
| 130 | ! Standalone ESC - quit |
| 131 | cancelled = .true. |
| 132 | running = .false. |
| 133 | end if |
| 134 | |
| 135 | case ('q', 'Q') ! Quit |
| 136 | cancelled = .true. |
| 137 | running = .false. |
| 138 | |
| 139 | case (char(10), char(13)) ! Enter - select current item (file or directory) |
| 140 | if (current_count > 0) then |
| 141 | if (current_is_dir(selected)) then |
| 142 | ! Select directory and exit |
| 143 | selected_path = join_path(current_dir, trim(current_files(selected))) |
| 144 | is_directory = .true. |
| 145 | running = .false. |
| 146 | else |
| 147 | ! Select file and exit |
| 148 | selected_path = join_path(current_dir, trim(current_files(selected))) |
| 149 | is_directory = .false. |
| 150 | running = .false. |
| 151 | end if |
| 152 | end if |
| 153 | |
| 154 | case ('~') ! Jump to home |
| 155 | call get_environment_variable("HOME", current_dir) |
| 156 | selected = 1 |
| 157 | scroll_offset = 0 |
| 158 | |
| 159 | case ('/') ! Jump to root |
| 160 | current_dir = "/" |
| 161 | selected = 1 |
| 162 | scroll_offset = 0 |
| 163 | |
| 164 | case ('f', 'F') ! Add current directory to favorites |
| 165 | call add_to_favorites(current_dir, rows) |
| 166 | |
| 167 | end select |
| 168 | end do |
| 169 | |
| 170 | ! Set outputs based on result |
| 171 | if (.not. cancelled) then |
| 172 | ! Check if selected_path was already set (file selection in loop) |
| 173 | if (.not. allocated(selected_path)) then |
| 174 | ! Directory selection or other exit - set to current directory |
| 175 | selected_path = trim(current_dir) |
| 176 | end if |
| 177 | ! Determine if the selected path is a directory |
| 178 | if (allocated(selected_path)) then |
| 179 | if (selected_path == trim(current_dir)) then |
| 180 | is_directory = .true. |
| 181 | else |
| 182 | is_directory = .false. ! Already set in loop for files |
| 183 | end if |
| 184 | end if |
| 185 | else |
| 186 | ! Cancelled - set empty path |
| 187 | selected_path = "" |
| 188 | is_directory = .false. |
| 189 | end if |
| 190 | |
| 191 | end subroutine open_fortress_navigator |
| 192 | |
| 193 | !> Adjust scroll offset to keep selection visible with margin |
| 194 | subroutine adjust_scroll(sel, offset, visible_height) |
| 195 | integer, intent(in) :: sel, visible_height |
| 196 | integer, intent(inout) :: offset |
| 197 | integer :: margin |
| 198 | |
| 199 | ! Add a margin to avoid selection being at the very edge |
| 200 | margin = 3 |
| 201 | if (margin > visible_height / 4) margin = visible_height / 4 |
| 202 | |
| 203 | ! If selection is above the visible window (with margin) |
| 204 | if (sel < offset + 1 + margin) then |
| 205 | offset = sel - margin - 1 |
| 206 | if (offset < 0) offset = 0 |
| 207 | ! If selection is below the visible window (with margin) |
| 208 | else if (sel > offset + visible_height - margin) then |
| 209 | offset = sel - visible_height + margin |
| 210 | end if |
| 211 | |
| 212 | ! Ensure offset is not negative |
| 213 | if (offset < 0) offset = 0 |
| 214 | end subroutine adjust_scroll |
| 215 | |
| 216 | !> Check if ESC is start of arrow key sequence |
| 217 | function check_arrow_key(key) result(is_arrow) |
| 218 | character(len=1), intent(inout) :: key |
| 219 | logical :: is_arrow |
| 220 | integer :: char_code |
| 221 | |
| 222 | is_arrow = .false. |
| 223 | |
| 224 | if (key == char(27)) then |
| 225 | ! Try to read next character |
| 226 | char_code = terminal_read_char() |
| 227 | if (char_code >= 0) then |
| 228 | if (achar(char_code) == '[') then |
| 229 | ! It's an arrow key sequence - read the direction |
| 230 | char_code = terminal_read_char() |
| 231 | if (char_code >= 0) then |
| 232 | key = achar(char_code) |
| 233 | is_arrow = .true. |
| 234 | end if |
| 235 | end if |
| 236 | end if |
| 237 | end if |
| 238 | end function check_arrow_key |
| 239 | |
| 240 | !> Handle arrow key navigation |
| 241 | subroutine handle_arrow_key(key, sel, curr_dir, temp_dir, files, is_dir, file_count) |
| 242 | character(len=1), intent(in) :: key |
| 243 | integer, intent(inout) :: sel |
| 244 | character(len=MAX_PATH), intent(inout) :: curr_dir, temp_dir |
| 245 | character(len=*), dimension(*), intent(in) :: files |
| 246 | logical, dimension(*), intent(in) :: is_dir |
| 247 | integer, intent(in) :: file_count |
| 248 | |
| 249 | select case (key) |
| 250 | case ('A') ! Up arrow |
| 251 | if (sel > 1) sel = sel - 1 |
| 252 | |
| 253 | case ('B') ! Down arrow |
| 254 | if (sel < file_count) sel = sel + 1 |
| 255 | |
| 256 | case ('C') ! Right arrow - enter directory |
| 257 | if (file_count > 0 .and. is_dir(sel)) then |
| 258 | temp_dir = curr_dir |
| 259 | curr_dir = join_path(curr_dir, trim(files(sel))) |
| 260 | sel = 1 |
| 261 | end if |
| 262 | |
| 263 | case ('D') ! Left arrow - go to parent |
| 264 | temp_dir = curr_dir |
| 265 | curr_dir = get_parent_path(curr_dir) |
| 266 | sel = find_in_parent(temp_dir, files, file_count) |
| 267 | end select |
| 268 | end subroutine handle_arrow_key |
| 269 | |
| 270 | !> Get terminal size using fac's terminal module |
| 271 | subroutine get_term_size(rows, cols) |
| 272 | use terminal_io_module, only: terminal_get_size |
| 273 | integer, intent(out) :: rows, cols |
| 274 | |
| 275 | call terminal_get_size(rows, cols) |
| 276 | |
| 277 | ! Sanity check |
| 278 | if (rows <= 0) rows = 24 |
| 279 | if (cols <= 0) cols = 80 |
| 280 | end subroutine get_term_size |
| 281 | |
| 282 | !> Add current directory to favorites |
| 283 | subroutine add_to_favorites(dir_path, rows) |
| 284 | character(len=*), intent(in) :: dir_path |
| 285 | integer, intent(in) :: rows |
| 286 | character(len=256) :: label |
| 287 | logical :: success |
| 288 | integer :: i |
| 289 | |
| 290 | ! Extract basename for label |
| 291 | label = dir_path |
| 292 | do i = len_trim(dir_path), 1, -1 |
| 293 | if (dir_path(i:i) == '/') then |
| 294 | label = dir_path(i+1:) |
| 295 | exit |
| 296 | end if |
| 297 | end do |
| 298 | |
| 299 | ! Add to favorites |
| 300 | call favorites_add(dir_path, trim(label), success) |
| 301 | |
| 302 | ! Show feedback message |
| 303 | call terminal_move_cursor(rows, 1) |
| 304 | if (success) then |
| 305 | call terminal_write('Added to favorites: ' // trim(label)) |
| 306 | else |
| 307 | call terminal_write('Already in favorites or error') |
| 308 | end if |
| 309 | |
| 310 | ! Pause briefly so user can see message |
| 311 | call sleep_ms(800) |
| 312 | end subroutine add_to_favorites |
| 313 | |
| 314 | !> Sleep for specified milliseconds |
| 315 | subroutine sleep_ms(milliseconds) |
| 316 | integer, intent(in) :: milliseconds |
| 317 | integer :: i, j, dummy |
| 318 | |
| 319 | ! Simple busy-wait (not ideal but portable) |
| 320 | dummy = 0 |
| 321 | do i = 1, milliseconds * 1000 |
| 322 | do j = 1, 100 |
| 323 | dummy = dummy + 1 |
| 324 | end do |
| 325 | end do |
| 326 | end subroutine sleep_ms |
| 327 | |
| 328 | end module fortress_navigator_module |
| 329 |