Optimize input handling and cursor movement performance
- SHA
7e7fe234f3f90540f3a90fa9c9005d658ef9dc76- Parents
-
fb634e0 - Tree
afe941e
7e7fe23
7e7fe234f3f90540f3a90fa9c9005d658ef9dc76fb634e0
afe941e| Status | File | + | - |
|---|---|---|---|
| A |
CLAUDE.md
|
149 | 0 |
| M |
app/main.f90
|
27 | 20 |
| M |
src/commands/command_handler_module.f90
|
7 | 0 |
| M |
src/terminal/input_handler_module.f90
|
28 | 28 |
| M |
src/terminal/raw_mode_module.f90
|
28 | 0 |
| M |
src/terminal/renderer_module.f90
|
33 | 0 |
| M |
src/terminal/terminal_io_module.f90
|
16 | 1 |
| M |
src/terminal/termios_wrapper.c
|
119 | 10 |
CLAUDE.mdadded@@ -0,0 +1,149 @@ | ||
| 1 | +# CLAUDE.md | |
| 2 | + | |
| 3 | +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. | |
| 4 | + | |
| 5 | +## Project Overview | |
| 6 | + | |
| 7 | +Facsimile (`fac`) is a terminal text editor written in modern Fortran with VSCode-style keybindings. It uses a gap buffer for text storage and pure ANSI escape sequences for terminal rendering. | |
| 8 | + | |
| 9 | +## Build Commands | |
| 10 | + | |
| 11 | +```bash | |
| 12 | +# Standard build (recommended) | |
| 13 | +make | |
| 14 | + | |
| 15 | +# Clean and rebuild | |
| 16 | +make clean && make | |
| 17 | + | |
| 18 | +# Development build with comprehensive warnings | |
| 19 | +make dev | |
| 20 | + | |
| 21 | +# Debug build with runtime checks | |
| 22 | +make debug | |
| 23 | + | |
| 24 | +# Show compiler info | |
| 25 | +make info | |
| 26 | +``` | |
| 27 | + | |
| 28 | +The Makefile auto-detects the platform: | |
| 29 | +- **macOS arm64**: Uses gfortran-15 or flang-new from Homebrew | |
| 30 | +- **macOS Intel/Linux**: Uses standard gfortran | |
| 31 | + | |
| 32 | +## Version Management | |
| 33 | + | |
| 34 | +```bash | |
| 35 | +# Check current version | |
| 36 | +make version | |
| 37 | + | |
| 38 | +# Bump versions | |
| 39 | +make bump-patch # 0.9.1 -> 0.9.2 | |
| 40 | +make bump-minor # 0.9.1 -> 0.10.0 | |
| 41 | +make bump-major # 0.9.1 -> 1.0.0 | |
| 42 | + | |
| 43 | +# Full release build with checklist | |
| 44 | +make release | |
| 45 | +``` | |
| 46 | + | |
| 47 | +The `VERSION` file is the single source of truth. The Makefile auto-generates `src/version_module.f90`. | |
| 48 | + | |
| 49 | +## Architecture | |
| 50 | + | |
| 51 | +### Core Data Flow | |
| 52 | + | |
| 53 | +``` | |
| 54 | +Input → input_handler_module → command_handler_module → buffer operations → renderer_module → Terminal | |
| 55 | +``` | |
| 56 | + | |
| 57 | +### Key Modules | |
| 58 | + | |
| 59 | +- **`src/buffer/text_buffer_module.f90`**: Gap buffer implementation for text storage. All operations maintain gap position for efficient insertions. | |
| 60 | + | |
| 61 | +- **`src/editor_state_module.f90`**: Central state management. Contains `editor_state_t` with tabs, panes, cursors, LSP state, and UI panels. This is the "god object" that gets passed around. | |
| 62 | + | |
| 63 | +- **`src/terminal/input_handler_module.f90`**: Raw keyboard input processing. Handles escape sequences, mouse events, and key combinations. | |
| 64 | + | |
| 65 | +- **`src/terminal/renderer_module.f90`**: Screen rendering with ANSI escape sequences. Handles syntax highlighting, status bar, and split panes. | |
| 66 | + | |
| 67 | +- **`src/commands/command_handler_module.f90`**: Main command dispatch. Maps key inputs to editor actions (~7000 lines). | |
| 68 | + | |
| 69 | +- **`src/terminal/termios_wrapper.c`**: C wrapper for terminal raw mode via termios. | |
| 70 | + | |
| 71 | +### Module Dependency Order | |
| 72 | + | |
| 73 | +The Makefile's `SOURCES` list defines the required compilation order. Fortran modules must be compiled before modules that `use` them. The `.NOTPARALLEL` directive enforces sequential builds. | |
| 74 | + | |
| 75 | +### UTF-8 Handling | |
| 76 | + | |
| 77 | +All cursor positions use CHARACTER indices, not byte indices. The `utf8_module` provides conversion functions: | |
| 78 | +- `utf8_char_count()` - count characters in string | |
| 79 | +- `buffer_byte_to_char_col()` - convert byte position to character column | |
| 80 | +- `buffer_char_to_byte_col()` - convert character column to byte position | |
| 81 | + | |
| 82 | +### Pane/Tab Architecture | |
| 83 | + | |
| 84 | +``` | |
| 85 | +editor_state_t | |
| 86 | + └── tabs[] (multiple open files) | |
| 87 | + └── panes[] (split views of same file) | |
| 88 | + ├── buffer (text content) | |
| 89 | + ├── cursors[] (multiple cursor support) | |
| 90 | + └── viewport (scroll position) | |
| 91 | +``` | |
| 92 | + | |
| 93 | +### LSP Integration | |
| 94 | + | |
| 95 | +Located in `src/lsp/`. Communicates with language servers via JSON-RPC over stdio: | |
| 96 | +- `lsp_server_manager_module.f90` - Server lifecycle management | |
| 97 | +- `json_module.f90` - JSON parsing/generation | |
| 98 | +- `lsp_client_module.f90` - Request/response handling | |
| 99 | + | |
| 100 | +## Fortran-Specific Constraints | |
| 101 | + | |
| 102 | +### Line Length Limit | |
| 103 | +Fortran has a 132-character line limit. Unicode characters (like box-drawing `═`) count as multiple bytes. Long lines must be split using `&` continuation: | |
| 104 | + | |
| 105 | +```fortran | |
| 106 | +! Bad - will fail compilation | |
| 107 | +line = '═══════════════════════════════════════════════════════════════════' | |
| 108 | + | |
| 109 | +! Good - split across lines | |
| 110 | +line = '═══════════════════════' // & | |
| 111 | + '═══════════════════════' // & | |
| 112 | + '═══════════════════════' | |
| 113 | +``` | |
| 114 | + | |
| 115 | +### Module Files | |
| 116 | +Compilation generates `.mod` files in the root directory. These are binary module interfaces, not source files. | |
| 117 | + | |
| 118 | +## Distribution | |
| 119 | + | |
| 120 | +The project is distributed via three channels: | |
| 121 | +- **Homebrew**: `homebrew-facsimile` repo with `facsimile.rb` formula | |
| 122 | +- **AUR**: Arch User Repository PKGBUILD | |
| 123 | +- **RPM**: Spec file at `~/rpmbuild/SPECS/facsimile.spec` | |
| 124 | + | |
| 125 | +When releasing, update all three with the new version and SHA256 hash from the GitHub release tarball. | |
| 126 | + | |
| 127 | +## Testing | |
| 128 | + | |
| 129 | +```bash | |
| 130 | +# LSP module tests | |
| 131 | +make test-lsp | |
| 132 | + | |
| 133 | +# Test LSP with editor | |
| 134 | +make test-lsp-editor | |
| 135 | + | |
| 136 | +# Manual key testing | |
| 137 | +./keytest # Basic key codes | |
| 138 | +./keytest_fac # With fac's termios settings | |
| 139 | +``` | |
| 140 | + | |
| 141 | +## Running | |
| 142 | + | |
| 143 | +```bash | |
| 144 | +./fac [filename] # Open file | |
| 145 | +./fac --version # Show version | |
| 146 | +./fac --help # Show help | |
| 147 | +``` | |
| 148 | + | |
| 149 | +Key bindings follow VSCode conventions: Ctrl-S save, Ctrl-Q quit, Ctrl-B file tree, Ctrl-F search, Ctrl-Z undo. | |
app/main.f90modified@@ -8,7 +8,7 @@ program facsimile | ||
| 8 | 8 | use renderer_module |
| 9 | 9 | use command_handler_module, only: handle_key_command, init_command_handler, cleanup_command_handler, & |
| 10 | 10 | save_initial_state_for_undo, search_pattern, match_case_sensitive, & |
| 11 | - g_lsp_modified_buffer, g_lsp_ui_changed | |
| 11 | + g_lsp_modified_buffer, g_lsp_ui_changed, g_cursor_only_move | |
| 12 | 12 | use workspace_module |
| 13 | 13 | use backup_module |
| 14 | 14 | use save_prompt_module |
@@ -434,27 +434,30 @@ program facsimile | ||
| 434 | 434 | call handle_key_command(key_input, editor, buffer, should_quit) |
| 435 | 435 | |
| 436 | 436 | ! Sync back to active pane and other instances |
| 437 | - if (editor%active_tab_index > 0 .and. editor%active_tab_index <= size(editor%tabs)) then | |
| 438 | - if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. & | |
| 439 | - size(editor%tabs(editor%active_tab_index)%panes) > 0) then | |
| 440 | - ! Get active pane index | |
| 441 | - status = editor%tabs(editor%active_tab_index)%active_pane_index | |
| 442 | - if (status > 0 .and. status <= size(editor%tabs(editor%active_tab_index)%panes)) then | |
| 443 | - ! Copy main buffer back to active pane's buffer | |
| 444 | - call copy_buffer(editor%tabs(editor%active_tab_index)%panes(status)%buffer, buffer) | |
| 445 | - | |
| 446 | - ! Sync to all instances of this file | |
| 447 | - if (allocated(editor%tabs(editor%active_tab_index)%panes(status)%filename)) then | |
| 448 | - call sync_buffer_to_all_instances(editor, & | |
| 449 | - editor%tabs(editor%active_tab_index)%panes(status)%filename, buffer) | |
| 437 | + ! Skip buffer sync for cursor-only moves (buffer content unchanged) | |
| 438 | + if (.not. g_cursor_only_move) then | |
| 439 | + if (editor%active_tab_index > 0 .and. editor%active_tab_index <= size(editor%tabs)) then | |
| 440 | + if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. & | |
| 441 | + size(editor%tabs(editor%active_tab_index)%panes) > 0) then | |
| 442 | + ! Get active pane index | |
| 443 | + status = editor%tabs(editor%active_tab_index)%active_pane_index | |
| 444 | + if (status > 0 .and. status <= size(editor%tabs(editor%active_tab_index)%panes)) then | |
| 445 | + ! Copy main buffer back to active pane's buffer | |
| 446 | + call copy_buffer(editor%tabs(editor%active_tab_index)%panes(status)%buffer, buffer) | |
| 447 | + | |
| 448 | + ! Sync to all instances of this file | |
| 449 | + if (allocated(editor%tabs(editor%active_tab_index)%panes(status)%filename)) then | |
| 450 | + call sync_buffer_to_all_instances(editor, & | |
| 451 | + editor%tabs(editor%active_tab_index)%panes(status)%filename, buffer) | |
| 452 | + end if | |
| 450 | 453 | end if |
| 451 | - end if | |
| 452 | 454 | |
| 453 | - ! Also update tab buffer for backwards compatibility | |
| 454 | - call copy_buffer(editor%tabs(editor%active_tab_index)%buffer, buffer) | |
| 455 | + ! Also update tab buffer for backwards compatibility | |
| 456 | + call copy_buffer(editor%tabs(editor%active_tab_index)%buffer, buffer) | |
| 455 | 457 | |
| 456 | - ! Sync modified flag from buffer to tab | |
| 457 | - editor%tabs(editor%active_tab_index)%modified = buffer%modified | |
| 458 | + ! Sync modified flag from buffer to tab | |
| 459 | + editor%tabs(editor%active_tab_index)%modified = buffer%modified | |
| 460 | + end if | |
| 458 | 461 | end if |
| 459 | 462 | end if |
| 460 | 463 | |
@@ -462,7 +465,11 @@ program facsimile | ||
| 462 | 465 | running = .false. |
| 463 | 466 | else |
| 464 | 467 | ! Re-render screen after each command |
| 465 | - if (editor%fuss_mode_active) then | |
| 468 | + ! Use fast path for cursor-only movements | |
| 469 | + if (g_cursor_only_move) then | |
| 470 | + call render_cursor_only(buffer, editor, allocated(search_pattern), match_case_sensitive) | |
| 471 | + g_cursor_only_move = .false. | |
| 472 | + else if (editor%fuss_mode_active) then | |
| 466 | 473 | call render_screen_with_tree(buffer, editor, allocated(search_pattern), match_case_sensitive) |
| 467 | 474 | else |
| 468 | 475 | call render_screen(buffer, editor, allocated(search_pattern), match_case_sensitive) |
src/commands/command_handler_module.f90modified@@ -79,11 +79,14 @@ module command_handler_module | ||
| 79 | 79 | public :: search_pattern, match_case_sensitive ! Exposed for status bar hint |
| 80 | 80 | public :: g_lsp_modified_buffer ! Flag for immediate render after LSP edits |
| 81 | 81 | public :: g_lsp_ui_changed ! Flag for immediate render after LSP UI changes |
| 82 | + public :: g_cursor_only_move ! Flag for cursor-only moves (skip full re-render) | |
| 82 | 83 | |
| 83 | 84 | ! Flag to track if LSP modified the buffer (for immediate rendering) |
| 84 | 85 | logical :: g_lsp_modified_buffer = .false. |
| 85 | 86 | ! Flag to track if LSP changed UI panels (for immediate rendering) |
| 86 | 87 | logical :: g_lsp_ui_changed = .false. |
| 88 | + ! Flag for cursor-only movements (can skip full re-render) | |
| 89 | + logical :: g_cursor_only_move = .false. | |
| 87 | 90 | |
| 88 | 91 | type(yank_stack_t) :: yank_stack |
| 89 | 92 | type(undo_stack_t) :: undo_stack |
@@ -416,6 +419,7 @@ contains | ||
| 416 | 419 | end if |
| 417 | 420 | call sync_editor_to_pane(editor) |
| 418 | 421 | call update_viewport(editor) |
| 422 | + g_cursor_only_move = .true. | |
| 419 | 423 | |
| 420 | 424 | case('down') |
| 421 | 425 | ! If completion popup is visible, navigate it instead |
@@ -438,6 +442,7 @@ contains | ||
| 438 | 442 | end if |
| 439 | 443 | call sync_editor_to_pane(editor) |
| 440 | 444 | call update_viewport(editor) |
| 445 | + g_cursor_only_move = .true. | |
| 441 | 446 | |
| 442 | 447 | case('left') |
| 443 | 448 | ! Hide hover tooltip on movement |
@@ -457,6 +462,7 @@ contains | ||
| 457 | 462 | end if |
| 458 | 463 | call sync_editor_to_pane(editor) |
| 459 | 464 | call update_viewport(editor) |
| 465 | + g_cursor_only_move = .true. | |
| 460 | 466 | |
| 461 | 467 | case('right') |
| 462 | 468 | ! Hide hover tooltip on movement |
@@ -476,6 +482,7 @@ contains | ||
| 476 | 482 | end if |
| 477 | 483 | call sync_editor_to_pane(editor) |
| 478 | 484 | call update_viewport(editor) |
| 485 | + g_cursor_only_move = .true. | |
| 479 | 486 | |
| 480 | 487 | ! Selection with shift+motion |
| 481 | 488 | case('shift-up') |
src/terminal/input_handler_module.f90modified@@ -1,6 +1,6 @@ | ||
| 1 | 1 | module input_handler_module |
| 2 | 2 | use iso_fortran_env, only: input_unit, int8, error_unit |
| 3 | - use terminal_io_module, only: terminal_read_char | |
| 3 | + use terminal_io_module, only: terminal_read_char, terminal_read_char_escape | |
| 4 | 4 | implicit none |
| 5 | 5 | private |
| 6 | 6 | |
@@ -43,7 +43,7 @@ contains | ||
| 43 | 43 | key_str = '' |
| 44 | 44 | status = -1 |
| 45 | 45 | |
| 46 | - ! Read single character using raw mode function | |
| 46 | + ! Read single character using raw mode function (50ms timeout when idle) | |
| 47 | 47 | char_code = terminal_read_char() |
| 48 | 48 | |
| 49 | 49 | if (char_code < 0) then |
@@ -90,14 +90,14 @@ contains | ||
| 90 | 90 | |
| 91 | 91 | key_str = 'esc' |
| 92 | 92 | |
| 93 | - ! Try to read next character (with timeout) | |
| 94 | - char_code = terminal_read_char() | |
| 93 | + ! Try to read next character (with fast 5ms timeout for escape sequences) | |
| 94 | + char_code = terminal_read_char_escape() | |
| 95 | 95 | if (char_code < 0) return |
| 96 | 96 | ch1 = achar(char_code) |
| 97 | 97 | |
| 98 | 98 | if (ch1 == '[') then |
| 99 | 99 | ! CSI sequence (or Alt+[ if no valid sequence follows) |
| 100 | - char_code = terminal_read_char() | |
| 100 | + char_code = terminal_read_char_escape() | |
| 101 | 101 | if (char_code < 0) then |
| 102 | 102 | ! Timeout - no character follows, this is Alt+[ |
| 103 | 103 | key_str = 'alt-[' |
@@ -123,7 +123,7 @@ contains | ||
| 123 | 123 | key_str = 'shift-tab' |
| 124 | 124 | case('3') |
| 125 | 125 | ! Could be delete or Alt+Delete |
| 126 | - char_code = terminal_read_char() | |
| 126 | + char_code = terminal_read_char_escape() | |
| 127 | 127 | if (char_code >= 0) then |
| 128 | 128 | ch3 = achar(char_code) |
| 129 | 129 | if (ch3 == '~') then |
@@ -131,11 +131,11 @@ contains | ||
| 131 | 131 | else if (ch3 == ';') then |
| 132 | 132 | ! Modified delete: ESC [ 3 ; modifier ~ |
| 133 | 133 | ! Read the modifier |
| 134 | - char_code = terminal_read_char() | |
| 134 | + char_code = terminal_read_char_escape() | |
| 135 | 135 | if (char_code >= 0) then |
| 136 | 136 | modifier_ch = achar(char_code) |
| 137 | 137 | ! Read the terminating ~ |
| 138 | - char_code = terminal_read_char() | |
| 138 | + char_code = terminal_read_char_escape() | |
| 139 | 139 | if (char_code >= 0 .and. achar(char_code) == '~') then |
| 140 | 140 | ! Check modifier: 3 = Alt |
| 141 | 141 | if (modifier_ch == '3') then |
@@ -149,7 +149,7 @@ contains | ||
| 149 | 149 | end if |
| 150 | 150 | case('5') |
| 151 | 151 | ! Could be page up |
| 152 | - char_code = terminal_read_char() | |
| 152 | + char_code = terminal_read_char_escape() | |
| 153 | 153 | if (char_code >= 0) then |
| 154 | 154 | ch3 = achar(char_code) |
| 155 | 155 | ios = 0 |
@@ -164,7 +164,7 @@ contains | ||
| 164 | 164 | end if |
| 165 | 165 | case('6') |
| 166 | 166 | ! Could be page down |
| 167 | - char_code = terminal_read_char() | |
| 167 | + char_code = terminal_read_char_escape() | |
| 168 | 168 | if (char_code >= 0) then |
| 169 | 169 | ch3 = achar(char_code) |
| 170 | 170 | ios = 0 |
@@ -180,7 +180,7 @@ contains | ||
| 180 | 180 | case('1') |
| 181 | 181 | ! Could be function key (F1-F9) or modified arrow/home/end |
| 182 | 182 | ! Check next character |
| 183 | - char_code = terminal_read_char() | |
| 183 | + char_code = terminal_read_char_escape() | |
| 184 | 184 | if (char_code >= 0) then |
| 185 | 185 | ch3 = achar(char_code) |
| 186 | 186 | if (ch3 == '~') then |
@@ -188,14 +188,14 @@ contains | ||
| 188 | 188 | key_str = 'f1' |
| 189 | 189 | else if (ch3 == '0') then |
| 190 | 190 | ! F10 might be ESC [ 2 1 ~, check for tilde |
| 191 | - char_code = terminal_read_char() | |
| 191 | + char_code = terminal_read_char_escape() | |
| 192 | 192 | if (char_code >= 0 .and. achar(char_code) == '~') then |
| 193 | 193 | key_str = 'f10' |
| 194 | 194 | end if |
| 195 | 195 | else if (ch3 == '1' .or. ch3 == '2' .or. ch3 == '3' .or. ch3 == '4' .or. & |
| 196 | 196 | ch3 == '5' .or. ch3 == '7' .or. ch3 == '8' .or. ch3 == '9') then |
| 197 | 197 | ! Function keys F1-F8: ESC [ 1 X ~ or ESC [ 1 X ; modifier ~ |
| 198 | - char_code = terminal_read_char() | |
| 198 | + char_code = terminal_read_char_escape() | |
| 199 | 199 | if (char_code >= 0) then |
| 200 | 200 | ch = achar(char_code) |
| 201 | 201 | if (ch == '~') then |
@@ -230,12 +230,12 @@ contains | ||
| 230 | 230 | end if |
| 231 | 231 | case('2') |
| 232 | 232 | ! Could be F9-F12 or alternate modified keys |
| 233 | - char_code = terminal_read_char() | |
| 233 | + char_code = terminal_read_char_escape() | |
| 234 | 234 | if (char_code >= 0) then |
| 235 | 235 | ch3 = achar(char_code) |
| 236 | 236 | if (ch3 == '0' .or. ch3 == '1' .or. ch3 == '3' .or. ch3 == '4') then |
| 237 | 237 | ! Function keys F9-F12: ESC [ 2 X ~ or ESC [ 2 X ; modifier ~ |
| 238 | - char_code = terminal_read_char() | |
| 238 | + char_code = terminal_read_char_escape() | |
| 239 | 239 | if (char_code >= 0) then |
| 240 | 240 | ch = achar(char_code) |
| 241 | 241 | if (ch == '~') then |
@@ -257,7 +257,7 @@ contains | ||
| 257 | 257 | end if |
| 258 | 258 | else if (ch3 == ';') then |
| 259 | 259 | ! ESC [ 2 ; A format (shift+arrow) |
| 260 | - char_code = terminal_read_char() | |
| 260 | + char_code = terminal_read_char_escape() | |
| 261 | 261 | if (char_code >= 0) then |
| 262 | 262 | ch = achar(char_code) |
| 263 | 263 | key_str = 'shift-' |
@@ -297,7 +297,7 @@ contains | ||
| 297 | 297 | end select |
| 298 | 298 | else if (ch1 == 'O') then |
| 299 | 299 | ! SS3 sequence (e.g., function keys F1-F4) |
| 300 | - char_code = terminal_read_char() | |
| 300 | + char_code = terminal_read_char_escape() | |
| 301 | 301 | if (char_code < 0) then |
| 302 | 302 | ! Timeout - this is just Alt+O |
| 303 | 303 | key_str = 'alt-o' |
@@ -319,12 +319,12 @@ contains | ||
| 319 | 319 | end select |
| 320 | 320 | else if (ch1 == achar(27)) then |
| 321 | 321 | ! ESC ESC - likely Alt+something |
| 322 | - char_code = terminal_read_char() | |
| 322 | + char_code = terminal_read_char_escape() | |
| 323 | 323 | if (char_code >= 0) then |
| 324 | 324 | ch2 = achar(char_code) |
| 325 | 325 | if (ch2 == '[') then |
| 326 | 326 | ! ESC ESC [ - Alt+arrow keys or Alt+modified keys |
| 327 | - char_code = terminal_read_char() | |
| 327 | + char_code = terminal_read_char_escape() | |
| 328 | 328 | if (char_code >= 0) then |
| 329 | 329 | ch3 = achar(char_code) |
| 330 | 330 | select case(ch3) |
@@ -338,7 +338,7 @@ contains | ||
| 338 | 338 | key_str = 'alt-left' |
| 339 | 339 | case('3') |
| 340 | 340 | ! Could be Alt+Delete (ESC ESC [ 3 ~) |
| 341 | - char_code = terminal_read_char() | |
| 341 | + char_code = terminal_read_char_escape() | |
| 342 | 342 | if (char_code >= 0 .and. achar(char_code) == '~') then |
| 343 | 343 | key_str = 'alt-delete' |
| 344 | 344 | end if |
@@ -406,7 +406,7 @@ contains | ||
| 406 | 406 | read_count = read_count + 1 |
| 407 | 407 | if (read_count > 20) exit ! Safety limit |
| 408 | 408 | |
| 409 | - char_code = terminal_read_char() | |
| 409 | + char_code = terminal_read_char_escape() | |
| 410 | 410 | if (char_code >= 0) then |
| 411 | 411 | ch = achar(char_code) |
| 412 | 412 | ios = 0 |
@@ -506,14 +506,14 @@ contains | ||
| 506 | 506 | read(modifier_char, '(i1)') modifier |
| 507 | 507 | |
| 508 | 508 | ! Read the next character - might be the key or a semicolon |
| 509 | - char_code = terminal_read_char() | |
| 509 | + char_code = terminal_read_char_escape() | |
| 510 | 510 | if (char_code < 0) return |
| 511 | 511 | ch = achar(char_code) |
| 512 | 512 | |
| 513 | 513 | ! Check if there's a semicolon (ESC [ 2 ; A format) or direct key (ESC [ 2 A) |
| 514 | 514 | if (ch == ';') then |
| 515 | 515 | ! Read the actual key |
| 516 | - char_code = terminal_read_char() | |
| 516 | + char_code = terminal_read_char_escape() | |
| 517 | 517 | if (char_code < 0) return |
| 518 | 518 | ch = achar(char_code) |
| 519 | 519 | end if |
@@ -574,7 +574,7 @@ contains | ||
| 574 | 574 | read_count = read_count + 1 |
| 575 | 575 | if (read_count > 20) exit |
| 576 | 576 | |
| 577 | - char_code = terminal_read_char() | |
| 577 | + char_code = terminal_read_char_escape() | |
| 578 | 578 | if (char_code < 0) exit |
| 579 | 579 | |
| 580 | 580 | ch = achar(char_code) |
@@ -641,7 +641,7 @@ contains | ||
| 641 | 641 | |
| 642 | 642 | ! Read modifier sequence (already past the semicolon) |
| 643 | 643 | do |
| 644 | - char_code = terminal_read_char() | |
| 644 | + char_code = terminal_read_char_escape() | |
| 645 | 645 | if (char_code >= 0) then |
| 646 | 646 | ch = achar(char_code) |
| 647 | 647 | ios = 0 |
@@ -738,12 +738,12 @@ contains | ||
| 738 | 738 | end if |
| 739 | 739 | |
| 740 | 740 | ! Read modifier (should be a digit 2-8) |
| 741 | - char_code = terminal_read_char() | |
| 741 | + char_code = terminal_read_char_escape() | |
| 742 | 742 | if (char_code < 0) return |
| 743 | 743 | modifier_ch = achar(char_code) |
| 744 | 744 | |
| 745 | 745 | ! Read terminating ~ |
| 746 | - char_code = terminal_read_char() | |
| 746 | + char_code = terminal_read_char_escape() | |
| 747 | 747 | if (char_code < 0 .or. achar(char_code) /= '~') return |
| 748 | 748 | |
| 749 | 749 | ! Parse modifier: 2=Shift, 3=Alt, 4=Alt+Shift, 5=Ctrl, 6=Ctrl+Shift, 7=Alt+Ctrl, 8=Alt+Shift |
@@ -784,7 +784,7 @@ contains | ||
| 784 | 784 | |
| 785 | 785 | ! Read until 'M' (press) or 'm' (release) |
| 786 | 786 | do |
| 787 | - char_code = terminal_read_char() | |
| 787 | + char_code = terminal_read_char_escape() | |
| 788 | 788 | if (char_code >= 0) then |
| 789 | 789 | ch = achar(char_code) |
| 790 | 790 | ios = 0 |
src/terminal/raw_mode_module.f90modified@@ -4,6 +4,7 @@ module raw_mode_module | ||
| 4 | 4 | private |
| 5 | 5 | |
| 6 | 6 | public :: enable_raw_mode, disable_raw_mode, input_available, read_char_timeout |
| 7 | + public :: read_char_escape, input_available_count | |
| 7 | 8 | |
| 8 | 9 | ! C function interfaces |
| 9 | 10 | interface |
@@ -22,10 +23,20 @@ module raw_mode_module | ||
| 22 | 23 | integer(c_int) :: c_input_available |
| 23 | 24 | end function c_input_available |
| 24 | 25 | |
| 26 | + function c_input_available_count() bind(C, name="input_available_count") | |
| 27 | + import :: c_int | |
| 28 | + integer(c_int) :: c_input_available_count | |
| 29 | + end function c_input_available_count | |
| 30 | + | |
| 25 | 31 | function c_read_char_timeout() bind(C, name="read_char_timeout") |
| 26 | 32 | import :: c_int |
| 27 | 33 | integer(c_int) :: c_read_char_timeout |
| 28 | 34 | end function c_read_char_timeout |
| 35 | + | |
| 36 | + function c_read_char_escape() bind(C, name="read_char_escape") | |
| 37 | + import :: c_int | |
| 38 | + integer(c_int) :: c_read_char_escape | |
| 39 | + end function c_read_char_escape | |
| 29 | 40 | end interface |
| 30 | 41 | |
| 31 | 42 | contains |
@@ -54,6 +65,14 @@ contains | ||
| 54 | 65 | available = (result > 0) |
| 55 | 66 | end function input_available |
| 56 | 67 | |
| 68 | + function input_available_count() result(count) | |
| 69 | + integer :: count | |
| 70 | + integer(c_int) :: result | |
| 71 | + | |
| 72 | + result = c_input_available_count() | |
| 73 | + count = result | |
| 74 | + end function input_available_count | |
| 75 | + | |
| 57 | 76 | function read_char_timeout() result(ch) |
| 58 | 77 | integer :: ch |
| 59 | 78 | integer(c_int) :: result |
@@ -62,4 +81,13 @@ contains | ||
| 62 | 81 | ch = result |
| 63 | 82 | end function read_char_timeout |
| 64 | 83 | |
| 84 | + ! Fast read for escape sequences (5ms timeout instead of 50ms) | |
| 85 | + function read_char_escape() result(ch) | |
| 86 | + integer :: ch | |
| 87 | + integer(c_int) :: result | |
| 88 | + | |
| 89 | + result = c_read_char_escape() | |
| 90 | + ch = result | |
| 91 | + end function read_char_escape | |
| 92 | + | |
| 65 | 93 | end module raw_mode_module |
src/terminal/renderer_module.f90modified@@ -27,6 +27,7 @@ module renderer_module | ||
| 27 | 27 | public :: render_screen_with_tree, render_screen_with_lsp_panel |
| 28 | 28 | public :: tree_state |
| 29 | 29 | public :: update_syntax_highlighter |
| 30 | + public :: render_cursor_only ! Fast path for cursor-only updates | |
| 30 | 31 | |
| 31 | 32 | ! Configuration |
| 32 | 33 | logical :: show_line_numbers = .true. |
@@ -773,6 +774,38 @@ contains | ||
| 773 | 774 | end if |
| 774 | 775 | end subroutine render_cursor |
| 775 | 776 | |
| 777 | + ! Fast path for cursor-only updates - just update cursor and status bar | |
| 778 | + ! Use this when only cursor position changed, not buffer content | |
| 779 | + subroutine render_cursor_only(buffer, editor, match_mode_active, match_case_sens) | |
| 780 | + type(buffer_t), intent(in) :: buffer | |
| 781 | + type(editor_state_t), intent(inout) :: editor | |
| 782 | + logical, intent(in), optional :: match_mode_active | |
| 783 | + logical, intent(in), optional :: match_case_sens | |
| 784 | + | |
| 785 | + ! Just render the status bar and position cursor | |
| 786 | + call render_status_bar(editor, buffer, match_mode_active, match_case_sens) | |
| 787 | + | |
| 788 | + ! Handle panes vs single buffer | |
| 789 | + if (size(editor%tabs) > 0 .and. editor%active_tab_index > 0 .and. & | |
| 790 | + editor%active_tab_index <= size(editor%tabs)) then | |
| 791 | + if (allocated(editor%tabs(editor%active_tab_index)%panes)) then | |
| 792 | + if (editor%fuss_mode_active) then | |
| 793 | + call render_cursor_for_panes_with_tree(editor, 31, editor%screen_cols - 30) | |
| 794 | + else | |
| 795 | + call render_cursor_for_panes(editor) | |
| 796 | + end if | |
| 797 | + return | |
| 798 | + end if | |
| 799 | + end if | |
| 800 | + | |
| 801 | + ! Single buffer mode | |
| 802 | + if (editor%fuss_mode_active) then | |
| 803 | + call render_cursor_in_pane(editor, 31, editor%screen_cols - 30) | |
| 804 | + else | |
| 805 | + call render_cursor(editor, buffer) | |
| 806 | + end if | |
| 807 | + end subroutine render_cursor_only | |
| 808 | + | |
| 776 | 809 | subroutine update_viewport(editor) |
| 777 | 810 | use editor_state_module, only: pane_t |
| 778 | 811 | type(editor_state_t), intent(inout) :: editor |
src/terminal/terminal_io_module.f90modified@@ -4,7 +4,9 @@ module terminal_io_module | ||
| 4 | 4 | use raw_mode_module, only: raw_enable_raw_mode => enable_raw_mode, & |
| 5 | 5 | raw_disable_raw_mode => disable_raw_mode, & |
| 6 | 6 | raw_input_available => input_available, & |
| 7 | - raw_read_char_timeout => read_char_timeout | |
| 7 | + raw_read_char_timeout => read_char_timeout, & | |
| 8 | + raw_read_char_escape => read_char_escape, & | |
| 9 | + raw_input_available_count => input_available_count | |
| 8 | 10 | implicit none |
| 9 | 11 | private |
| 10 | 12 | |
@@ -13,6 +15,7 @@ module terminal_io_module | ||
| 13 | 15 | public :: terminal_get_size, terminal_enable_raw_mode, terminal_disable_raw_mode |
| 14 | 16 | public :: terminal_write, terminal_enable_mouse, terminal_disable_mouse |
| 15 | 17 | public :: terminal_input_available, terminal_read_char |
| 18 | + public :: terminal_read_char_escape, terminal_input_available_count | |
| 16 | 19 | |
| 17 | 20 | ! ANSI escape codes |
| 18 | 21 | character(len=*), parameter :: ESC = char(27) |
@@ -118,6 +121,18 @@ contains | ||
| 118 | 121 | ch = raw_read_char_timeout() |
| 119 | 122 | end function terminal_read_char |
| 120 | 123 | |
| 124 | + ! Fast read for escape sequences (5ms timeout) | |
| 125 | + function terminal_read_char_escape() result(ch) | |
| 126 | + integer :: ch | |
| 127 | + ch = raw_read_char_escape() | |
| 128 | + end function terminal_read_char_escape | |
| 129 | + | |
| 130 | + ! Get count of available input bytes | |
| 131 | + function terminal_input_available_count() result(count) | |
| 132 | + integer :: count | |
| 133 | + count = raw_input_available_count() | |
| 134 | + end function terminal_input_available_count | |
| 135 | + | |
| 121 | 136 | subroutine terminal_write(text) |
| 122 | 137 | character(len=*), intent(in) :: text |
| 123 | 138 | write(output_unit, '(a)', advance='no') text |
src/terminal/termios_wrapper.cmodified@@ -5,10 +5,18 @@ | ||
| 5 | 5 | #include <stdio.h> |
| 6 | 6 | #include <errno.h> |
| 7 | 7 | #include <sys/ioctl.h> |
| 8 | +#include <sys/select.h> | |
| 9 | +#include <string.h> | |
| 8 | 10 | |
| 9 | 11 | static struct termios orig_termios; |
| 10 | 12 | static int raw_mode_enabled = 0; |
| 11 | 13 | |
| 14 | +// Input buffer for batching reads | |
| 15 | +#define INPUT_BUFFER_SIZE 256 | |
| 16 | +static unsigned char input_buffer[INPUT_BUFFER_SIZE]; | |
| 17 | +static int buffer_start = 0; | |
| 18 | +static int buffer_end = 0; | |
| 19 | + | |
| 12 | 20 | // Enable raw mode - returns 0 on success, -1 on failure |
| 13 | 21 | int enable_raw_mode(void) { |
| 14 | 22 | if (raw_mode_enabled) return 0; |
@@ -31,15 +39,17 @@ int enable_raw_mode(void) { | ||
| 31 | 39 | // Local flags: disable canonical mode, echo, signals, extended input processing |
| 32 | 40 | raw.c_lflag &= ~(tcflag_t)(ECHO | ICANON | ISIG | IEXTEN); |
| 33 | 41 | |
| 34 | - // Control characters: minimum bytes and timeout for read() | |
| 35 | - raw.c_cc[VMIN] = 0; // Return each byte, or zero for timeout | |
| 36 | - raw.c_cc[VTIME] = 1; // 100ms timeout (unit is 1/10 second) | |
| 42 | + // Control characters: non-blocking reads | |
| 43 | + // We'll use select() for timeout management instead of VTIME | |
| 44 | + raw.c_cc[VMIN] = 0; // Don't block | |
| 45 | + raw.c_cc[VTIME] = 0; // No timeout - we use select() instead | |
| 37 | 46 | |
| 38 | 47 | if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == -1) { |
| 39 | 48 | return -1; |
| 40 | 49 | } |
| 41 | 50 | |
| 42 | 51 | raw_mode_enabled = 1; |
| 52 | + buffer_start = buffer_end = 0; | |
| 43 | 53 | return 0; |
| 44 | 54 | } |
| 45 | 55 | |
@@ -52,11 +62,18 @@ int disable_raw_mode(void) { | ||
| 52 | 62 | } |
| 53 | 63 | |
| 54 | 64 | raw_mode_enabled = 0; |
| 65 | + buffer_start = buffer_end = 0; | |
| 55 | 66 | return 0; |
| 56 | 67 | } |
| 57 | 68 | |
| 58 | 69 | // Check if input is available (non-blocking) |
| 59 | 70 | int input_available(void) { |
| 71 | + // First check our buffer | |
| 72 | + if (buffer_start < buffer_end) { | |
| 73 | + return 1; | |
| 74 | + } | |
| 75 | + | |
| 76 | + // Then check stdin | |
| 60 | 77 | int nread; |
| 61 | 78 | if (ioctl(STDIN_FILENO, FIONREAD, &nread) == -1) { |
| 62 | 79 | return 0; |
@@ -64,13 +81,105 @@ int input_available(void) { | ||
| 64 | 81 | return nread > 0; |
| 65 | 82 | } |
| 66 | 83 | |
| 67 | -// Read a single character (with timeout) | |
| 84 | +// Get count of available input bytes | |
| 85 | +int input_available_count(void) { | |
| 86 | + int buffered = buffer_end - buffer_start; | |
| 87 | + int pending = 0; | |
| 88 | + if (ioctl(STDIN_FILENO, FIONREAD, &pending) == -1) { | |
| 89 | + pending = 0; | |
| 90 | + } | |
| 91 | + return buffered + pending; | |
| 92 | +} | |
| 93 | + | |
| 94 | +// Fill the input buffer with all available data | |
| 95 | +static void fill_input_buffer(void) { | |
| 96 | + // Shift remaining data to start of buffer | |
| 97 | + if (buffer_start > 0 && buffer_start < buffer_end) { | |
| 98 | + memmove(input_buffer, input_buffer + buffer_start, buffer_end - buffer_start); | |
| 99 | + buffer_end -= buffer_start; | |
| 100 | + buffer_start = 0; | |
| 101 | + } else if (buffer_start >= buffer_end) { | |
| 102 | + buffer_start = buffer_end = 0; | |
| 103 | + } | |
| 104 | + | |
| 105 | + // Read all available data into buffer | |
| 106 | + int space = INPUT_BUFFER_SIZE - buffer_end; | |
| 107 | + if (space > 0) { | |
| 108 | + ssize_t nread = read(STDIN_FILENO, input_buffer + buffer_end, space); | |
| 109 | + if (nread > 0) { | |
| 110 | + buffer_end += nread; | |
| 111 | + } | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +// Read a single character with smart timeout | |
| 116 | +// - If data is buffered or available, return immediately | |
| 117 | +// - Otherwise wait up to timeout_ms for input | |
| 118 | +// - Use short timeout (5ms) for escape sequence continuation | |
| 119 | +// - Use longer timeout (50ms) for initial wait when idle | |
| 68 | 120 | int read_char_timeout(void) { |
| 69 | - char c; | |
| 70 | - ssize_t nread = read(STDIN_FILENO, &c, 1); | |
| 71 | - if (nread == 1) { | |
| 72 | - return (unsigned char)c; | |
| 73 | - } else { | |
| 74 | - return -1; // No input or error | |
| 121 | + // Return from buffer if available | |
| 122 | + if (buffer_start < buffer_end) { | |
| 123 | + return input_buffer[buffer_start++]; | |
| 124 | + } | |
| 125 | + | |
| 126 | + // Try to fill buffer | |
| 127 | + fill_input_buffer(); | |
| 128 | + if (buffer_start < buffer_end) { | |
| 129 | + return input_buffer[buffer_start++]; | |
| 130 | + } | |
| 131 | + | |
| 132 | + // No data available - wait with select() | |
| 133 | + // Use 50ms timeout for responsive feel without busy-waiting | |
| 134 | + fd_set readfds; | |
| 135 | + struct timeval tv; | |
| 136 | + | |
| 137 | + FD_ZERO(&readfds); | |
| 138 | + FD_SET(STDIN_FILENO, &readfds); | |
| 139 | + tv.tv_sec = 0; | |
| 140 | + tv.tv_usec = 50000; // 50ms - good balance of responsiveness and CPU usage | |
| 141 | + | |
| 142 | + int ret = select(STDIN_FILENO + 1, &readfds, NULL, NULL, &tv); | |
| 143 | + if (ret > 0) { | |
| 144 | + fill_input_buffer(); | |
| 145 | + if (buffer_start < buffer_end) { | |
| 146 | + return input_buffer[buffer_start++]; | |
| 147 | + } | |
| 148 | + } | |
| 149 | + | |
| 150 | + return -1; // No input | |
| 151 | +} | |
| 152 | + | |
| 153 | +// Read a character with very short timeout (for escape sequences) | |
| 154 | +// This is used when we've already seen ESC and are looking for the rest | |
| 155 | +int read_char_escape(void) { | |
| 156 | + // Return from buffer if available | |
| 157 | + if (buffer_start < buffer_end) { | |
| 158 | + return input_buffer[buffer_start++]; | |
| 159 | + } | |
| 160 | + | |
| 161 | + // Try immediate read first | |
| 162 | + fill_input_buffer(); | |
| 163 | + if (buffer_start < buffer_end) { | |
| 164 | + return input_buffer[buffer_start++]; | |
| 75 | 165 | } |
| 166 | + | |
| 167 | + // Short wait for escape sequence continuation (5ms) | |
| 168 | + fd_set readfds; | |
| 169 | + struct timeval tv; | |
| 170 | + | |
| 171 | + FD_ZERO(&readfds); | |
| 172 | + FD_SET(STDIN_FILENO, &readfds); | |
| 173 | + tv.tv_sec = 0; | |
| 174 | + tv.tv_usec = 5000; // 5ms - fast escape sequence detection | |
| 175 | + | |
| 176 | + int ret = select(STDIN_FILENO + 1, &readfds, NULL, NULL, &tv); | |
| 177 | + if (ret > 0) { | |
| 178 | + fill_input_buffer(); | |
| 179 | + if (buffer_start < buffer_end) { | |
| 180 | + return input_buffer[buffer_start++]; | |
| 181 | + } | |
| 182 | + } | |
| 183 | + | |
| 184 | + return -1; | |
| 76 | 185 | } |