fortrangoingonforty/facsimile / 7e7fe23

Browse files

Optimize input handling and cursor movement performance

Authored by espadonne
SHA
7e7fe234f3f90540f3a90fa9c9005d658ef9dc76
Parents
fb634e0
Tree
afe941e

8 changed files

StatusFile+-
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
     use renderer_module
8
     use renderer_module
9
     use command_handler_module, only: handle_key_command, init_command_handler, cleanup_command_handler, &
9
     use command_handler_module, only: handle_key_command, init_command_handler, cleanup_command_handler, &
10
                                       save_initial_state_for_undo, search_pattern, match_case_sensitive, &
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
     use workspace_module
12
     use workspace_module
13
     use backup_module
13
     use backup_module
14
     use save_prompt_module
14
     use save_prompt_module
@@ -434,27 +434,30 @@ program facsimile
434
             call handle_key_command(key_input, editor, buffer, should_quit)
434
             call handle_key_command(key_input, editor, buffer, should_quit)
435
 
435
 
436
             ! Sync back to active pane and other instances
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
437
+            ! Skip buffer sync for cursor-only moves (buffer content unchanged)
438
-                if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. &
438
+            if (.not. g_cursor_only_move) then
439
-                    size(editor%tabs(editor%active_tab_index)%panes) > 0) then
439
+                if (editor%active_tab_index > 0 .and. editor%active_tab_index <= size(editor%tabs)) then
440
-                    ! Get active pane index
440
+                    if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. &
441
-                    status = editor%tabs(editor%active_tab_index)%active_pane_index
441
+                        size(editor%tabs(editor%active_tab_index)%panes) > 0) then
442
-                    if (status > 0 .and. status <= size(editor%tabs(editor%active_tab_index)%panes)) then
442
+                        ! Get active pane index
443
-                        ! Copy main buffer back to active pane's buffer
443
+                        status = editor%tabs(editor%active_tab_index)%active_pane_index
444
-                        call copy_buffer(editor%tabs(editor%active_tab_index)%panes(status)%buffer, buffer)
444
+                        if (status > 0 .and. status <= size(editor%tabs(editor%active_tab_index)%panes)) then
445
-
445
+                            ! Copy main buffer back to active pane's buffer
446
-                        ! Sync to all instances of this file
446
+                            call copy_buffer(editor%tabs(editor%active_tab_index)%panes(status)%buffer, buffer)
447
-                        if (allocated(editor%tabs(editor%active_tab_index)%panes(status)%filename)) then
447
+
448
-                            call sync_buffer_to_all_instances(editor, &
448
+                            ! Sync to all instances of this file
449
-                                editor%tabs(editor%active_tab_index)%panes(status)%filename, buffer)
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
                         end if
453
                         end if
451
-                    end if
452
 
454
 
453
-                    ! Also update tab buffer for backwards compatibility
455
+                        ! Also update tab buffer for backwards compatibility
454
-                    call copy_buffer(editor%tabs(editor%active_tab_index)%buffer, buffer)
456
+                        call copy_buffer(editor%tabs(editor%active_tab_index)%buffer, buffer)
455
 
457
 
456
-                    ! Sync modified flag from buffer to tab
458
+                        ! Sync modified flag from buffer to tab
457
-                    editor%tabs(editor%active_tab_index)%modified = buffer%modified
459
+                        editor%tabs(editor%active_tab_index)%modified = buffer%modified
460
+                    end if
458
                 end if
461
                 end if
459
             end if
462
             end if
460
 
463
 
@@ -462,7 +465,11 @@ program facsimile
462
                 running = .false.
465
                 running = .false.
463
             else
466
             else
464
                 ! Re-render screen after each command
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
                     call render_screen_with_tree(buffer, editor, allocated(search_pattern), match_case_sensitive)
473
                     call render_screen_with_tree(buffer, editor, allocated(search_pattern), match_case_sensitive)
467
                 else
474
                 else
468
                     call render_screen(buffer, editor, allocated(search_pattern), match_case_sensitive)
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
     public :: search_pattern, match_case_sensitive  ! Exposed for status bar hint
79
     public :: search_pattern, match_case_sensitive  ! Exposed for status bar hint
80
     public :: g_lsp_modified_buffer  ! Flag for immediate render after LSP edits
