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
88
     use renderer_module
99
     use command_handler_module, only: handle_key_command, init_command_handler, cleanup_command_handler, &
1010
                                       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
1212
     use workspace_module
1313
     use backup_module
1414
     use save_prompt_module
@@ -434,27 +434,30 @@ program facsimile
434434
             call handle_key_command(key_input, editor, buffer, should_quit)
435435
 
436436
             ! 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
450453
                         end if
451
-                    end if
452454
 
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)
455457
 
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
458461
                 end if
459462
             end if
460463
 
@@ -462,7 +465,11 @@ program facsimile
462465
                 running = .false.
463466
             else
464467
                 ! 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
466473
                     call render_screen_with_tree(buffer, editor, allocated(search_pattern), match_case_sensitive)
467474
                 else
468475
                     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
7979
     public :: search_pattern, match_case_sensitive  ! Exposed for status bar hint
8080
     public :: g_lsp_modified_buffer  ! Flag for immediate render after LSP edits
8181
     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)
8283
 
8384
     ! Flag to track if LSP modified the buffer (for immediate rendering)
8485
     logical :: g_lsp_modified_buffer = .false.
8586
     ! Flag to track if LSP changed UI panels (for immediate rendering)
8687
     logical :: g_lsp_ui_changed = .false.
88
+    ! Flag for cursor-only movements (can skip full re-render)
89
+    logical :: g_cursor_only_move = .false.
8790
 
8891
     type(yank_stack_t) :: yank_stack
8992
     type(undo_stack_t) :: undo_stack
@@ -416,6 +419,7 @@ contains
416419
             end if
417420
             call sync_editor_to_pane(editor)
418421
             call update_viewport(editor)
422
+            g_cursor_only_move = .true.
419423
 
420424
         case('down')
421425
             ! If completion popup is visible, navigate it instead
@@ -438,6 +442,7 @@ contains
438442
             end if
439443
             call sync_editor_to_pane(editor)
440444
             call update_viewport(editor)
445
+            g_cursor_only_move = .true.
441446
 
442447
         case('left')
443448
             ! Hide hover tooltip on movement
@@ -457,6 +462,7 @@ contains
457462
             end if
458463
             call sync_editor_to_pane(editor)
459464
             call update_viewport(editor)
465
+            g_cursor_only_move = .true.
460466
 
461467
         case('right')
462468
             ! Hide hover tooltip on movement
@@ -476,6 +482,7 @@ contains
476482
             end if
477483
             call sync_editor_to_pane(editor)
478484
             call update_viewport(editor)
485
+            g_cursor_only_move = .true.
479486
 
480487
         ! Selection with shift+motion
481488
         case('shift-up')
src/terminal/input_handler_module.f90modified
@@ -1,6 +1,6 @@
11
 module input_handler_module
22
     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
44
     implicit none
55
     private
66
 
@@ -43,7 +43,7 @@ contains
4343
         key_str = ''
4444
         status = -1
4545
 
46
-        ! Read single character using raw mode function
46
+        ! Read single character using raw mode function (50ms timeout when idle)
4747
         char_code = terminal_read_char()
4848
 
4949
         if (char_code < 0) then
@@ -90,14 +90,14 @@ contains
9090
 
9191
         key_str = 'esc'
9292
 
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()
9595
         if (char_code < 0) return
9696
         ch1 = achar(char_code)
9797
 
9898
         if (ch1 == '[') then
