| 1 | module terminal_mod |
| 2 | use cell_mod |
| 3 | use screen_mod |
| 4 | use cursor_mod |
| 5 | use scrollback_mod |
| 6 | use wcwidth_mod, only: codepoint_width |
| 7 | implicit none |
| 8 | private |
| 9 | |
| 10 | public :: terminal_t |
| 11 | public :: terminal_init, terminal_destroy, terminal_resize, terminal_reset |
| 12 | public :: terminal_put_char, terminal_newline, terminal_carriage_return |
| 13 | public :: terminal_tab, terminal_backspace |
| 14 | public :: terminal_scroll_up, terminal_scroll_down |
| 15 | public :: terminal_erase_display, terminal_erase_line |
| 16 | public :: terminal_cursor_move, terminal_cursor_up, terminal_cursor_down |
| 17 | public :: terminal_cursor_forward, terminal_cursor_backward |
| 18 | public :: terminal_save_cursor, terminal_restore_cursor |
| 19 | public :: terminal_set_scroll_region |
| 20 | public :: terminal_insert_lines, terminal_delete_lines |
| 21 | public :: terminal_insert_chars, terminal_delete_chars |
| 22 | public :: terminal_index, terminal_reverse_index |
| 23 | public :: terminal_switch_screen, terminal_active_screen |
| 24 | public :: terminal_scroll_view, terminal_get_scroll_offset, terminal_reset_scroll_view |
| 25 | public :: terminal_get_scrollback_count, terminal_get_scrollback_line |
| 26 | public :: terminal_queue_response, terminal_get_response, terminal_has_response |
| 27 | public :: terminal_set_title, terminal_get_title, terminal_has_title_changed |
| 28 | |
| 29 | integer, parameter :: RESPONSE_BUFFER_SIZE = 256 |
| 30 | |
| 31 | type :: terminal_t |
| 32 | type(screen_t) :: screen ! Primary screen buffer |
| 33 | type(screen_t) :: alt_screen ! Alternate screen buffer |
| 34 | logical :: using_alt = .false. ! Which screen is active |
| 35 | |
| 36 | type(cursor_t) :: cursor ! Current cursor state |
| 37 | type(cursor_t) :: saved_cursor ! Saved cursor (for DECSC/DECRC) |
| 38 | |
| 39 | integer :: rows = 24 ! Terminal rows |
| 40 | integer :: cols = 80 ! Terminal columns |
| 41 | integer :: scroll_top = 1 ! Scroll region top |
| 42 | integer :: scroll_bottom = 24 ! Scroll region bottom |
| 43 | |
| 44 | ! Scrollback buffer (for primary screen only) |
| 45 | type(scrollback_t) :: scrollback |
| 46 | integer :: scroll_offset = 0 ! View offset into scrollback (0 = live view) |
| 47 | |
| 48 | ! Mode flags |
| 49 | logical :: mode_autowrap = .true. ! Auto-wrap at end of line |
| 50 | logical :: mode_origin = .false. ! Origin mode (cursor relative to scroll region) |
| 51 | logical :: mode_insert = .false. ! Insert mode |
| 52 | logical :: pending_wrap = .false. ! Deferred wrap: cursor at EOL, wrap on next char |
| 53 | |
| 54 | ! Tab stops |
| 55 | logical, allocatable :: tabstops(:) |
| 56 | |
| 57 | ! Response buffer for escape sequence replies (e.g., DA1) |
| 58 | character(len=RESPONSE_BUFFER_SIZE) :: response = '' |
| 59 | integer :: response_len = 0 |
| 60 | |
| 61 | ! Window title (set via OSC 0/1/2) |
| 62 | character(len=256) :: title = 'fortty' |
| 63 | logical :: title_changed = .false. |
| 64 | end type terminal_t |
| 65 | |
| 66 | contains |
| 67 | |
| 68 | ! Initialize terminal |
| 69 | subroutine terminal_init(term, rows, cols) |
| 70 | type(terminal_t), intent(inout) :: term |
| 71 | integer, intent(in) :: rows, cols |
| 72 | integer :: i |
| 73 | |
| 74 | term%rows = rows |
| 75 | term%cols = cols |
| 76 | term%scroll_top = 1 |
| 77 | term%scroll_bottom = rows |
| 78 | term%scroll_offset = 0 |
| 79 | |
| 80 | ! Initialize both screen buffers |
| 81 | call screen_init(term%screen, rows, cols) |
| 82 | call screen_init(term%alt_screen, rows, cols) |
| 83 | |
| 84 | ! Initialize scrollback buffer |
| 85 | call scrollback_init(term%scrollback, cols) |
| 86 | |
| 87 | ! Initialize cursor with default colors |
| 88 | term%cursor%row = 1 |
| 89 | term%cursor%col = 1 |
| 90 | term%cursor%fg = default_fg |
| 91 | term%cursor%bg = default_bg |
| 92 | term%cursor%attrs = 0 |
| 93 | |
| 94 | ! Initialize tab stops every 8 columns |
| 95 | allocate(term%tabstops(cols)) |
| 96 | term%tabstops = .false. |
| 97 | do i = 1, cols, 8 |
| 98 | term%tabstops(i) = .true. |
| 99 | end do |
| 100 | end subroutine terminal_init |
| 101 | |
| 102 | ! Destroy terminal |
| 103 | subroutine terminal_destroy(term) |
| 104 | type(terminal_t), intent(inout) :: term |
| 105 | |
| 106 | call screen_destroy(term%screen) |
| 107 | call screen_destroy(term%alt_screen) |
| 108 | call scrollback_destroy(term%scrollback) |
| 109 | if (allocated(term%tabstops)) deallocate(term%tabstops) |
| 110 | end subroutine terminal_destroy |
| 111 | |
| 112 | ! Resize terminal |
| 113 | subroutine terminal_resize(term, rows, cols) |
| 114 | type(terminal_t), intent(inout) :: term |
| 115 | integer, intent(in) :: rows, cols |
| 116 | integer :: i |
| 117 | |
| 118 | term%rows = rows |
| 119 | term%cols = cols |
| 120 | term%scroll_bottom = rows |
| 121 | |
| 122 | call screen_resize(term%screen, rows, cols) |
| 123 | call screen_resize(term%alt_screen, rows, cols) |
| 124 | |
| 125 | ! Resize tab stops |
| 126 | if (allocated(term%tabstops)) deallocate(term%tabstops) |
| 127 | allocate(term%tabstops(cols)) |
| 128 | term%tabstops = .false. |
| 129 | do i = 1, cols, 8 |
| 130 | term%tabstops(i) = .true. |
| 131 | end do |
| 132 | |
| 133 | ! Clamp cursor to new bounds |
| 134 | term%cursor%row = min(term%cursor%row, rows) |
| 135 | term%cursor%col = min(term%cursor%col, cols) |
| 136 | end subroutine terminal_resize |
| 137 | |
| 138 | ! Get pointer to active screen |
| 139 | function terminal_active_screen(term) result(scr) |
| 140 | type(terminal_t), intent(inout), target :: term |
| 141 | type(screen_t), pointer :: scr |
| 142 | |
| 143 | if (term%using_alt) then |
| 144 | scr => term%alt_screen |
| 145 | else |
| 146 | scr => term%screen |
| 147 | end if |
| 148 | end function terminal_active_screen |
| 149 | |
| 150 | ! Put a character at cursor position |
| 151 | subroutine terminal_put_char(term, codepoint) |
| 152 | type(terminal_t), intent(inout) :: term |
| 153 | integer, intent(in) :: codepoint |
| 154 | type(cell_t) :: cell, cont_cell |
| 155 | type(screen_t), pointer :: scr |
| 156 | integer :: width |
| 157 | |
| 158 | scr => terminal_active_screen(term) |
| 159 | |
| 160 | ! Handle control characters |
| 161 | select case (codepoint) |
| 162 | case (10) ! LF - Line Feed |
| 163 | call terminal_newline(term) |
| 164 | return |
| 165 | case (13) ! CR - Carriage Return |
| 166 | call terminal_carriage_return(term) |
| 167 | return |
| 168 | case (9) ! HT - Horizontal Tab |
| 169 | call terminal_tab(term) |
| 170 | return |
| 171 | case (8) ! BS - Backspace |
| 172 | call terminal_backspace(term) |
| 173 | return |
| 174 | case (7) ! BEL - Bell (ignore for now) |
| 175 | return |
| 176 | case (0:6, 14:26, 28:31) ! Other control chars - ignore (27=ESC handled in Phase 5) |
| 177 | return |
| 178 | end select |
| 179 | |
| 180 | ! Determine character display width |
| 181 | width = codepoint_width(codepoint) |
| 182 | |
| 183 | ! Skip zero-width characters (combining marks, etc.) |
| 184 | if (width == 0) return |
| 185 | |
| 186 | ! Handle deferred wrap: if pending, execute wrap now before writing new char |
| 187 | if (term%pending_wrap) then |
| 188 | call terminal_newline(term) |
| 189 | term%cursor%col = 1 |
| 190 | term%pending_wrap = .false. |
| 191 | end if |
| 192 | |
| 193 | ! Check if wide character fits before line end |
| 194 | if (width == 2 .and. term%cursor%col + 1 > term%cols) then |
| 195 | if (term%mode_autowrap) then |
| 196 | call terminal_newline(term) |
| 197 | term%cursor%col = 1 |
| 198 | else |
| 199 | ! Can't fit - don't draw |
| 200 | return |
| 201 | end if |
| 202 | end if |
| 203 | |
| 204 | ! Printable character - create cell with current style |
| 205 | cell%codepoint = codepoint |
| 206 | cell%fg = term%cursor%fg |
| 207 | cell%bg = term%cursor%bg |
| 208 | cell%attrs = term%cursor%attrs |
| 209 | cell%width = width |
| 210 | cell%is_continuation = .false. |
| 211 | |
| 212 | ! Place in buffer |
| 213 | call screen_set_cell(scr, term%cursor%row, term%cursor%col, cell) |
| 214 | |
| 215 | ! For wide characters, write continuation cell |
| 216 | if (width == 2 .and. term%cursor%col < term%cols) then |
| 217 | cont_cell%codepoint = 0 |
| 218 | cont_cell%fg = term%cursor%fg |
| 219 | cont_cell%bg = term%cursor%bg |
| 220 | cont_cell%attrs = term%cursor%attrs |
| 221 | cont_cell%width = 0 |
| 222 | cont_cell%is_continuation = .true. |
| 223 | call screen_set_cell(scr, term%cursor%row, term%cursor%col + 1, cont_cell) |
| 224 | end if |
| 225 | |
| 226 | ! Advance cursor by character width |
| 227 | term%cursor%col = term%cursor%col + width |
| 228 | |
| 229 | ! Handle wrap at end of line - use deferred wrap |
| 230 | if (term%cursor%col > term%cols) then |
| 231 | if (term%mode_autowrap) then |
| 232 | ! Don't wrap yet - set pending flag, wrap on next char |
| 233 | term%cursor%col = term%cols ! Keep cursor at last column |
| 234 | term%pending_wrap = .true. |
| 235 | else |
| 236 | term%cursor%col = term%cols ! Stay at edge |
| 237 | end if |
| 238 | end if |
| 239 | end subroutine terminal_put_char |
| 240 | |
| 241 | ! Move to next line |
| 242 | subroutine terminal_newline(term) |
| 243 | type(terminal_t), intent(inout) :: term |
| 244 | |
| 245 | if (term%cursor%row >= term%scroll_bottom) then |
| 246 | ! At bottom of scroll region - scroll up |
| 247 | call terminal_scroll_up(term, 1) |
| 248 | else |
| 249 | ! Move down |
| 250 | term%cursor%row = term%cursor%row + 1 |
| 251 | end if |
| 252 | end subroutine terminal_newline |
| 253 | |
| 254 | ! Move to start of line |
| 255 | subroutine terminal_carriage_return(term) |
| 256 | type(terminal_t), intent(inout) :: term |
| 257 | |
| 258 | term%cursor%col = 1 |
| 259 | term%pending_wrap = .false. ! Cancel deferred wrap |
| 260 | end subroutine terminal_carriage_return |
| 261 | |
| 262 | ! Move to next tab stop |
| 263 | subroutine terminal_tab(term) |
| 264 | type(terminal_t), intent(inout) :: term |
| 265 | integer :: col |
| 266 | |
| 267 | term%pending_wrap = .false. ! Cancel deferred wrap |
| 268 | |
| 269 | do col = term%cursor%col + 1, term%cols |
| 270 | if (term%tabstops(col)) then |
| 271 | term%cursor%col = col |
| 272 | return |
| 273 | end if |
| 274 | end do |
| 275 | |
| 276 | ! No more tab stops - go to end |
| 277 | term%cursor%col = term%cols |
| 278 | end subroutine terminal_tab |
| 279 | |
| 280 | ! Move cursor back one position |
| 281 | subroutine terminal_backspace(term) |
| 282 | type(terminal_t), intent(inout) :: term |
| 283 | |
| 284 | term%pending_wrap = .false. ! Cancel deferred wrap |
| 285 | if (term%cursor%col > 1) then |
| 286 | term%cursor%col = term%cursor%col - 1 |
| 287 | end if |
| 288 | end subroutine terminal_backspace |
| 289 | |
| 290 | ! Scroll up n lines within scroll region |
| 291 | subroutine terminal_scroll_up(term, n) |
| 292 | type(terminal_t), intent(inout) :: term |
| 293 | integer, intent(in) :: n |
| 294 | type(screen_t), pointer :: scr |
| 295 | integer :: row, col, src_row, i |
| 296 | |
| 297 | scr => terminal_active_screen(term) |
| 298 | |
| 299 | ! Save lines to scrollback before they're lost (primary screen only) |
| 300 | ! Only save if scrolling from the very top of the screen |
| 301 | if (.not. term%using_alt .and. term%scroll_top == 1) then |
| 302 | do i = 1, min(n, term%scroll_bottom) |
| 303 | call scrollback_push_line(term%scrollback, scr%cells(i, :), term%cols) |
| 304 | end do |
| 305 | end if |
| 306 | |
| 307 | ! Move lines up |
| 308 | do row = term%scroll_top, term%scroll_bottom - n |
| 309 | src_row = row + n |
| 310 | do col = 1, term%cols |
| 311 | scr%cells(row, col) = scr%cells(src_row, col) |
| 312 | end do |
| 313 | call screen_mark_dirty(scr, row) |
| 314 | end do |
| 315 | |
| 316 | ! Clear new lines at bottom |
| 317 | do row = term%scroll_bottom - n + 1, term%scroll_bottom |
| 318 | call screen_clear_line(scr, row) |
| 319 | end do |
| 320 | end subroutine terminal_scroll_up |
| 321 | |
| 322 | ! Scroll down n lines within scroll region |
| 323 | subroutine terminal_scroll_down(term, n) |
| 324 | type(terminal_t), intent(inout) :: term |
| 325 | integer, intent(in) :: n |
| 326 | type(screen_t), pointer :: scr |
| 327 | integer :: row, col, src_row |
| 328 | |
| 329 | scr => terminal_active_screen(term) |
| 330 | |
| 331 | ! Move lines down (iterate in reverse) |
| 332 | do row = term%scroll_bottom, term%scroll_top + n, -1 |
| 333 | src_row = row - n |
| 334 | do col = 1, term%cols |
| 335 | scr%cells(row, col) = scr%cells(src_row, col) |
| 336 | end do |
| 337 | call screen_mark_dirty(scr, row) |
| 338 | end do |
| 339 | |
| 340 | ! Clear new lines at top |
| 341 | do row = term%scroll_top, term%scroll_top + n - 1 |
| 342 | call screen_clear_line(scr, row) |
| 343 | end do |
| 344 | end subroutine terminal_scroll_down |
| 345 | |
| 346 | ! Erase in display (ED) |
| 347 | ! mode 0: cursor to end, mode 1: start to cursor, mode 2: entire screen |
| 348 | subroutine terminal_erase_display(term, mode) |
| 349 | type(terminal_t), intent(inout) :: term |
| 350 | integer, intent(in) :: mode |
| 351 | type(screen_t), pointer :: scr |
| 352 | integer :: row |
| 353 | |
| 354 | scr => terminal_active_screen(term) |
| 355 | |
| 356 | select case (mode) |
| 357 | case (0) ! Cursor to end of screen |
| 358 | ! Clear from cursor to end of current line |
| 359 | call screen_clear_region(scr, term%cursor%row, term%cursor%col, & |
| 360 | term%cursor%row, term%cols) |
| 361 | ! Clear remaining lines |
| 362 | do row = term%cursor%row + 1, term%rows |
| 363 | call screen_clear_line(scr, row) |
| 364 | end do |
| 365 | |
| 366 | case (1) ! Start to cursor |
| 367 | ! Clear lines before cursor |
| 368 | do row = 1, term%cursor%row - 1 |
| 369 | call screen_clear_line(scr, row) |
| 370 | end do |
| 371 | ! Clear from start of line to cursor |
| 372 | call screen_clear_region(scr, term%cursor%row, 1, & |
| 373 | term%cursor%row, term%cursor%col) |
| 374 | |
| 375 | case (2, 3) ! Entire screen (3 also clears scrollback, but we don't have that yet) |
| 376 | call screen_clear(scr) |
| 377 | end select |
| 378 | end subroutine terminal_erase_display |
| 379 | |
| 380 | ! Erase in line (EL) |
| 381 | ! mode 0: cursor to end, mode 1: start to cursor, mode 2: entire line |
| 382 | subroutine terminal_erase_line(term, mode) |
| 383 | type(terminal_t), intent(inout) :: term |
| 384 | integer, intent(in) :: mode |
| 385 | type(screen_t), pointer :: scr |
| 386 | |
| 387 | scr => terminal_active_screen(term) |
| 388 | |
| 389 | select case (mode) |
| 390 | case (0) ! Cursor to end of line |
| 391 | call screen_clear_region(scr, term%cursor%row, term%cursor%col, & |
| 392 | term%cursor%row, term%cols) |
| 393 | |
| 394 | case (1) ! Start to cursor |
| 395 | call screen_clear_region(scr, term%cursor%row, 1, & |
| 396 | term%cursor%row, term%cursor%col) |
| 397 | |
| 398 | case (2) ! Entire line |
| 399 | call screen_clear_line(scr, term%cursor%row) |
| 400 | end select |
| 401 | end subroutine terminal_erase_line |
| 402 | |
| 403 | ! Move cursor to absolute position |
| 404 | subroutine terminal_cursor_move(term, row, col) |
| 405 | type(terminal_t), intent(inout) :: term |
| 406 | integer, intent(in) :: row, col |
| 407 | |
| 408 | term%pending_wrap = .false. ! Cancel deferred wrap |
| 409 | term%cursor%row = max(1, min(row, term%rows)) |
| 410 | term%cursor%col = max(1, min(col, term%cols)) |
| 411 | end subroutine terminal_cursor_move |
| 412 | |
| 413 | ! Move cursor up n rows |
| 414 | subroutine terminal_cursor_up(term, n) |
| 415 | type(terminal_t), intent(inout) :: term |
| 416 | integer, intent(in) :: n |
| 417 | |
| 418 | term%pending_wrap = .false. ! Cancel deferred wrap |
| 419 | term%cursor%row = max(1, term%cursor%row - n) |
| 420 | end subroutine terminal_cursor_up |
| 421 | |
| 422 | ! Move cursor down n rows |
| 423 | subroutine terminal_cursor_down(term, n) |
| 424 | type(terminal_t), intent(inout) :: term |
| 425 | integer, intent(in) :: n |
| 426 | |
| 427 | term%pending_wrap = .false. ! Cancel deferred wrap |
| 428 | term%cursor%row = min(term%rows, term%cursor%row + n) |
| 429 | end subroutine terminal_cursor_down |
| 430 | |
| 431 | ! Move cursor forward n columns |
| 432 | subroutine terminal_cursor_forward(term, n) |
| 433 | type(terminal_t), intent(inout) :: term |
| 434 | integer, intent(in) :: n |
| 435 | |
| 436 | term%pending_wrap = .false. ! Cancel deferred wrap |
| 437 | term%cursor%col = min(term%cols, term%cursor%col + n) |
| 438 | end subroutine terminal_cursor_forward |
| 439 | |
| 440 | ! Move cursor backward n columns |
| 441 | subroutine terminal_cursor_backward(term, n) |
| 442 | type(terminal_t), intent(inout) :: term |
| 443 | integer, intent(in) :: n |
| 444 | |
| 445 | term%pending_wrap = .false. ! Cancel deferred wrap |
| 446 | term%cursor%col = max(1, term%cursor%col - n) |
| 447 | end subroutine terminal_cursor_backward |
| 448 | |
| 449 | ! Switch between primary and alternate screen |
| 450 | subroutine terminal_switch_screen(term, use_alt) |
| 451 | type(terminal_t), intent(inout) :: term |
| 452 | logical, intent(in) :: use_alt |
| 453 | |
| 454 | if (use_alt .and. .not. term%using_alt) then |
| 455 | ! Switching to alternate - save cursor |
| 456 | term%saved_cursor = term%cursor |
| 457 | term%using_alt = .true. |
| 458 | call screen_clear(term%alt_screen) |
| 459 | term%cursor%row = 1 |
| 460 | term%cursor%col = 1 |
| 461 | else if (.not. use_alt .and. term%using_alt) then |
| 462 | ! Switching back to primary - restore cursor |
| 463 | term%using_alt = .false. |
| 464 | term%cursor = term%saved_cursor |
| 465 | end if |
| 466 | |
| 467 | ! Mark active screen as fully dirty |
| 468 | if (term%using_alt) then |
| 469 | call screen_mark_all_dirty(term%alt_screen) |
| 470 | else |
| 471 | call screen_mark_all_dirty(term%screen) |
| 472 | end if |
| 473 | end subroutine terminal_switch_screen |
| 474 | |
| 475 | ! Save cursor position and attributes (DECSC) |
| 476 | subroutine terminal_save_cursor(term) |
| 477 | type(terminal_t), intent(inout) :: term |
| 478 | |
| 479 | term%saved_cursor = term%cursor |
| 480 | end subroutine terminal_save_cursor |
| 481 | |
| 482 | ! Restore cursor position and attributes (DECRC) |
| 483 | subroutine terminal_restore_cursor(term) |
| 484 | type(terminal_t), intent(inout) :: term |
| 485 | |
| 486 | term%cursor = term%saved_cursor |
| 487 | ! Clamp to current screen bounds |
| 488 | term%cursor%row = max(1, min(term%cursor%row, term%rows)) |
| 489 | term%cursor%col = max(1, min(term%cursor%col, term%cols)) |
| 490 | end subroutine terminal_restore_cursor |
| 491 | |
| 492 | ! Set scroll region (DECSTBM) |
| 493 | subroutine terminal_set_scroll_region(term, top, bottom) |
| 494 | type(terminal_t), intent(inout) :: term |
| 495 | integer, intent(in) :: top, bottom |
| 496 | |
| 497 | if (top < bottom .and. top >= 1 .and. bottom <= term%rows) then |
| 498 | term%scroll_top = top |
| 499 | term%scroll_bottom = bottom |
| 500 | ! Move cursor to home position |
| 501 | if (term%mode_origin) then |
| 502 | term%cursor%row = top |
| 503 | else |
| 504 | term%cursor%row = 1 |
| 505 | end if |
| 506 | term%cursor%col = 1 |
| 507 | end if |
| 508 | end subroutine terminal_set_scroll_region |
| 509 | |
| 510 | ! Insert n blank lines at cursor (IL) |
| 511 | subroutine terminal_insert_lines(term, n) |
| 512 | type(terminal_t), intent(inout) :: term |
| 513 | integer, intent(in) :: n |
| 514 | type(screen_t), pointer :: scr |
| 515 | integer :: row, col, src_row, actual_n |
| 516 | |
| 517 | ! Only works within scroll region |
| 518 | if (term%cursor%row < term%scroll_top .or. term%cursor%row > term%scroll_bottom) return |
| 519 | |
| 520 | scr => terminal_active_screen(term) |
| 521 | actual_n = min(n, term%scroll_bottom - term%cursor%row + 1) |
| 522 | |
| 523 | ! Move lines down (iterate in reverse) |
| 524 | do row = term%scroll_bottom, term%cursor%row + actual_n, -1 |
| 525 | src_row = row - actual_n |
| 526 | do col = 1, term%cols |
| 527 | scr%cells(row, col) = scr%cells(src_row, col) |
| 528 | end do |
| 529 | call screen_mark_dirty(scr, row) |
| 530 | end do |
| 531 | |
| 532 | ! Clear new lines at cursor position |
| 533 | do row = term%cursor%row, term%cursor%row + actual_n - 1 |
| 534 | call screen_clear_line(scr, row) |
| 535 | end do |
| 536 | end subroutine terminal_insert_lines |
| 537 | |
| 538 | ! Delete n lines at cursor (DL) |
| 539 | subroutine terminal_delete_lines(term, n) |
| 540 | type(terminal_t), intent(inout) :: term |
| 541 | integer, intent(in) :: n |
| 542 | type(screen_t), pointer :: scr |
| 543 | integer :: row, col, src_row, actual_n |
| 544 | |
| 545 | ! Only works within scroll region |
| 546 | if (term%cursor%row < term%scroll_top .or. term%cursor%row > term%scroll_bottom) return |
| 547 | |
| 548 | scr => terminal_active_screen(term) |
| 549 | actual_n = min(n, term%scroll_bottom - term%cursor%row + 1) |
| 550 | |
| 551 | ! Move lines up |
| 552 | do row = term%cursor%row, term%scroll_bottom - actual_n |
| 553 | src_row = row + actual_n |
| 554 | do col = 1, term%cols |
| 555 | scr%cells(row, col) = scr%cells(src_row, col) |
| 556 | end do |
| 557 | call screen_mark_dirty(scr, row) |
| 558 | end do |
| 559 | |
| 560 | ! Clear lines at bottom of scroll region |
| 561 | do row = term%scroll_bottom - actual_n + 1, term%scroll_bottom |
| 562 | call screen_clear_line(scr, row) |
| 563 | end do |
| 564 | end subroutine terminal_delete_lines |
| 565 | |
| 566 | ! Insert n blank characters at cursor (ICH) |
| 567 | subroutine terminal_insert_chars(term, n) |
| 568 | type(terminal_t), intent(inout) :: term |
| 569 | integer, intent(in) :: n |
| 570 | type(screen_t), pointer :: scr |
| 571 | integer :: col, src_col, actual_n |
| 572 | |
| 573 | scr => terminal_active_screen(term) |
| 574 | actual_n = min(n, term%cols - term%cursor%col + 1) |
| 575 | |
| 576 | ! Shift characters right |
| 577 | do col = term%cols, term%cursor%col + actual_n, -1 |
| 578 | src_col = col - actual_n |
| 579 | scr%cells(term%cursor%row, col) = scr%cells(term%cursor%row, src_col) |
| 580 | end do |
| 581 | |
| 582 | ! Clear inserted positions |
| 583 | do col = term%cursor%col, term%cursor%col + actual_n - 1 |
| 584 | scr%cells(term%cursor%row, col) = cell_t(32, default_fg, default_bg, 0) |
| 585 | end do |
| 586 | |
| 587 | call screen_mark_dirty(scr, term%cursor%row) |
| 588 | end subroutine terminal_insert_chars |
| 589 | |
| 590 | ! Delete n characters at cursor (DCH) |
| 591 | subroutine terminal_delete_chars(term, n) |
| 592 | type(terminal_t), intent(inout) :: term |
| 593 | integer, intent(in) :: n |
| 594 | type(screen_t), pointer :: scr |
| 595 | integer :: col, src_col, actual_n |
| 596 | |
| 597 | scr => terminal_active_screen(term) |
| 598 | actual_n = min(n, term%cols - term%cursor%col + 1) |
| 599 | |
| 600 | ! Shift characters left |
| 601 | do col = term%cursor%col, term%cols - actual_n |
| 602 | src_col = col + actual_n |
| 603 | scr%cells(term%cursor%row, col) = scr%cells(term%cursor%row, src_col) |
| 604 | end do |
| 605 | |
| 606 | ! Clear vacated positions at end |
| 607 | do col = term%cols - actual_n + 1, term%cols |
| 608 | scr%cells(term%cursor%row, col) = cell_t(32, default_fg, default_bg, 0) |
| 609 | end do |
| 610 | |
| 611 | call screen_mark_dirty(scr, term%cursor%row) |
| 612 | end subroutine terminal_delete_chars |
| 613 | |
| 614 | ! Index - move cursor down, scroll if at bottom (IND) |
| 615 | subroutine terminal_index(term) |
| 616 | type(terminal_t), intent(inout) :: term |
| 617 | |
| 618 | if (term%cursor%row >= term%scroll_bottom) then |
| 619 | call terminal_scroll_up(term, 1) |
| 620 | else |
| 621 | term%cursor%row = term%cursor%row + 1 |
| 622 | end if |
| 623 | end subroutine terminal_index |
| 624 | |
| 625 | ! Reverse index - move cursor up, scroll if at top (RI) |
| 626 | subroutine terminal_reverse_index(term) |
| 627 | type(terminal_t), intent(inout) :: term |
| 628 | |
| 629 | if (term%cursor%row <= term%scroll_top) then |
| 630 | call terminal_scroll_down(term, 1) |
| 631 | else |
| 632 | term%cursor%row = term%cursor%row - 1 |
| 633 | end if |
| 634 | end subroutine terminal_reverse_index |
| 635 | |
| 636 | ! Reset terminal to initial state (RIS) |
| 637 | subroutine terminal_reset(term) |
| 638 | type(terminal_t), intent(inout) :: term |
| 639 | integer :: i |
| 640 | |
| 641 | ! Reset scroll region |
| 642 | term%scroll_top = 1 |
| 643 | term%scroll_bottom = term%rows |
| 644 | |
| 645 | ! Reset modes |
| 646 | term%mode_autowrap = .true. |
| 647 | term%mode_origin = .false. |
| 648 | term%mode_insert = .false. |
| 649 | term%pending_wrap = .false. |
| 650 | |
| 651 | ! Reset cursor |
| 652 | term%cursor%row = 1 |
| 653 | term%cursor%col = 1 |
| 654 | term%cursor%fg = default_fg |
| 655 | term%cursor%bg = default_bg |
| 656 | term%cursor%attrs = 0 |
| 657 | term%cursor%visible = .true. |
| 658 | |
| 659 | ! Clear screen |
| 660 | call screen_clear(term%screen) |
| 661 | call screen_clear(term%alt_screen) |
| 662 | |
| 663 | ! Switch to primary screen |
| 664 | term%using_alt = .false. |
| 665 | |
| 666 | ! Reset tab stops |
| 667 | term%tabstops = .false. |
| 668 | do i = 1, term%cols, 8 |
| 669 | term%tabstops(i) = .true. |
| 670 | end do |
| 671 | end subroutine terminal_reset |
| 672 | |
| 673 | ! Scroll view into scrollback history |
| 674 | ! Positive delta = scroll up (back in history) |
| 675 | ! Negative delta = scroll down (toward present) |
| 676 | subroutine terminal_scroll_view(term, delta) |
| 677 | type(terminal_t), intent(inout) :: term |
| 678 | integer, intent(in) :: delta |
| 679 | integer :: max_offset |
| 680 | |
| 681 | ! Don't scroll on alternate screen |
| 682 | if (term%using_alt) return |
| 683 | |
| 684 | max_offset = scrollback_count(term%scrollback) |
| 685 | term%scroll_offset = term%scroll_offset + delta |
| 686 | term%scroll_offset = max(0, min(term%scroll_offset, max_offset)) |
| 687 | end subroutine terminal_scroll_view |
| 688 | |
| 689 | ! Get current scroll offset |
| 690 | function terminal_get_scroll_offset(term) result(offset) |
| 691 | type(terminal_t), intent(in) :: term |
| 692 | integer :: offset |
| 693 | |
| 694 | offset = term%scroll_offset |
| 695 | end function terminal_get_scroll_offset |
| 696 | |
| 697 | ! Reset scroll view to live (current) output |
| 698 | subroutine terminal_reset_scroll_view(term) |
| 699 | type(terminal_t), intent(inout) :: term |
| 700 | |
| 701 | term%scroll_offset = 0 |
| 702 | end subroutine terminal_reset_scroll_view |
| 703 | |
| 704 | ! Get number of lines in scrollback |
| 705 | function terminal_get_scrollback_count(term) result(n) |
| 706 | type(terminal_t), intent(in) :: term |
| 707 | integer :: n |
| 708 | |
| 709 | n = scrollback_count(term%scrollback) |
| 710 | end function terminal_get_scrollback_count |
| 711 | |
| 712 | ! Get a line from scrollback |
| 713 | ! offset: 0 = most recent line, 1 = second most recent, etc. |
| 714 | subroutine terminal_get_scrollback_line(term, offset, line, cols) |
| 715 | type(terminal_t), intent(in) :: term |
| 716 | integer, intent(in) :: offset |
| 717 | type(cell_t), intent(out) :: line(:) |
| 718 | integer, intent(in) :: cols |
| 719 | |
| 720 | call scrollback_get_line(term%scrollback, offset, line, cols) |
| 721 | end subroutine terminal_get_scrollback_line |
| 722 | |
| 723 | ! Queue a response to be sent back to the PTY |
| 724 | subroutine terminal_queue_response(term, response) |
| 725 | type(terminal_t), intent(inout) :: term |
| 726 | character(len=*), intent(in) :: response |
| 727 | integer :: len_resp |
| 728 | |
| 729 | len_resp = len_trim(response) |
| 730 | if (len_resp > 0 .and. len_resp <= RESPONSE_BUFFER_SIZE) then |
| 731 | term%response = response |
| 732 | term%response_len = len_resp |
| 733 | end if |
| 734 | end subroutine terminal_queue_response |
| 735 | |
| 736 | ! Check if there's a pending response |
| 737 | function terminal_has_response(term) result(has) |
| 738 | type(terminal_t), intent(in) :: term |
| 739 | logical :: has |
| 740 | |
| 741 | has = (term%response_len > 0) |
| 742 | end function terminal_has_response |
| 743 | |
| 744 | ! Get and clear the pending response |
| 745 | subroutine terminal_get_response(term, response, length) |
| 746 | type(terminal_t), intent(inout) :: term |
| 747 | character(len=*), intent(out) :: response |
| 748 | integer, intent(out) :: length |
| 749 | |
| 750 | response = term%response |
| 751 | length = term%response_len |
| 752 | term%response = '' |
| 753 | term%response_len = 0 |
| 754 | end subroutine terminal_get_response |
| 755 | |
| 756 | ! Set window title (from OSC 0/1/2) |
| 757 | subroutine terminal_set_title(term, title) |
| 758 | type(terminal_t), intent(inout) :: term |
| 759 | character(len=*), intent(in) :: title |
| 760 | |
| 761 | term%title = title |
| 762 | term%title_changed = .true. |
| 763 | end subroutine terminal_set_title |
| 764 | |
| 765 | ! Get window title |
| 766 | function terminal_get_title(term) result(title) |
| 767 | type(terminal_t), intent(in) :: term |
| 768 | character(len=256) :: title |
| 769 | |
| 770 | title = term%title |
| 771 | end function terminal_get_title |
| 772 | |
| 773 | ! Check if title has changed (and clear the flag) |
| 774 | function terminal_has_title_changed(term) result(changed) |
| 775 | type(terminal_t), intent(inout) :: term |
| 776 | logical :: changed |
| 777 | |
| 778 | changed = term%title_changed |
| 779 | term%title_changed = .false. |
| 780 | end function terminal_has_title_changed |
| 781 | |
| 782 | end module terminal_mod |
| 783 |