80
     public :: g_lsp_modified_buffer  ! Flag for immediate render after LSP edits
81
     public :: g_lsp_ui_changed       ! Flag for immediate render after LSP UI changes
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
     ! Flag to track if LSP modified the buffer (for immediate rendering)
84
     ! Flag to track if LSP modified the buffer (for immediate rendering)
84
     logical :: g_lsp_modified_buffer = .false.
85
     logical :: g_lsp_modified_buffer = .false.
85
     ! Flag to track if LSP changed UI panels (for immediate rendering)
86
     ! Flag to track if LSP changed UI panels (for immediate rendering)
86
     logical :: g_lsp_ui_changed = .false.
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
     type(yank_stack_t) :: yank_stack
91
     type(yank_stack_t) :: yank_stack
89
     type(undo_stack_t) :: undo_stack
92
     type(undo_stack_t) :: undo_stack
@@ -416,6 +419,7 @@ contains
416
             end if
419
             end if
417
             call sync_editor_to_pane(editor)
420
             call sync_editor_to_pane(editor)
418
             call update_viewport(editor)
421
             call update_viewport(editor)
422
+            g_cursor_only_move = .true.
419
 
423
 
420
         case('down')
424
         case('down')
421
             ! If completion popup is visible, navigate it instead
425
             ! If completion popup is visible, navigate it instead
@@ -438,6 +442,7 @@ contains
438
             end if
442
             end if
439
             call sync_editor_to_pane(editor)
443
             call sync_editor_to_pane(editor)
440
             call update_viewport(editor)
444
             call update_viewport(editor)
445
+            g_cursor_only_move = .true.
441
 
446
 
442
         case('left')
447
         case('left')
443
             ! Hide hover tooltip on movement
448
             ! Hide hover tooltip on movement
@@ -457,6 +462,7 @@ contains
457
             end if
462
             end if
458
             call sync_editor_to_pane(editor)
463
             call sync_editor_to_pane(editor)
459
             call update_viewport(editor)
464
             call update_viewport(editor)
465
+            g_cursor_only_move = .true.
460
 
466
 
461
         case('right')
467
         case('right')
462
             ! Hide hover tooltip on movement
468
             ! Hide hover tooltip on movement
@@ -476,6 +482,7 @@ contains
476
             end if
482
             end if
477
             call sync_editor_to_pane(editor)
483
             call sync_editor_to_pane(editor)
478
             call update_viewport(editor)
484
             call update_viewport(editor)
485
+            g_cursor_only_move = .true.
479
 
486
 
480
         ! Selection with shift+motion
487
         ! Selection with shift+motion
481
         case('shift-up')
488
         case('shift-up')
src/terminal/input_handler_module.f90modified
@@ -1,6 +1,6 @@
1
 module input_handler_module
1
 module input_handler_module
2
     use iso_fortran_env, only: input_unit, int8, error_unit
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
     implicit none
4
     implicit none
5
     private
5
     private
6
 
6
 
@@ -43,7 +43,7 @@ contains
43
         key_str = ''
43
         key_str = ''
44
         status = -1
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
         char_code = terminal_read_char()
47
         char_code = terminal_read_char()
48
 
48
 
49
         if (char_code < 0) then
49
         if (char_code < 0) then
@@ -90,14 +90,14 @@ contains
90
 
90
 
91
         key_str = 'esc'
91
         key_str = 'esc'
92
 
92
 
93
-        ! Try to read next character (with timeout)
93
+        ! Try to read next character (with fast 5ms timeout for escape sequences)
94
-        char_code = terminal_read_char()
94
+        char_code = terminal_read_char_escape()
95
         if (char_code < 0) return
95
         if (char_code < 0) return
96
         ch1 = achar(char_code)
96
         ch1 = achar(char_code)
97
 
97
 
98
         if (ch1 == '[') then
98
         if (ch1 == '[') then