9999
             ! CSI sequence (or Alt+[ if no valid sequence follows)
100
-            char_code = terminal_read_char()
100
+            char_code = terminal_read_char_escape()
101101
             if (char_code < 0) then
102102
                 ! Timeout - no character follows, this is Alt+[
103103
                 key_str = 'alt-['
@@ -123,7 +123,7 @@ contains
123123
                 key_str = 'shift-tab'
124124
             case('3')
125125
                 ! Could be delete or Alt+Delete
126
-                char_code = terminal_read_char()
126
+                char_code = terminal_read_char_escape()
127127
                 if (char_code >= 0) then
128128
                     ch3 = achar(char_code)
129129
                     if (ch3 == '~') then
@@ -131,11 +131,11 @@ contains
131131
                     else if (ch3 == ';') then
132132
                         ! Modified delete: ESC [ 3 ; modifier ~
133133
                         ! Read the modifier
134
-                        char_code = terminal_read_char()
134
+                        char_code = terminal_read_char_escape()
135135
                         if (char_code >= 0) then
136136
                             modifier_ch = achar(char_code)
137137
                             ! Read the terminating ~
138
-                            char_code = terminal_read_char()
138
+                            char_code = terminal_read_char_escape()
139139
                             if (char_code >= 0 .and. achar(char_code) == '~') then
140140
                                 ! Check modifier: 3 = Alt
141141
                                 if (modifier_ch == '3') then
@@ -149,7 +149,7 @@ contains
149149
                 end if
150150
             case('5')
151151
                 ! Could be page up
152
-                char_code = terminal_read_char()
152
+                char_code = terminal_read_char_escape()
153153
                 if (char_code >= 0) then
154154
                     ch3 = achar(char_code)
155155
                     ios = 0
@@ -164,7 +164,7 @@ contains
164164
                 end if
165165
             case('6')
166166
                 ! Could be page down
167
-                char_code = terminal_read_char()
167
+                char_code = terminal_read_char_escape()
168168
                 if (char_code >= 0) then
169169
                     ch3 = achar(char_code)
170170
                     ios = 0
@@ -180,7 +180,7 @@ contains
180180
             case('1')
181181
                 ! Could be function key (F1-F9) or modified arrow/home/end
182182
                 ! Check next character
183
-                char_code = terminal_read_char()
183
+                char_code = terminal_read_char_escape()
184184
                 if (char_code >= 0) then
185185
                     ch3 = achar(char_code)
186186
                     if (ch3 == '~') then
@@ -188,14 +188,14 @@ contains
188188
                         key_str = 'f1'
189189
                     else if (ch3 == '0') then
190190
                         ! F10 might be ESC [ 2 1 ~, check for tilde
191
-                        char_code = terminal_read_char()
191
+                        char_code = terminal_read_char_escape()
192192
                         if (char_code >= 0 .and. achar(char_code) == '~') then
193193
                             key_str = 'f10'
194194
                         end if
195195
                     else if (ch3 == '1' .or. ch3 == '2' .or. ch3 == '3' .or. ch3 == '4' .or. &
196196
                              ch3 == '5' .or. ch3 == '7' .or. ch3 == '8' .or. ch3 == '9') then
197197
                         ! 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()
199199
                         if (char_code >= 0) then
200200
                             ch = achar(char_code)
201201
                             if (ch == '~') then
@@ -230,12 +230,12 @@ contains
230230
                 end if
231231
             case('2')
232232
                 ! Could be F9-F12 or alternate modified keys
233
-                char_code = terminal_read_char()
233
+                char_code = terminal_read_char_escape()
234234
                 if (char_code >= 0) then
235235
                     ch3 = achar(char_code)
236236
                     if (ch3 == '0' .or. ch3 == '1' .or. ch3 == '3' .or. ch3 == '4') then
237237
                         ! 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()
239239
                         if (char_code >= 0) then
240240
                             ch = achar(char_code)
241241
                             if (ch == '~') then
@@ -257,7 +257,7 @@ contains
257257
                         end if
258258
                     else if (ch3 == ';') then
259259
                         ! ESC [ 2 ; A format (shift+arrow)
260
-                        char_code = terminal_read_char()
260
+                        char_code = terminal_read_char_escape()
261261
                         if (char_code >= 0) then
262262
                             ch = achar(char_code)
263263
                             key_str = 'shift-'
@@ -297,7 +297,7 @@ contains
297297
             end select
298298
         else if (ch1 == 'O') then
299299
             ! SS3 sequence (e.g., function keys F1-F4)
300
-            char_code = terminal_read_char()
300
+            char_code = terminal_read_char_escape()
301301
             if (char_code < 0) then
302302
                 ! Timeout - this is just Alt+O
303303
                 key_str = 'alt-o'
@@ -319,12 +319,12 @@ contains
319319
             end select
320320
         else if (ch1 == achar(27)) then
321321
             ! ESC ESC - likely Alt+something
322
-            char_code = terminal_read_char()
322
+            char_code = terminal_read_char_escape()
323323
             if (char_code >= 0) then
324324
                 ch2 = achar(char_code)
325325
                 if (ch2 == '[') then
326326
                     ! ESC ESC [ - Alt+arrow keys or Alt+modified keys
327
-                    char_code = terminal_read_char()
327
+                    char_code = terminal_read_char_escape()
328328
                     if (char_code >= 0) then
329329
                         ch3 = achar(char_code)
330330
                         select case(ch3)
@@ -338,7 +338,7 @@ contains
338338
                             key_str = 'alt-left'
339339
                         case('3')
340340
                             ! Could be Alt+Delete (ESC ESC [ 3 ~)
341
-                            char_code = terminal_read_char()
341
+                            char_code = terminal_read_char_escape()
342342
                             if (char_code >= 0 .and. achar(char_code) == '~') then
343343
                                 key_str = 'alt-delete'
344344
                             end if
@@ -406,7 +406,7 @@ contains
406406
             read_count = read_count + 1
407407
             if (read_count > 20) exit  ! Safety limit
408408
 
409
-            char_code = terminal_read_char()
409
+            char_code = terminal_read_char_escape()
410410
             if (char_code >= 0) then
411411
                 ch = achar(char_code)
412412
                 ios = 0
@@ -506,14 +506,14 @@ contains
506506
         read(modifier_char, '(i1)') modifier
507507
 
508508
         ! Read the next character - might be the key or a semicolon
509
-        char_code = terminal_read_char()
509
+        char_code = terminal_read_char_escape()
510510
         if (char_code < 0) return
511511
         ch = achar(char_code)
512512
 
513513
         ! Check if there's a semicolon (ESC [ 2 ; A format) or direct key (ESC [ 2 A)
514514
         if (ch == ';') then
515515
             ! Read the actual key
516
-            char_code = terminal_read_char()
516
+            char_code = terminal_read_char_escape()
517517
             if (char_code < 0) return
518518
             ch = achar(char_code)
519519
         end if
@@ -574,7 +574,7 @@ contains
574574
             read_count = read_count + 1
575575
             if (read_count > 20) exit
576576
 
577
-            char_code = terminal_read_char()
577
+            char_code = terminal_read_char_escape()
578578
             if (char_code < 0) exit
579579
 
580580
             ch = achar(char_code)
@@ -641,7 +641,7 @@ contains
641641
 
642642
         ! Read modifier sequence (already past the semicolon)
643643
         do
644
-            char_code = terminal_read_char()
644
+            char_code = terminal_read_char_escape()
645645
             if (char_code >= 0) then
646646
                 ch = achar(char_code)
647647
                 ios = 0
@@ -738,12 +738,12 @@ contains
738738
         end if
739739
 
740740
         ! Read modifier (should be a digit 2-8)
741
-        char_code = terminal_read_char()
741
+        char_code = terminal_read_char_escape()
742742
         if (char_code < 0) return
743743
         modifier_ch = achar(char_code)
744744
 
745745
         ! Read terminating ~
746
-        char_code = terminal_read_char()
746
+        char_code = terminal_read_char_escape()
747747
         if (char_code < 0 .or. achar(char_code) /= '~') return
748748
 
749749
         ! 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
784784
 
785785
         ! Read until 'M' (press) or 'm' (release)
786786
         do
787
-            char_code = terminal_read_char()
787
+            char_code = terminal_read_char_escape()
788788
             if (char_code >= 0) then
789789
                 ch = achar(char_code)
790790
                 ios = 0
src/terminal/raw_mode_module.f90modified
@@ -4,6 +4,7 @@ module raw_mode_module
44
     private
55
 
66
     public :: enable_raw_mode, disable_raw_mode, input_available, read_char_timeout
7
+    public :: read_char_escape, input_available_count
78
 
89
     ! C function interfaces
910
     interface
@@ -22,10 +23,20 @@ module raw_mode_module
2223
             integer(c_int) :: c_input_available
2324
         end function c_input_available
2425
 
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
+
2531
         function c_read_char_timeout() bind(C, name="read_char_timeout")
2632
             import :: c_int
2733
             integer(c_int) :: c_read_char_timeout
2834
         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
2940
     end interface
3041
 
3142
 contains
@@ -54,6 +65,14 @@ contains
5465
         available = (result > 0)
5566
     end function input_available
5667
 
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
+
5776
     function read_char_timeout() result(ch)
5877
         integer :: ch
5978
         integer(c_int) :: result
@@ -62,4 +81,13 @@ contains
6281
         ch = result
6382
     end function read_char_timeout
6483
 
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
+
6593
 end module raw_mode_module
src/terminal/renderer_module.f90modified
@@ -27,6 +27,7 @@ module renderer_module
2727
     public :: render_screen_with_tree, render_screen_with_lsp_panel
2828
     public :: tree_state
2929
     public :: update_syntax_highlighter
30
+    public :: render_cursor_only  ! Fast path for cursor-only updates
3031
 
3132
     ! Configuration
3233
     logical :: show_line_numbers = .true.
@@ -773,6 +774,38 @@ contains
773774
         end if
774775
     end subroutine render_cursor
775776
 
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
+
776809
     subroutine update_viewport(editor)
777810
         use editor_state_module, only: pane_t
778811
         type(editor_state_t), intent(inout) :: editor
src/terminal/terminal_io_module.f90modified
@@ -4,7 +4,9 @@ module terminal_io_module
44
     use raw_mode_module, only: raw_enable_raw_mode => enable_raw_mode, &
55
                                raw_disable_raw_mode => disable_raw_mode, &
66
                                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
810
     implicit none
911
     private
1012
 
@@ -13,6 +15,7 @@ module terminal_io_module
1315
     public :: terminal_get_size, terminal_enable_raw_mode, terminal_disable_raw_mode
1416
     public :: terminal_write, terminal_enable_mouse, terminal_disable_mouse
1517
     public :: terminal_input_available, terminal_read_char
18
+    public :: terminal_read_char_escape, terminal_input_available_count
1619
 
1720
     ! ANSI escape codes
1821
     character(len=*), parameter :: ESC = char(27)
@@ -118,6 +121,18 @@ contains
118121
         ch = raw_read_char_timeout()
119122
     end function terminal_read_char
120123
 
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
+
121136
     subroutine terminal_write(text)
122137
         character(len=*), intent(in) :: text
123138
         write(output_unit, '(a)', advance='no') text
src/terminal/termios_wrapper.cmodified
@@ -5,10 +5,18 @@
55
 #include <stdio.h>
66
 #include <errno.h>
77
 #include <sys/ioctl.h>
8
+#include <sys/select.h>
9
+#include <string.h>
810
 
911
 static struct termios orig_termios;
1012
 static int raw_mode_enabled = 0;
1113
 
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
+
1220
 // Enable raw mode - returns 0 on success, -1 on failure
1321
 int enable_raw_mode(void) {
1422
     if (raw_mode_enabled) return 0;
@@ -31,15 +39,17 @@ int enable_raw_mode(void) {
3139
     // Local flags: disable canonical mode, echo, signals, extended input processing
3240
     raw.c_lflag &= ~(tcflag_t)(ECHO | ICANON | ISIG | IEXTEN);
3341
 
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
3746
 
3847
     if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == -1) {
3948
         return -1;
4049
     }
4150
 
4251
     raw_mode_enabled = 1;
52
+    buffer_start = buffer_end = 0;
4353
     return 0;
4454
 }
4555
 
@@ -52,11 +62,18 @@ int disable_raw_mode(void) {
5262
     }
5363
 
5464
     raw_mode_enabled = 0;
65
+    buffer_start = buffer_end = 0;
5566
     return 0;
5667
 }
5768
 
5869
 // Check if input is available (non-blocking)
5970
 int input_available(void) {
71
+    // First check our buffer
72
+    if (buffer_start < buffer_end) {
73
+        return 1;
74
+    }
75
+
76
+    // Then check stdin
6077
     int nread;
6178
     if (ioctl(STDIN_FILENO, FIONREAD, &nread) == -1) {
6279
         return 0;
@@ -64,13 +81,105 @@ int input_available(void) {
6481
     return nread > 0;
6582
 }
6683
 
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
68120
 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++];
75165
     }
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;
76185
 }