| 1 | module ui_display |
| 2 | use iso_fortran_env, only: output_unit |
| 3 | use terminal_control, only: DIM, BOLD, RESET, UNDERLINE, & |
| 4 | BLUE, GREEN, RED, GREY, WHITE, YELLOW, CYAN, REVERSE |
| 5 | use git_ops, only: write_git_indicators |
| 6 | use filesystem_ops, only: MAX_PATH, MAX_FILES |
| 7 | implicit none |
| 8 | private |
| 9 | |
| 10 | public :: draw_interface, get_file_color |
| 11 | |
| 12 | contains |
| 13 | |
| 14 | subroutine draw_interface(r, c, top_padding, current_dir, current_files, current_is_dir, current_is_exec, & |
| 15 | current_is_staged, current_is_unstaged, current_is_untracked, current_has_incoming, & |
| 16 | current_count, parent_files, parent_is_dir, parent_is_exec, parent_count, & |
| 17 | selected, parent_selected, scroll_offset, parent_scroll_offset, & |
| 18 | in_git_repo, repo_name, branch_name, mode, & |
| 19 | move_mode, move_source_name, move_dest_selected, & |
| 20 | has_clipboard, clipboard_is_cut, clipboard_source_name, clipboard_count, & |
| 21 | is_selected, selection_count, & |
| 22 | current_is_favorite, parent_is_favorite, & |
| 23 | search_buffer, search_length, & |
| 24 | in_rename_mode, rename_buffer, rename_cursor_pos) |
| 25 | integer, intent(in) :: r, c, top_padding, current_count, parent_count, selected, parent_selected |
| 26 | integer, intent(in) :: scroll_offset, parent_scroll_offset |
| 27 | character(len=*), intent(in) :: current_dir, repo_name, branch_name, mode |
| 28 | character(len=*), dimension(*), intent(in) :: current_files, parent_files |
| 29 | logical, dimension(*), intent(in) :: current_is_dir, parent_is_dir |
| 30 | logical, dimension(*), intent(in) :: current_is_exec, parent_is_exec |
| 31 | logical, dimension(*), intent(in) :: current_is_staged, current_is_unstaged, current_is_untracked |
| 32 | logical, dimension(*), intent(in) :: current_has_incoming |
| 33 | logical, intent(in) :: in_git_repo, move_mode |
| 34 | character(len=*), intent(in) :: move_source_name |
| 35 | integer, intent(in) :: move_dest_selected |
| 36 | logical, intent(in) :: has_clipboard, clipboard_is_cut |
| 37 | character(len=*), intent(in) :: clipboard_source_name |
| 38 | integer, intent(in) :: clipboard_count |
| 39 | logical, dimension(*), intent(in) :: is_selected |
| 40 | integer, intent(in) :: selection_count |
| 41 | logical, dimension(*), intent(in) :: current_is_favorite, parent_is_favorite |
| 42 | character(len=*), intent(in) :: search_buffer |
| 43 | integer, intent(in) :: search_length |
| 44 | logical, intent(in) :: in_rename_mode |
| 45 | character(len=*), intent(in) :: rename_buffer |
| 46 | integer, intent(in) :: rename_cursor_pos |
| 47 | integer :: left_w, i, parent_idx, current_idx, vis_h, display_len |
| 48 | character(len=256) :: fname |
| 49 | character(len=20) :: color_code |
| 50 | character(len=600) :: footer_text |
| 51 | integer :: footer_len |
| 52 | |
| 53 | left_w = c * 3 / 10 |
| 54 | vis_h = r - top_padding - 4 ! Visible height with buffer to prevent scrolling |
| 55 | |
| 56 | ! Add blank lines at top as padding for terminals that need it |
| 57 | do i = 1, top_padding |
| 58 | write(output_unit, '()') ! Write blank line with explicit format |
| 59 | end do |
| 60 | |
| 61 | ! Header - Line 1: Always show path |
| 62 | write(output_unit, '(a)') BOLD // "FORTRESS" // RESET // " - " // trim(current_dir) |
| 63 | |
| 64 | ! Header - Line 2: Status info (always present to prevent shifting) |
| 65 | if (in_rename_mode) then |
| 66 | write(output_unit, '(a)') YELLOW // "RENAME MODE" // RESET |
| 67 | else if (move_mode) then |
| 68 | write(output_unit, '(a)') RED // "MOVE: " // trim(move_source_name) // RESET |
| 69 | else if (selection_count > 0) then |
| 70 | ! Show selection count |
| 71 | write(output_unit, '(a)') BLUE // trim(adjustl(itoa(selection_count))) // " selected" // RESET |
| 72 | else if (has_clipboard) then |
| 73 | if (clipboard_count > 1) then |
| 74 | ! Multiple items in clipboard |
| 75 | if (clipboard_is_cut) then |
| 76 | write(output_unit, '(a)') YELLOW // "CUT: " // trim(adjustl(itoa(clipboard_count))) // " items" // RESET |
| 77 | else |
| 78 | write(output_unit, '(a)') GREEN // "COPY: " // trim(adjustl(itoa(clipboard_count))) // " items" // RESET |
| 79 | end if |
| 80 | else |
| 81 | ! Single item in clipboard |
| 82 | if (clipboard_is_cut) then |
| 83 | write(output_unit, '(a)') YELLOW // "CUT: " // trim(clipboard_source_name) // RESET |
| 84 | else |
| 85 | write(output_unit, '(a)') GREEN // "COPY: " // trim(clipboard_source_name) // RESET |
| 86 | end if |
| 87 | end if |
| 88 | else if (in_git_repo .and. trim(mode) == 'git') then |
| 89 | ! Show git mode indicator |
| 90 | write(output_unit, '(a)') CYAN // trim(repo_name) // ":" // YELLOW // trim(branch_name) // " " // & |
| 91 | YELLOW // BOLD // "[ GIT MODE ]" // RESET |
| 92 | else if (in_git_repo) then |
| 93 | ! Show git repo info when no other status |
| 94 | write(output_unit, '(a)') DIM // trim(repo_name) // ":" // trim(branch_name) // RESET |
| 95 | else |
| 96 | ! Empty status line to maintain consistent 2-line header |
| 97 | write(output_unit, '()') |
| 98 | end if |
| 99 | |
| 100 | ! Files (render based on scroll offsets) |
| 101 | do i = 1, vis_h |
| 102 | parent_idx = i + parent_scroll_offset |
| 103 | current_idx = i + scroll_offset |
| 104 | |
| 105 | ! Parent pane |
| 106 | if (parent_idx >= 1 .and. parent_idx <= parent_count) then |
| 107 | fname = parent_files(parent_idx) |
| 108 | |
| 109 | ! Track if this item has a star (for visual width adjustment) |
| 110 | display_len = 0 |
| 111 | if (parent_is_favorite(parent_idx)) then |
| 112 | fname = "★ " // trim(fname) |
| 113 | display_len = -2 ! "★ " is 4 bytes but 2 visual cols, so subtract 2 |
| 114 | end if |
| 115 | |
| 116 | if (parent_is_dir(parent_idx) .and. parent_files(parent_idx) /= "." .and. parent_files(parent_idx) /= "..") then |
| 117 | fname = trim(fname) // "/" |
| 118 | end if |
| 119 | |
| 120 | ! Get color for parent file |
| 121 | color_code = get_file_color(parent_files(parent_idx), parent_is_dir(parent_idx), parent_is_exec(parent_idx)) |
| 122 | |
| 123 | ! Calculate visual width: string length + extra for wide char |
| 124 | display_len = min(len_trim(fname) + display_len, left_w) |
| 125 | |
| 126 | if (parent_idx == parent_selected) then |
| 127 | write(output_unit, '(a)', advance='no') DIM // BOLD // trim(color_code) // & |
| 128 | fname(1:min(len_trim(fname), left_w)) // RESET |
| 129 | else |
| 130 | write(output_unit, '(a)', advance='no') DIM // trim(color_code) // & |
| 131 | fname(1:min(len_trim(fname), left_w)) // RESET |
| 132 | end if |
| 133 | write(output_unit, '(a)', advance='no') repeat(" ", max(0, left_w - display_len)) |
| 134 | else |
| 135 | write(output_unit, '(a)', advance='no') repeat(" ", left_w) |
| 136 | end if |
| 137 | |
| 138 | ! RESET before separator to clear any state from parent pane |
| 139 | write(output_unit, '(a)', advance='no') RESET |
| 140 | |
| 141 | ! Separator |
| 142 | write(output_unit, '(a)', advance='no') " │ " |
| 143 | |
| 144 | ! Current pane |
| 145 | if (current_idx >= 1 .and. current_idx <= current_count) then |
| 146 | |
| 147 | fname = current_files(current_idx) |
| 148 | |
| 149 | ! Track if this item has a star (for visual width - star takes 2 columns) |
| 150 | display_len = 0 |
| 151 | if (current_is_favorite(current_idx)) then |
| 152 | fname = "★ " // trim(fname) |
| 153 | display_len = -2 ! "★ " is 4 bytes but 2 visual cols, so subtract 2 |
| 154 | end if |
| 155 | |
| 156 | if (current_is_dir(current_idx) .and. current_files(current_idx) /= "." .and. current_files(current_idx) /= "..") then |
| 157 | fname = trim(fname) // "/" |
| 158 | end if |
| 159 | |
| 160 | ! Store the visual display length for this line (used by git indicators) |
| 161 | display_len = len_trim(fname) + display_len |
| 162 | |
| 163 | ! Get color for current file |
| 164 | color_code = get_file_color(current_files(current_idx), current_is_dir(current_idx), current_is_exec(current_idx)) |
| 165 | |
| 166 | ! Check if this file is cut to clipboard (show in dark red) |
| 167 | ! Only highlight for single-item cuts (multi-cuts shown in header) |
| 168 | if (has_clipboard .and. clipboard_is_cut .and. clipboard_count == 1 .and. & |
| 169 | trim(current_files(current_idx)) == trim(clipboard_source_name)) then |
| 170 | ! File is cut - show in dark red (dimmed red) |
| 171 | write(output_unit, '(a)', advance='no') DIM // RED // trim(fname) |
| 172 | if (in_git_repo) then |
| 173 | call write_git_indicators(current_is_staged(current_idx), & |
| 174 | current_is_unstaged(current_idx), & |
| 175 | current_is_untracked(current_idx), & |
| 176 | current_has_incoming(current_idx), .false.) |
| 177 | end if |
| 178 | write(output_unit, '(a)') RESET |
| 179 | ! Move mode: show source in red, destination in white |
| 180 | else if (move_mode .and. trim(current_files(current_idx)) == trim(move_source_name)) then |
| 181 | ! Source file - show in RED |
| 182 | write(output_unit, '(a)', advance='no') RED // BOLD // trim(fname) // RESET |
| 183 | write(output_unit, '(a)') "" |
| 184 | else if (move_mode .and. current_idx == move_dest_selected) then |
| 185 | ! Destination cursor - show with bold+underline |
| 186 | write(output_unit, '(a)', advance='no') BOLD // UNDERLINE // WHITE // trim(fname) |
| 187 | if (in_git_repo) then |
| 188 | call write_git_indicators(current_is_staged(current_idx), & |
| 189 | current_is_unstaged(current_idx), & |
| 190 | current_is_untracked(current_idx), & |
| 191 | current_has_incoming(current_idx), .true.) |
| 192 | end if |
| 193 | write(output_unit, '(a)') RESET |
| 194 | else if (current_idx == selected .and. .not. move_mode .and. is_selected(current_idx)) then |
| 195 | ! Cursor on a selected item - use bold+underline with original color |
| 196 | write(output_unit, '(a)', advance='no') BOLD // UNDERLINE // trim(color_code) // trim(fname) |
| 197 | ! Add git indicators if in repo |
| 198 | if (in_git_repo) then |
| 199 | call write_git_indicators(current_is_staged(current_idx), & |
| 200 | current_is_unstaged(current_idx), & |
| 201 | current_is_untracked(current_idx), & |
| 202 | current_has_incoming(current_idx), .true.) |
| 203 | end if |
| 204 | write(output_unit, '(a)') RESET |
| 205 | else if (current_idx == selected .and. .not. move_mode) then |
| 206 | ! Normal selection cursor (not in move mode) |
| 207 | ! Check if in rename mode - show editable buffer with cursor |
| 208 | if (in_rename_mode) then |
| 209 | ! Show rename buffer with block cursor (█) at cursor position |
| 210 | if (current_is_favorite(current_idx)) then |
| 211 | write(output_unit, '(a)', advance='no') REVERSE // trim(color_code) // "★ " |
| 212 | else |
| 213 | write(output_unit, '(a)', advance='no') REVERSE // trim(color_code) |
| 214 | end if |
| 215 | |
| 216 | if (rename_cursor_pos == len_trim(rename_buffer)) then |
| 217 | ! Cursor at end |
| 218 | write(output_unit, '(a)', advance='no') trim(rename_buffer) // '█' |
| 219 | else if (rename_cursor_pos == 0) then |
| 220 | ! Cursor at beginning |
| 221 | write(output_unit, '(a)', advance='no') '█' // trim(rename_buffer) |
| 222 | else |
| 223 | ! Cursor in middle |
| 224 | write(output_unit, '(a)', advance='no') rename_buffer(1:rename_cursor_pos) // '█' // & |
| 225 | rename_buffer(rename_cursor_pos+1:len_trim(rename_buffer)) |
| 226 | end if |
| 227 | if (current_is_dir(current_idx) .and. current_files(current_idx) /= "." .and. & |
| 228 | current_files(current_idx) /= "..") then |
| 229 | write(output_unit, '(a)', advance='no') "/" |
| 230 | end if |
| 231 | write(output_unit, '(a)') RESET |
| 232 | ! Not in rename mode - check if cut file selected |
| 233 | else if (has_clipboard .and. clipboard_is_cut .and. clipboard_count == 1 .and. & |
| 234 | trim(current_files(current_idx)) == trim(clipboard_source_name)) then |
| 235 | ! If file is cut, show selected with red reverse |
| 236 | write(output_unit, '(a)', advance='no') REVERSE // RED // trim(fname) |
| 237 | ! Add git indicators if in repo |
| 238 | if (in_git_repo) then |
| 239 | call write_git_indicators(current_is_staged(current_idx), & |
| 240 | current_is_unstaged(current_idx), & |
| 241 | current_is_untracked(current_idx), & |
| 242 | current_has_incoming(current_idx), .true.) |
| 243 | end if |
| 244 | write(output_unit, '(a)') RESET |
| 245 | else |
| 246 | ! Normal selection cursor - use bold+underline with original color |
| 247 | write(output_unit, '(a)', advance='no') BOLD // UNDERLINE // trim(color_code) // trim(fname) |
| 248 | ! Add git indicators if in repo |
| 249 | if (in_git_repo) then |
| 250 | call write_git_indicators(current_is_staged(current_idx), & |
| 251 | current_is_unstaged(current_idx), & |
| 252 | current_is_untracked(current_idx), & |
| 253 | current_has_incoming(current_idx), .true.) |
| 254 | end if |
| 255 | write(output_unit, '(a)') RESET |
| 256 | end if |
| 257 | else if (is_selected(current_idx)) then |
| 258 | ! Multi-selected item (not the cursor) - show with underline |
| 259 | write(output_unit, '(a)', advance='no') UNDERLINE // trim(color_code) // trim(fname) |
| 260 | ! Add git indicators if in repo |
| 261 | if (in_git_repo) then |
| 262 | call write_git_indicators(current_is_staged(current_idx), & |
| 263 | current_is_unstaged(current_idx), & |
| 264 | current_is_untracked(current_idx), & |
| 265 | current_has_incoming(current_idx), .true.) |
| 266 | end if |
| 267 | write(output_unit, '(a)') RESET |
| 268 | else |
| 269 | ! Normal rendering |
| 270 | write(output_unit, '(a)', advance='no') trim(color_code) // trim(fname) |
| 271 | ! Add git indicators if in repo |
| 272 | if (in_git_repo) then |
| 273 | call write_git_indicators(current_is_staged(current_idx), & |
| 274 | current_is_unstaged(current_idx), & |
| 275 | current_is_untracked(current_idx), & |
| 276 | current_has_incoming(current_idx), .false.) |
| 277 | end if |
| 278 | write(output_unit, '(a)') RESET |
| 279 | end if |
| 280 | else |
| 281 | write(output_unit, *) |
| 282 | end if |
| 283 | end do |
| 284 | |
| 285 | ! Footer - help text only (status moved to header) |
| 286 | ! Build footer text and truncate to prevent wrapping which causes screen scroll |
| 287 | if (in_rename_mode) then |
| 288 | footer_text = YELLOW // "RENAME MODE: " // RESET // & |
| 289 | DIM // "Type to edit | Enter:confirm ESC:cancel" // RESET |
| 290 | else if (move_mode) then |
| 291 | footer_text = RED // "MOVE MODE: " // RESET // & |
| 292 | DIM // "↑↓:next/prev dir →:enter dir ←:parent ~:home /:root alt-m:move here q:cancel" // RESET |
| 293 | else if (selection_count > 0) then |
| 294 | ! Selection mode footer - show multi-select help |
| 295 | footer_text = BLUE // "MULTI-SELECT: " // RESET // & |
| 296 | DIM // "Space:toggle Shift+↑↓:block e:exit | alt-y:copy alt-x:cut alt-p:paste alt-r:delete | " // & |
| 297 | "→:enter ←:back ~:home /:root alt-c:cd ctrl-q:quit" // RESET |
| 298 | else if (in_git_repo .and. trim(mode) == 'git') then |
| 299 | ! Git mode footer - show git operations |
| 300 | footer_text = YELLOW // trim(repo_name) // ":" // trim(branch_name) // " [ GIT MODE ]" // RESET // " | " // & |
| 301 | YELLOW // "a:add u:unstage m:commit h:push l:pull f:fetch d:diff t:tag" // RESET // " | " // & |
| 302 | DIM // "Space:select ↑↓:nav →:enter ←:back ~:home /:root 8:fav *:star " // & |
| 303 | "alt-n:rename alt-v:view alt-m:move alt-y:copy alt-x:cut alt-p:paste alt-r:delete .:hidden " // & |
| 304 | "alt-s:search alt-g:exit-mode alt-c:cd ctrl-q:quit" // RESET |
| 305 | else if (in_git_repo) then |
| 306 | ! Normal mode in git repo - show alt-g to enter git mode |
| 307 | footer_text = DIM // trim(repo_name) // ":" // trim(branch_name) // " | " // & |
| 308 | "Space:select Shift+↑↓:block | ↑↓:nav →:enter ←:back ~:home /:root " // & |
| 309 | "8:fav *:star alt-n:rename alt-v:view alt-m:move alt-y:copy alt-x:cut alt-p:paste alt-r:delete " // & |
| 310 | ".:hidden alt-s:search alt-g:git-mode alt-c:cd ctrl-q:quit" // RESET |
| 311 | else |
| 312 | ! Non-git repo footer |
| 313 | footer_text = DIM // "Space:select Shift+↑↓:block | ↑↓:nav →:enter ←:back ~:home /:root " // & |
| 314 | "8:fav *:star alt-n:rename alt-v:view alt-m:move alt-y:copy alt-x:cut alt-p:paste alt-r:delete " // & |
| 315 | ".:hidden alt-s:search alt-c:cd ctrl-q:quit" // RESET |
| 316 | end if |
| 317 | |
| 318 | ! Truncate footer to terminal width - CRITICAL to prevent wrapping which causes screen scroll |
| 319 | ! Conservative truncation: assume ANSI codes are ~30% of string, so truncate to ~130% of terminal width |
| 320 | footer_len = len_trim(footer_text) |
| 321 | if (footer_len > (c * 13 / 10)) then |
| 322 | footer_text = footer_text(1:c * 13 / 10) |
| 323 | end if |
| 324 | |
| 325 | ! Write footer WITHOUT newline (newline on last row causes wrap which scrolls screen up) |
| 326 | write(output_unit, '(a)', advance='no') trim(footer_text) |
| 327 | |
| 328 | contains |
| 329 | function itoa(n) result(str) |
| 330 | integer, intent(in) :: n |
| 331 | character(len=10) :: str |
| 332 | write(str, '(i0)') n |
| 333 | end function itoa |
| 334 | end subroutine draw_interface |
| 335 | |
| 336 | function get_file_color(filename, is_dir, is_exec) result(color) |
| 337 | character(len=*), intent(in) :: filename |
| 338 | logical, intent(in) :: is_dir, is_exec |
| 339 | character(len=20) :: color |
| 340 | |
| 341 | ! Directories: Blue and bold |
| 342 | if (is_dir) then |
| 343 | color = BOLD // BLUE |
| 344 | ! Dotfiles: Grey |
| 345 | else if (filename(1:1) == '.') then |
| 346 | color = GREY |
| 347 | ! Executable files: Green |
| 348 | else if (is_exec) then |
| 349 | color = GREEN |
| 350 | ! All other files: White |
| 351 | else |
| 352 | color = WHITE |
| 353 | end if |
| 354 | end function get_file_color |
| 355 | |
| 356 | end module ui_display |
| 357 |