99
             ! CSI sequence (or Alt+[ if no valid sequence follows)
99
             ! CSI sequence (or Alt+[ if no valid sequence follows)
100
-            char_code = terminal_read_char()
100
+            char_code = terminal_read_char_escape()
101
             if (char_code < 0) then
101
             if (char_code < 0) then
102
                 ! Timeout - no character follows, this is Alt+[
102
                 ! Timeout - no character follows, this is Alt+[
103
                 key_str = 'alt-['
103
                 key_str = 'alt-['
@@ -123,7 +123,7 @@ contains
123
                 key_str = 'shift-tab'
123
                 key_str = 'shift-tab'
124
             case('3')
124
             case('3')
125
                 ! Could be delete or Alt+Delete
125
                 ! Could be delete or Alt+Delete
126
-                char_code = terminal_read_char()
126
+                char_code = terminal_read_char_escape()
127
                 if (char_code >= 0) then
127
                 if (char_code >= 0) then
128
                     ch3 = achar(char_code)
128
                     ch3 = achar(char_code)
129
                     if (ch3 == '~') then
129
                     if (ch3 == '~') then
@@ -131,11 +131,11 @@ contains
131
                     else if (ch3 == ';') then
131
                     else if (ch3 == ';') then
132
                         ! Modified delete: ESC [ 3 ; modifier ~
132
                         ! Modified delete: ESC [ 3 ; modifier ~
133
                         ! Read the modifier
133
                         ! Read the modifier
134
-                        char_code = terminal_read_char()
134
+                        char_code = terminal_read_char_escape()
135
                         if (char_code >= 0) then
135
                         if (char_code >= 0) then
136
                             modifier_ch = achar(char_code)
136
                             modifier_ch = achar(char_code)
137
                             ! Read the terminating ~
137
                             ! Read the terminating ~
138
-                            char_code = terminal_read_char()
138
+                            char_code = terminal_read_char_escape()
139
                             if (char_code >= 0 .and. achar(char_code) == '~') then
139
                             if (char_code >= 0 .and. achar(char_code) == '~') then
140
                                 ! Check modifier: 3 = Alt
140
                                 ! Check modifier: 3 = Alt
141
                                 if (modifier_ch == '3') then
141
                                 if (modifier_ch == '3') then
@@ -149,7 +149,7 @@ contains
149
                 end if
149
                 end if
150
             case('5')
150
             case('5')
151
                 ! Could be page up
151
                 ! Could be page up
152
-                char_code = terminal_read_char()
152
+                char_code = terminal_read_char_escape()
153
                 if (char_code >= 0) then
153
                 if (char_code >= 0) then
154
                     ch3 = achar(char_code)
154
                     ch3 = achar(char_code)
155
                     ios = 0
155
                     ios = 0
@@ -164,7 +164,7 @@ contains
164
                 end if
164
                 end if
165
             case('6')
165
             case('6')
166
                 ! Could be page down
166
                 ! Could be page down
167
-                char_code = terminal_read_char()
167
+                char_code = terminal_read_char_escape()
168
                 if (char_code >= 0) then
168
                 if (char_code >= 0) then
169
                     ch3 = achar(char_code)
169
                     ch3 = achar(char_code)
170
                     ios = 0
170
                     ios = 0
@@ -180,7 +180,7 @@ contains
180
             case('1')
180
             case('1')
181
                 ! Could be function key (F1-F9) or modified arrow/home/end
181
                 ! Could be function key (F1-F9) or modified arrow/home/end
182
                 ! Check next character
182
                 ! Check next character
183
-                char_code = terminal_read_char()
183
+                char_code = terminal_read_char_escape()
184
                 if (char_code >= 0) then
184
                 if (char_code >= 0) then
185
                     ch3 = achar(char_code)
185
                     ch3 = achar(char_code)
186
                     if (ch3 == '~') then
186
                     if (ch3 == '~') then
@@ -188,14 +188,14 @@ contains
188
                         key_str = 'f1'
188
                         key_str = 'f1'
189
                     else if (ch3 == '0') then
189
                     else if (ch3 == '0') then
190
                         ! F10 might be ESC [ 2 1 ~, check for tilde
190
                         ! F10 might be ESC [ 2 1 ~, check for tilde
191
-                        char_code = terminal_read_char()
191
+                        char_code = terminal_read_char_escape()
192
                         if (char_code >= 0 .and. achar(char_code) == '~') then
192
                         if (char_code >= 0 .and. achar(char_code) == '~') then
193
                             key_str = 'f10'
193
                             key_str = 'f10'
194
                         end if
194
                         end if
195
                     else if (ch3 == '1' .or. ch3 == '2' .or. ch3 == '3' .or. ch3 == '4' .or. &
195
                     else if (ch3 == '1' .or. ch3 == '2' .or. ch3 == '3' .or. ch3 == '4' .or. &
196
                              ch3 == '5' .or. ch3 == '7' .or. ch3 == '8' .or. ch3 == '9') then
196
                              ch3 == '5' .or. ch3 == '7' .or. ch3 == '8' .or. ch3 == '9') then
197
                         ! Function keys F1-F8: ESC [ 1 X ~ or ESC [ 1 X ; modifier ~
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
                         if (char_code >= 0) then
199
                         if (char_code >= 0) then
200
                             ch = achar(char_code)
200
                             ch = achar(char_code)
201
                             if (ch == '~') then
201
                             if (ch == '~') then
@@ -230,12 +230,12 @@ contains
230
                 end if
230
                 end if
231
             case('2')
231
             case('2')
232
                 ! Could be F9-F12 or alternate modified keys
232
                 ! Could be F9-F12 or alternate modified keys
233
-                char_code = terminal_read_char()
233
+                char_code = terminal_read_char_escape()
234
                 if (char_code >= 0) then
234
                 if (char_code >= 0) then
235
                     ch3 = achar(char_code)
235
                     ch3 = achar(char_code)
236
                     if (ch3 == '0' .or. ch3 == '1' .or. ch3 == '3' .or. ch3 == '4') then
236
                     if (ch3 == '0' .or. ch3 == '1' .or. ch3 == '3' .or. ch3 == '4') then
237
                         ! Function keys F9-F12: ESC [ 2 X ~ or ESC [ 2 X ; modifier ~
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
                         if (char_code >= 0) then
239
                         if (char_code >= 0) then
240
                             ch = achar(char_code)
240
                             ch = achar(char_code)
241
                             if (ch == '~') then
241
                             if (ch == '~') then
@@ -257,7 +257,7 @@ contains
257
                         end if
257
                         end if
258
                     else if (ch3 == ';') then
258
                     else if (ch3 == ';') then
259
                         ! ESC [ 2 ; A format (shift+arrow)
259
                         ! ESC [ 2 ; A format (shift+arrow)
260
-                        char_code = terminal_read_char()
260
+                        char_code = terminal_read_char_escape()
261
                         if (char_code >= 0) then
261
                         if (char_code >= 0) then
262
                             ch = achar(char_code)
262
                             ch = achar(char_code)
263
                             key_str = 'shift-'
263
                             key_str = 'shift-'
@@ -297,7 +297,7 @@ contains
297
             end select
297
             end select
298
         else if (ch1 == 'O') then
298
         else if (ch1 == 'O') then
299
             ! SS3 sequence (e.g., function keys F1-F4)
299
             ! SS3 sequence (e.g., function keys F1-F4)
300
-            char_code = terminal_read_char()
300
+            char_code = terminal_read_char_escape()
301
             if (char_code < 0) then
301
             if (char_code < 0) then
302
                 ! Timeout - this is just Alt+O
302
                 ! Timeout - this is just Alt+O
303
                 key_str = 'alt-o'
303
                 key_str = 'alt-o'
@@ -319,12 +319,12 @@ contains
319
             end select
319
             end select
320
         else if (ch1 == achar(27)) then
320
         else if (ch1 == achar(27)) then
321
             ! ESC ESC - likely Alt+something
321
             ! ESC ESC - likely Alt+something
322
-            char_code = terminal_read_char()
322
+            char_code = terminal_read_char_escape()
323
             if (char_code >= 0) then
323
             if (char_code >= 0) then
324
                 ch2 = achar(char_code)
324
                 ch2 = achar(char_code)
325
                 if (ch2 == '[') then
325
                 if (ch2 == '[') then
326
                     ! ESC ESC [ - Alt+arrow keys or Alt+modified keys
326
                     ! ESC ESC [ - Alt+arrow keys or Alt+modified keys
327
-                    char_code = terminal_read_char()
327
+                    char_code = terminal_read_char_escape()
328
                     if (char_code >= 0) then
328
                     if (char_code >= 0) then
329
                         ch3 = achar(char_code)
329
                         ch3 = achar(char_code)
330
                         select case(ch3)
330
                         select case(ch3)
@@ -338,7 +338,7 @@ contains
338
                             key_str = 'alt-left'
338
                             key_str = 'alt-left'
339
                         case('3')
339
                         case('3')
340
                             ! Could be Alt+Delete (ESC ESC [ 3 ~)
340
                             ! Could be Alt+Delete (ESC ESC [ 3 ~)
341
-                            char_code = terminal_read_char()
341
+                            char_code = terminal_read_char_escape()
342
                             if (char_code >= 0 .and. achar(char_code) == '~') then
342
                             if (char_code >= 0 .and. achar(char_code) == '~') then
343
                                 key_str = 'alt-delete'
343
                                 key_str = 'alt-delete'
344
                             end if
344
                             end if
@@ -406,7 +406,7 @@ contains
406
             read_count = read_count + 1
406
             read_count = read_count + 1
407
             if (read_count > 20) exit  ! Safety limit
407
             if (read_count > 20) exit  ! Safety limit
408
 
408
 
409
-            char_code = terminal_read_char()
409
+            char_code = terminal_read_char_escape()
410
             if (char_code >= 0) then
410
             if (char_code >= 0) then
411
                 ch = achar(char_code)
411
                 ch = achar(char_code)
412
                 ios = 0
412
                 ios = 0
@@ -506,14 +506,14 @@ contains
506
         read(modifier_char, '(i1)') modifier
506
         read(modifier_char, '(i1)') modifier
507
 
507
 
508
         ! Read the next character - might be the key or a semicolon
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
         if (char_code < 0) return
510
         if (char_code < 0) return
511
         ch = achar(char_code)
511
         ch = achar(char_code)
512
 
512
 
513
         ! Check if there's a semicolon (ESC [ 2 ; A format) or direct key (ESC [ 2 A)
513
         ! Check if there's a semicolon (ESC [ 2 ; A format) or direct key (ESC [ 2 A)
514
         if (ch == ';') then
514
         if (ch == ';') then
515
             ! Read the actual key
515
             ! Read the actual key
516
-            char_code = terminal_read_char()
516
+            char_code = terminal_read_char_escape()
517
             if (char_code < 0) return
517
             if (char_code < 0) return
518
             ch = achar(char_code)
518
             ch = achar(char_code)
519
         end if
519
         end if
@@ -574,7 +574,7 @@ contains
574
             read_count = read_count + 1
574
             read_count = read_count + 1
575
             if (read_count > 20) exit
575
             if (read_count > 20) exit
576
 
576
 
577
-            char_code = terminal_read_char()
577
+            char_code = terminal_read_char_escape()
578
             if (char_code < 0) exit
578
             if (char_code < 0) exit
579
 
579
 
580
             ch = achar(char_code)
580
             ch = achar(char_code)
@@ -641,7 +641,7 @@ contains
641
 
641
 
642
         ! Read modifier sequence (already past the semicolon)
642
         ! Read modifier sequence (already past the semicolon)
643
         do
643
         do
644
-            char_code = terminal_read_char()
644
+            char_code = terminal_read_char_escape()
645
             if (char_code >= 0) then
645
             if (char_code >= 0) then
646
                 ch = achar(char_code)
646
                 ch = achar(char_code)
647
                 ios = 0
647
                 ios = 0
@@ -738,12 +738,12 @@ contains
738
         end if
738
         end if
739
 
739
 
740
         ! Read modifier (should be a digit 2-8)
740
         ! Read modifier (should be a digit 2-8)
741
-        char_code = terminal_read_char()
741
+        char_code = terminal_read_char_escape()
742
         if (char_code < 0) return
742
         if (char_code < 0) return
743
         modifier_ch = achar(char_code)
743
         modifier_ch = achar(char_code)
744
 
744
 
745
         ! Read terminating ~
745
         ! Read terminating ~
746
-        char_code = terminal_read_char()
746
+        char_code = terminal_read_char_escape()
747
         if (char_code < 0 .or. achar(char_code) /= '~') return
747
         if (char_code < 0 .or. achar(char_code) /= '~') return
748
 
748
 
749
         ! Parse modifier: 2=Shift, 3=Alt, 4=Alt+Shift, 5=Ctrl, 6=Ctrl+Shift, 7=Alt+Ctrl, 8=Alt+Shift
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
         ! Read until 'M' (press) or 'm' (release)
785
         ! Read until 'M' (press) or 'm' (release)
786
         do
786
         do
787
-            char_code = terminal_read_char()
787
+            char_code = terminal_read_char_escape()
788
             if (char_code >= 0) then
788
             if (char_code >= 0) then
789
                 ch = achar(char_code)
789
                 ch = achar(char_code)
790
                 ios = 0
790
                 ios = 0
src/terminal/raw_mode_module.f90modified
@@ -4,6 +4,7 @@ module raw_mode_module
4
     private
4
     private
5
 
5
 
6
     public :: enable_raw_mode, disable_raw_mode, input_available, read_char_timeout
6
     public :: enable_raw_mode, disable_raw_mode, input_available, read_char_timeout
7
+    public :: read_char_escape, input_available_count
7
 
8
 
8
     ! C function interfaces
9
     ! C function interfaces
9
     interface
10
     interface
@@ -22,10 +23,20 @@ module raw_mode_module
22
             integer(c_int) :: c_input_available
23
             integer(c_int) :: c_input_available
23
         end function c_input_available
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
         function c_read_char_timeout() bind(C, name="read_char_timeout")
31
         function c_read_char_timeout() bind(C, name="read_char_timeout")
26
             import :: c_int
32
             import :: c_int
27
             integer(c_int) :: c_read_char_timeout
33
             integer(c_int) :: c_read_char_timeout
28
         end function c_read_char_timeout
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
     end interface
40
     end interface
30
 
41
 
31
 contains
42
 contains
@@ -54,6 +65,14 @@ contains
54
         available = (result > 0)
65
         available = (result > 0)
55
     end function input_available
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
     function read_char_timeout() result(ch)
76
     function read_char_timeout() result(ch)
58
         integer :: ch
77
         integer :: ch
59
         integer(c_int) :: result
78
         integer(c_int) :: result
@@ -62,4 +81,13 @@ contains
62
         ch = result
81
         ch = result
63
     end function read_char_timeout
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
 end module raw_mode_module
93
 end module raw_mode_module
src/terminal/renderer_module.f90modified
@@ -27,6 +27,7 @@ module renderer_module
27
     public :: render_screen_with_tree, render_screen_with_lsp_panel
27
     public :: render_screen_with_tree, render_screen_with_lsp_panel
28
     public :: tree_state
28
     public :: tree_state
29
     public :: update_syntax_highlighter
29
     public :: update_syntax_highlighter
30
+    public :: render_cursor_only  ! Fast path for cursor-only updates
30
 
31
 
31
     ! Configuration
32
     ! Configuration
32
     logical :: show_line_numbers = .true.
33
     logical :: show_line_numbers = .true.
@@ -773,6 +774,38 @@ contains
773
         end if
774
         end if
774
     end subroutine render_cursor
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
     subroutine update_viewport(editor)
809
     subroutine update_viewport(editor)
777
         use editor_state_module, only: pane_t
810
         use editor_state_module, only: pane_t
778
         type(editor_state_t), intent(inout) :: editor
811
         type(editor_state_t), intent(inout) :: editor
src/terminal/terminal_io_module.f90modified
@@ -4,7 +4,9 @@ module terminal_io_module
4
     use raw_mode_module, only: raw_enable_raw_mode => enable_raw_mode, &
4
     use raw_mode_module, only: raw_enable_raw_mode => enable_raw_mode, &
5
                                raw_disable_raw_mode => disable_raw_mode, &
5
                                raw_disable_raw_mode => disable_raw_mode, &
6
                                raw_input_available => input_available, &
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
     implicit none
10
     implicit none
9
     private
11
     private
10
 
12
 
@@ -13,6 +15,7 @@ module terminal_io_module
13
     public :: terminal_get_size, terminal_enable_raw_mode, terminal_disable_raw_mode
15
     public :: terminal_get_size, terminal_enable_raw_mode, terminal_disable_raw_mode
14
     public :: terminal_write, terminal_enable_mouse, terminal_disable_mouse
16
     public :: terminal_write, terminal_enable_mouse, terminal_disable_mouse
15
     public :: terminal_input_available, terminal_read_char
17
     public :: terminal_input_available, terminal_read_char
18
+    public :: terminal_read_char_escape, terminal_input_available_count
16
 
19
 
17
     ! ANSI escape codes
20
     ! ANSI escape codes
18
     character(len=*), parameter :: ESC = char(27)
21
     character(len=*), parameter :: ESC = char(27)
@@ -118,6 +121,18 @@ contains
118
         ch = raw_read_char_timeout()
121
         ch = raw_read_char_timeout()
119
     end function terminal_read_char
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
     subroutine terminal_write(text)
136
     subroutine terminal_write(text)
122
         character(len=*), intent(in) :: text
137
         character(len=*), intent(in) :: text
123
         write(output_unit, '(a)', advance='no') text
138
         write(output_unit, '(a)', advance='no') text
src/terminal/termios_wrapper.cmodified
@@ -5,10 +5,18 @@
5
 #include <stdio.h>
5
 #include <stdio.h>
6
 #include <errno.h>
6
 #include <errno.h>
7
 #include <sys/ioctl.h>
7
 #include <sys/ioctl.h>
8
+#include <sys/select.h>
9
+#include <string.h>
8
 
10
 
9
 static struct termios orig_termios;
11
 static struct termios orig_termios;
10
 static int raw_mode_enabled = 0;
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
 // Enable raw mode - returns 0 on success, -1 on failure
20
 // Enable raw mode - returns 0 on success, -1 on failure
13
 int enable_raw_mode(void) {
21
 int enable_raw_mode(void) {
14
     if (raw_mode_enabled) return 0;
22
     if (raw_mode_enabled) return 0;
@@ -31,15 +39,17 @@ int enable_raw_mode(void) {
31
     // Local flags: disable canonical mode, echo, signals, extended input processing
39
     // Local flags: disable canonical mode, echo, signals, extended input processing
32
     raw.c_lflag &= ~(tcflag_t)(ECHO | ICANON | ISIG | IEXTEN);
40
     raw.c_lflag &= ~(tcflag_t)(ECHO | ICANON | ISIG | IEXTEN);
33
 
41
 
34
-    // Control characters: minimum bytes and timeout for read()
42
+    // Control characters: non-blocking reads
35
-    raw.c_cc[VMIN] = 0;  // Return each byte, or zero for timeout
43
+    // We'll use select() for timeout management instead of VTIME
36
-    raw.c_cc[VTIME] = 1; // 100ms timeout (unit is 1/10 second)
44
+    raw.c_cc[VMIN] = 0;   // Don't block
45
+    raw.c_cc[VTIME] = 0;  // No timeout - we use select() instead
37
 
46
 
38
     if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == -1) {
47
     if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == -1) {
39
         return -1;
48
         return -1;
40
     }
49
     }
41
 
50
 
42
     raw_mode_enabled = 1;
51
     raw_mode_enabled = 1;
52
+    buffer_start = buffer_end = 0;
43
     return 0;
53
     return 0;
44
 }
54
 }
45
 
55
 
@@ -52,11 +62,18 @@ int disable_raw_mode(void) {
52
     }
62
     }
53
 
63
 
54
     raw_mode_enabled = 0;
64
     raw_mode_enabled = 0;
65
+    buffer_start = buffer_end = 0;
55
     return 0;
66
     return 0;
56
 }
67
 }
57
 
68
 
58
 // Check if input is available (non-blocking)
69
 // Check if input is available (non-blocking)
59
 int input_available(void) {
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
     int nread;
77
     int nread;
61
     if (ioctl(STDIN_FILENO, FIONREAD, &nread) == -1) {
78
     if (ioctl(STDIN_FILENO, FIONREAD, &nread) == -1) {
62
         return 0;
79
         return 0;
@@ -64,13 +81,105 @@ int input_available(void) {
64
     return nread > 0;
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
 int read_char_timeout(void) {
120
 int read_char_timeout(void) {
69
-    char c;
121
+    // Return from buffer if available
70
-    ssize_t nread = read(STDIN_FILENO, &c, 1);
122
+    if (buffer_start < buffer_end) {
71
-    if (nread == 1) {
123
+        return input_buffer[buffer_start++];
72
-        return (unsigned char)c;
124
+    }
73
-    } else {
125
+
74
-        return -1; // No input or error
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
 }