fortrangoingonforty/fortty / 5732b28

Browse files

Integrate title polling and cursor blink in main loop

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
5732b2883bb53bfbf05d6fa70072de5cff8e6b53
Parents
b0c1a51
Tree
509d636

3 changed files

StatusFile+-
M src/fortty.f90 80 8
M src/terminal/terminal.f90 66 3
M src/window/window.f90 231 1
src/fortty.f90modified
@@ -1,5 +1,6 @@
1
 program fortty
1
 program fortty
2
   use window_mod
2
   use window_mod
3
+  use selection_mod, only: selection_contains
3
   use gl_bindings
4
   use gl_bindings
4
   use renderer_mod
5
   use renderer_mod
5
   use font_mod, only: font_find_monospace, font_find_for_codepoint
6
   use font_mod, only: font_find_monospace, font_find_for_codepoint
@@ -9,6 +10,8 @@ program fortty
9
   use screen_mod
10
   use screen_mod
10
   use cell_mod, only: cell_t, set_palette_color, set_default_colors
11
   use cell_mod, only: cell_t, set_palette_color, set_default_colors
11
   use config_mod
12
   use config_mod
13
+  use cursor_mod, only: CURSOR_BLOCK, CURSOR_UNDERLINE, CURSOR_BAR
14
+  use glfw_bindings, only: glfwGetTime
12
   implicit none
15
   implicit none
13
 
16
 
14
   type(window_t) :: win
17
   type(window_t) :: win
@@ -31,6 +34,9 @@ program fortty
31
   real :: x, y, r, g, b, bg_r, bg_g, bg_b
34
   real :: x, y, r, g, b, bg_r, bg_g, bg_b
32
   type(cell_t), allocatable :: sb_line(:)
35
   type(cell_t), allocatable :: sb_line(:)
33
   integer :: cell_width, cell_height  ! From font metrics
36
   integer :: cell_width, cell_height  ! From font metrics
37
+  real(8) :: current_time, last_time, blink_timer
38
+  logical :: cursor_blink_visible
39
+  type(selection_t) :: sel
34
 
40
 
35
   ! Load configuration (uses defaults if no config file found)
41
   ! Load configuration (uses defaults if no config file found)
36
   cfg = config_load('')
42
   cfg = config_load('')
@@ -146,6 +152,9 @@ program fortty
146
   if (cell_height < 1) cell_height = 18  ! Fallback
152
   if (cell_height < 1) cell_height = 18  ! Fallback
147
   print *, "Font cell size:", cell_width, "x", cell_height
153
   print *, "Font cell size:", cell_width, "x", cell_height
148
 
154
 
155
+  ! Share cell dimensions with window module for mouse selection
156
+  call window_set_cell_size(cell_width, cell_height)
157
+
149
   ! Calculate terminal dimensions based on font metrics
158
   ! Calculate terminal dimensions based on font metrics
150
   term_cols = win_width / cell_width
159
   term_cols = win_width / cell_width
151
   term_rows = win_height / cell_height
160
   term_rows = win_height / cell_height
@@ -171,8 +180,22 @@ program fortty
171
   call window_set_pty(pty)
180
   call window_set_pty(pty)
172
   call window_set_terminal(term)
181
   call window_set_terminal(term)
173
 
182
 
183
+  ! Initialize blink timer
184
+  last_time = glfwGetTime()
185
+  blink_timer = 0.0d0
186
+  cursor_blink_visible = .true.
187
+
174
   ! Main event loop
188
   ! Main event loop
175
   do while (.not. window_should_close(win) .and. pty_is_alive(pty))
189
   do while (.not. window_should_close(win) .and. pty_is_alive(pty))
190
+    ! Update blink timer
191
+    current_time = glfwGetTime()
192
+    blink_timer = blink_timer + (current_time - last_time)
193
+    last_time = current_time
194
+    if (blink_timer > 0.5d0) then
195
+      cursor_blink_visible = .not. cursor_blink_visible
196
+      blink_timer = 0.0d0
197
+    end if
198
+
176
     ! Check for window resize
199
     ! Check for window resize
177
     call window_get_size(win, win_width, win_height)
200
     call window_get_size(win, win_width, win_height)
178
     if (win_width /= prev_width .or. win_height /= prev_height) then
201
     if (win_width /= prev_width .or. win_height /= prev_height) then
@@ -210,6 +233,11 @@ program fortty
210
       end if
233
       end if
211
     end if
234
     end if
212
 
235
 
236
+    ! Check for window title changes (from OSC 0/1/2)
237
+    if (terminal_has_title_changed(term)) then
238
+      call window_set_title(win, terminal_get_title(term))
239
+    end if
240
+
213
     ! Clear screen with background color from config
241
     ! Clear screen with background color from config
214
     bg_r = real(cfg%bg_color%r) / 255.0
242
     bg_r = real(cfg%bg_color%r) / 255.0
215
     bg_g = real(cfg%bg_color%g) / 255.0
243
     bg_g = real(cfg%bg_color%g) / 255.0
@@ -223,6 +251,9 @@ program fortty
223
     scr => terminal_active_screen(term)
251
     scr => terminal_active_screen(term)
224
     scroll_offset = terminal_get_scroll_offset(term)
252
     scroll_offset = terminal_get_scroll_offset(term)
225
 
253
 
254
+    ! Get current selection for highlighting
255
+    sel = window_get_selection()
256
+
226
     ! Allocate/resize scrollback line buffer if needed
257
     ! Allocate/resize scrollback line buffer if needed
227
     if (.not. allocated(sb_line)) then
258
     if (.not. allocated(sb_line)) then
228
       allocate(sb_line(term_cols))
259
       allocate(sb_line(term_cols))
@@ -242,8 +273,19 @@ program fortty
242
         call terminal_get_scrollback_line(term, sb_offset - 1, sb_line, term_cols)
273
         call terminal_get_scrollback_line(term, sb_offset - 1, sb_line, term_cols)
243
         do col = 1, min(term_cols, scr%cols)
274
         do col = 1, min(term_cols, scr%cols)
244
           cell = sb_line(col)
275
           cell = sb_line(col)
245
-          if (cell%codepoint /= 32) then
276
+
246
-            x = real(col - 1) * cell_width
277
+          ! Skip continuation cells (2nd half of wide chars)
278
+          if (cell%is_continuation) cycle
279
+
280
+          x = real(col - 1) * cell_width
281
+
282
+          ! Draw selection background if selected
283
+          if (selection_contains(sel, row, col)) then
284
+            call renderer_draw_rect(ren, x, y, real(cell_width), real(cell_height), &
285
+                                    0.3, 0.3, 0.6, 1.0)
286
+          end if
287
+
288
+          if (cell%codepoint /= 32 .and. cell%codepoint /= 0) then
247
             r = real(cell%fg%r) / 255.0
289
             r = real(cell%fg%r) / 255.0
248
             g = real(cell%fg%g) / 255.0
290
             g = real(cell%fg%g) / 255.0
249
             b = real(cell%fg%b) / 255.0
291
             b = real(cell%fg%b) / 255.0
@@ -256,8 +298,19 @@ program fortty
256
         if (screen_row >= 1 .and. screen_row <= scr%rows) then
298
         if (screen_row >= 1 .and. screen_row <= scr%rows) then
257
           do col = 1, scr%cols
299
           do col = 1, scr%cols
258
             cell = screen_get_cell(scr, screen_row, col)
300
             cell = screen_get_cell(scr, screen_row, col)
259
-            if (cell%codepoint /= 32) then
301
+
260
-              x = real(col - 1) * cell_width
302
+            ! Skip continuation cells (2nd half of wide chars)
303
+            if (cell%is_continuation) cycle
304
+
305
+            x = real(col - 1) * cell_width
306
+
307
+            ! Draw selection background if selected
308
+            if (selection_contains(sel, row, col)) then
309
+              call renderer_draw_rect(ren, x, y, real(cell_width), real(cell_height), &
310
+                                      0.3, 0.3, 0.6, 1.0)
311
+            end if
312
+
313
+            if (cell%codepoint /= 32 .and. cell%codepoint /= 0) then
261
               r = real(cell%fg%r) / 255.0
314
               r = real(cell%fg%r) / 255.0
262
               g = real(cell%fg%g) / 255.0
315
               g = real(cell%fg%g) / 255.0
263
               b = real(cell%fg%b) / 255.0
316
               b = real(cell%fg%b) / 255.0
@@ -270,10 +323,29 @@ program fortty
270
 
323
 
271
     ! Draw cursor if visible (only when not scrolled back)
324
     ! Draw cursor if visible (only when not scrolled back)
272
     if (term%cursor%visible .and. scroll_offset == 0) then
325
     if (term%cursor%visible .and. scroll_offset == 0) then
273
-      x = real(term%cursor%col - 1) * cell_width
326
+      ! Check blink state - only hide cursor if blink is enabled and in off phase
274
-      y = real(term%cursor%row) * cell_height
327
+      if (.not. term%cursor%blink .or. cursor_blink_visible) then
275
-      ! Draw cursor as underscore character for visibility
328
+        x = real(term%cursor%col - 1) * cell_width
276
-      call renderer_draw_char(ren, x, y, 95, 0.7, 0.7, 0.7, 1.0)  ! '_'
329
+        y = real(term%cursor%row - 1) * cell_height
330
+
331
+        select case (term%cursor%style)
332
+          case (CURSOR_BLOCK)
333
+            ! Filled block cursor
334
+            call renderer_draw_rect(ren, x, y, real(cell_width), real(cell_height), &
335
+                                    0.7, 0.7, 0.7, 0.8)
336
+          case (CURSOR_UNDERLINE)
337
+            ! Underline at bottom of cell
338
+            call renderer_draw_rect(ren, x, y + real(cell_height) - 2.0, &
339
+                                    real(cell_width), 2.0, 0.7, 0.7, 0.7, 1.0)
340
+          case (CURSOR_BAR)
341
+            ! Vertical bar at left of cell
342
+            call renderer_draw_rect(ren, x, y, 2.0, real(cell_height), 0.7, 0.7, 0.7, 1.0)
343
+          case default
344
+            ! Fallback to block
345
+            call renderer_draw_rect(ren, x, y, real(cell_width), real(cell_height), &
346
+                                    0.7, 0.7, 0.7, 0.8)
347
+        end select
348
+      end if
277
     end if
349
     end if
278
 
350
 
279
     call renderer_flush(ren)
351
     call renderer_flush(ren)
src/terminal/terminal.f90modified
@@ -3,6 +3,7 @@ module terminal_mod
3
   use screen_mod
3
   use screen_mod
4
   use cursor_mod
4
   use cursor_mod
5
   use scrollback_mod
5
   use scrollback_mod
6
+  use wcwidth_mod, only: codepoint_width
6
   implicit none
7
   implicit none
7
   private
8
   private
8
 
9
 
@@ -23,6 +24,7 @@ module terminal_mod
23
   public :: terminal_scroll_view, terminal_get_scroll_offset, terminal_reset_scroll_view
24
   public :: terminal_scroll_view, terminal_get_scroll_offset, terminal_reset_scroll_view
24
   public :: terminal_get_scrollback_count, terminal_get_scrollback_line
25
   public :: terminal_get_scrollback_count, terminal_get_scrollback_line
25
   public :: terminal_queue_response, terminal_get_response, terminal_has_response
26
   public :: terminal_queue_response, terminal_get_response, terminal_has_response
27
+  public :: terminal_set_title, terminal_get_title, terminal_has_title_changed
26
 
28
 
27
   integer, parameter :: RESPONSE_BUFFER_SIZE = 256
29
   integer, parameter :: RESPONSE_BUFFER_SIZE = 256
28
 
30
 
@@ -54,6 +56,10 @@ module terminal_mod
54
     ! Response buffer for escape sequence replies (e.g., DA1)
56
     ! Response buffer for escape sequence replies (e.g., DA1)
55
     character(len=RESPONSE_BUFFER_SIZE) :: response = ''
57
     character(len=RESPONSE_BUFFER_SIZE) :: response = ''
56
     integer :: response_len = 0
58
     integer :: response_len = 0
59
+
60
+    ! Window title (set via OSC 0/1/2)
61
+    character(len=256) :: title = 'fortty'
62
+    logical :: title_changed = .false.
57
   end type terminal_t
63
   end type terminal_t
58
 
64
 
59
 contains
65
 contains
@@ -144,8 +150,9 @@ contains
144
   subroutine terminal_put_char(term, codepoint)
150
   subroutine terminal_put_char(term, codepoint)
145
     type(terminal_t), intent(inout) :: term
151
     type(terminal_t), intent(inout) :: term
146
     integer, intent(in) :: codepoint
152
     integer, intent(in) :: codepoint
147
-    type(cell_t) :: cell
153
+    type(cell_t) :: cell, cont_cell
148
     type(screen_t), pointer :: scr
154
     type(screen_t), pointer :: scr
155
+    integer :: width
149
 
156
 
150
     scr => terminal_active_screen(term)
157
     scr => terminal_active_screen(term)
151
 
158
 
@@ -169,17 +176,47 @@ contains
169
         return
176
         return
170
     end select
177
     end select
171
 
178
 
179
+    ! Determine character display width
180
+    width = codepoint_width(codepoint)
181
+
182
+    ! Skip zero-width characters (combining marks, etc.)
183
+    if (width == 0) return
184
+
185
+    ! Check if wide character fits before line end
186
+    if (width == 2 .and. term%cursor%col + 1 > term%cols) then
187
+      if (term%mode_autowrap) then
188
+        call terminal_newline(term)
189
+        term%cursor%col = 1
190
+      else
191
+        ! Can't fit - don't draw
192
+        return
193
+      end if
194
+    end if
195
+
172
     ! Printable character - create cell with current style
196
     ! Printable character - create cell with current style
173
     cell%codepoint = codepoint
197
     cell%codepoint = codepoint
174
     cell%fg = term%cursor%fg
198
     cell%fg = term%cursor%fg
175
     cell%bg = term%cursor%bg
199
     cell%bg = term%cursor%bg
176
     cell%attrs = term%cursor%attrs
200
     cell%attrs = term%cursor%attrs
201
+    cell%width = width
202
+    cell%is_continuation = .false.
177
 
203
 
178
     ! Place in buffer
204
     ! Place in buffer
179
     call screen_set_cell(scr, term%cursor%row, term%cursor%col, cell)
205
     call screen_set_cell(scr, term%cursor%row, term%cursor%col, cell)
180
 
206
 
181
-    ! Advance cursor
207
+    ! For wide characters, write continuation cell
182
-    term%cursor%col = term%cursor%col + 1
208
+    if (width == 2 .and. term%cursor%col < term%cols) then
209
+      cont_cell%codepoint = 0
210
+      cont_cell%fg = term%cursor%fg
211
+      cont_cell%bg = term%cursor%bg
212
+      cont_cell%attrs = term%cursor%attrs
213
+      cont_cell%width = 0
214
+      cont_cell%is_continuation = .true.
215
+      call screen_set_cell(scr, term%cursor%row, term%cursor%col + 1, cont_cell)
216
+    end if
217
+
218
+    ! Advance cursor by character width
219
+    term%cursor%col = term%cursor%col + width
183
 
220
 
184
     ! Handle wrap at end of line
221
     ! Handle wrap at end of line
185
     if (term%cursor%col > term%cols) then
222
     if (term%cursor%col > term%cols) then
@@ -697,4 +734,30 @@ contains
697
     term%response_len = 0
734
     term%response_len = 0
698
   end subroutine terminal_get_response
735
   end subroutine terminal_get_response
699
 
736
 
737
+  ! Set window title (from OSC 0/1/2)
738
+  subroutine terminal_set_title(term, title)
739
+    type(terminal_t), intent(inout) :: term
740
+    character(len=*), intent(in) :: title
741
+
742
+    term%title = title
743
+    term%title_changed = .true.
744
+  end subroutine terminal_set_title
745
+
746
+  ! Get window title
747
+  function terminal_get_title(term) result(title)
748
+    type(terminal_t), intent(in) :: term
749
+    character(len=256) :: title
750
+
751
+    title = term%title
752
+  end function terminal_get_title
753
+
754
+  ! Check if title has changed (and clear the flag)
755
+  function terminal_has_title_changed(term) result(changed)
756
+    type(terminal_t), intent(inout) :: term
757
+    logical :: changed
758
+
759
+    changed = term%title_changed
760
+    term%title_changed = .false.
761
+  end function terminal_has_title_changed
762
+
700
 end module terminal_mod
763
 end module terminal_mod
src/window/window.f90modified
@@ -5,13 +5,16 @@ module window_mod
5
   use gl_bindings
5
   use gl_bindings
6
   use pty_mod
6
   use pty_mod
7
   use terminal_mod
7
   use terminal_mod
8
+  use selection_mod
8
   implicit none
9
   implicit none
9
   private
10
   private
10
 
11
 
11
-  public :: window_t
12
+  public :: window_t, selection_t
12
   public :: window_create, window_destroy
13
   public :: window_create, window_destroy
13
   public :: window_should_close, window_swap_buffers, window_poll_events
14
   public :: window_should_close, window_swap_buffers, window_poll_events
14
   public :: window_get_size, window_set_pty, window_set_terminal
15
   public :: window_get_size, window_set_pty, window_set_terminal
16
+  public :: window_set_title, window_set_cell_size
17
+  public :: window_get_selection, window_clipboard_set, window_clipboard_get
15
 
18
 
16
   type :: window_t
19
   type :: window_t
17
     type(c_ptr) :: handle = c_null_ptr
20
     type(c_ptr) :: handle = c_null_ptr
@@ -30,6 +33,13 @@ module window_mod
30
   ! Module-level terminal pointer for scrollback
33
   ! Module-level terminal pointer for scrollback
31
   type(terminal_t), pointer, save :: active_term => null()
34
   type(terminal_t), pointer, save :: active_term => null()
32
 
35
 
36
+  ! Selection state for mouse-based text selection
37
+  type(selection_t), save :: active_selection
38
+
39
+  ! Cell dimensions for mouse coordinate conversion
40
+  integer, save :: cell_width = 10
41
+  integer, save :: cell_height = 18
42
+
33
   ! Interface to C helper for loading OpenGL
43
   ! Interface to C helper for loading OpenGL
34
   interface
44
   interface
35
     integer(c_int) function fortty_load_gl() bind(C, name="fortty_load_gl")
45
     integer(c_int) function fortty_load_gl() bind(C, name="fortty_load_gl")
@@ -99,6 +109,8 @@ contains
99
     dummy = glfwSetKeyCallback(win%handle, c_funloc(key_callback))
109
     dummy = glfwSetKeyCallback(win%handle, c_funloc(key_callback))
100
     dummy = glfwSetCharCallback(win%handle, c_funloc(char_callback))
110
     dummy = glfwSetCharCallback(win%handle, c_funloc(char_callback))
101
     dummy = glfwSetScrollCallback(win%handle, c_funloc(scroll_callback))
111
     dummy = glfwSetScrollCallback(win%handle, c_funloc(scroll_callback))
112
+    dummy = glfwSetMouseButtonCallback(win%handle, c_funloc(mouse_button_callback))
113
+    dummy = glfwSetCursorPosCallback(win%handle, c_funloc(cursor_pos_callback))
102
 
114
 
103
   end function window_create
115
   end function window_create
104
 
116
 
@@ -198,6 +210,14 @@ contains
198
       call terminal_reset_scroll_view(active_term)
210
       call terminal_reset_scroll_view(active_term)
199
     end if
211
     end if
200
 
212
 
213
+    ! Handle Ctrl+Shift+V for paste
214
+    if (iand(mods, GLFW_MOD_CONTROL) /= 0 .and. iand(mods, GLFW_MOD_SHIFT) /= 0) then
215
+      if (key == GLFW_KEY_V) then
216
+        call handle_paste(window)
217
+        return
218
+      end if
219
+    end if
220
+
201
     ! Handle Ctrl combinations (these don't trigger char_callback)
221
     ! Handle Ctrl combinations (these don't trigger char_callback)
202
     if (iand(mods, GLFW_MOD_CONTROL) /= 0) then
222
     if (iand(mods, GLFW_MOD_CONTROL) /= 0) then
203
       if (key >= GLFW_KEY_A .and. key <= GLFW_KEY_Z) then
223
       if (key >= GLFW_KEY_A .and. key <= GLFW_KEY_Z) then
@@ -445,4 +465,214 @@ contains
445
     call terminal_scroll_view(active_term, scroll_lines)
465
     call terminal_scroll_view(active_term, scroll_lines)
446
   end subroutine scroll_callback
466
   end subroutine scroll_callback
447
 
467
 
468
+  ! Set window title
469
+  subroutine window_set_title(win, title)
470
+    type(window_t), intent(in) :: win
471
+    character(len=*), intent(in) :: title
472
+    character(len=257) :: c_title
473
+
474
+    if (.not. c_associated(win%handle)) return
475
+
476
+    c_title = trim(title) // c_null_char
477
+    call glfwSetWindowTitle(win%handle, c_title)
478
+  end subroutine window_set_title
479
+
480
+  ! Set cell dimensions for mouse coordinate conversion
481
+  subroutine window_set_cell_size(w, h)
482
+    integer, intent(in) :: w, h
483
+
484
+    cell_width = w
485
+    cell_height = h
486
+  end subroutine window_set_cell_size
487
+
488
+  ! Get selection state (for rendering)
489
+  function window_get_selection() result(sel)
490
+    type(selection_t) :: sel
491
+
492
+    sel = active_selection
493
+  end function window_get_selection
494
+
495
+  ! Set clipboard content
496
+  subroutine window_clipboard_set(win, text)
497
+    type(window_t), intent(in) :: win
498
+    character(len=*), intent(in) :: text
499
+    character(len=4097) :: c_text
500
+
501
+    if (.not. c_associated(win%handle)) return
502
+    if (len_trim(text) == 0) return
503
+
504
+    c_text = trim(text) // c_null_char
505
+    call glfwSetClipboardString(win%handle, c_text)
506
+  end subroutine window_clipboard_set
507
+
508
+  ! Get clipboard content
509
+  function window_clipboard_get(win) result(text)
510
+    type(window_t), intent(in) :: win
511
+    character(len=4096) :: text
512
+    type(c_ptr) :: clip_ptr
513
+    character(len=1), pointer :: chars(:)
514
+    integer :: i, length
515
+
516
+    text = ''
517
+    if (.not. c_associated(win%handle)) return
518
+
519
+    clip_ptr = glfwGetClipboardString(win%handle)
520
+    if (.not. c_associated(clip_ptr)) return
521
+
522
+    ! Convert C string to Fortran string
523
+    call c_f_pointer(clip_ptr, chars, [4096])
524
+    length = 0
525
+    do i = 1, 4096
526
+      if (chars(i) == c_null_char) exit
527
+      length = i
528
+    end do
529
+
530
+    do i = 1, min(length, 4096)
531
+      text(i:i) = chars(i)
532
+    end do
533
+  end function window_clipboard_get
534
+
535
+  ! Callback: handle mouse button events
536
+  subroutine mouse_button_callback(window, button, action, mods) bind(C)
537
+    type(c_ptr), value :: window
538
+    integer(c_int), value :: button, action, mods
539
+    real(c_double) :: xpos, ypos
540
+    integer :: col, row
541
+
542
+    ! Only handle left mouse button for selection
543
+    if (button /= GLFW_MOUSE_BUTTON_LEFT) return
544
+
545
+    call glfwGetCursorPos(window, xpos, ypos)
546
+
547
+    ! Convert pixel position to terminal cell coordinates (1-based)
548
+    col = int(xpos / cell_width) + 1
549
+    row = int(ypos / cell_height) + 1
550
+
551
+    ! Clamp to valid range
552
+    if (associated(active_term)) then
553
+      if (col < 1) col = 1
554
+      if (col > active_term%cols) col = active_term%cols
555
+      if (row < 1) row = 1
556
+      if (row > active_term%rows) row = active_term%rows
557
+    end if
558
+
559
+    if (action == GLFW_PRESS) then
560
+      call selection_start(active_selection, row, col)
561
+    else if (action == GLFW_RELEASE) then
562
+      call selection_end(active_selection)
563
+      ! Copy selection to clipboard if active
564
+      if (selection_is_active(active_selection)) then
565
+        call copy_selection_to_clipboard(window)
566
+      end if
567
+    end if
568
+  end subroutine mouse_button_callback
569
+
570
+  ! Callback: handle cursor position changes
571
+  subroutine cursor_pos_callback(window, xpos, ypos) bind(C)
572
+    type(c_ptr), value :: window
573
+    real(c_double), value :: xpos, ypos
574
+    integer :: col, row
575
+
576
+    ! Only update if actively selecting
577
+    if (.not. active_selection%selecting) return
578
+
579
+    ! Convert pixel position to terminal cell coordinates (1-based)
580
+    col = int(xpos / cell_width) + 1
581
+    row = int(ypos / cell_height) + 1
582
+
583
+    ! Clamp to valid range
584
+    if (associated(active_term)) then
585
+      if (col < 1) col = 1
586
+      if (col > active_term%cols) col = active_term%cols
587
+      if (row < 1) row = 1
588
+      if (row > active_term%rows) row = active_term%rows
589
+    end if
590
+
591
+    call selection_update(active_selection, row, col)
592
+  end subroutine cursor_pos_callback
593
+
594
+  ! Copy selection to clipboard
595
+  subroutine copy_selection_to_clipboard(window)
596
+    use screen_mod
597
+    use cell_mod
598
+    type(c_ptr), intent(in) :: window
599
+    type(screen_t), pointer :: scr
600
+    type(cell_t) :: cell
601
+    character(len=4096) :: text
602
+    character(len=4) :: utf8
603
+    integer :: r1, c1, r2, c2, row, col, pos, utf8_len
604
+
605
+    if (.not. associated(active_term)) return
606
+    if (.not. selection_is_active(active_selection)) return
607
+
608
+    scr => terminal_active_screen(active_term)
609
+    call selection_get_bounds(active_selection, r1, c1, r2, c2)
610
+
611
+    text = ''
612
+    pos = 1
613
+
614
+    do row = r1, r2
615
+      do col = 1, scr%cols
616
+        ! Check if this cell is in selection
617
+        if (.not. selection_contains(active_selection, row, col)) cycle
618
+
619
+        cell = screen_get_cell(scr, row, col)
620
+
621
+        ! Convert codepoint to UTF-8
622
+        if (cell%codepoint >= 32 .and. cell%codepoint < 1114112) then
623
+          call codepoint_to_utf8(cell%codepoint, utf8, utf8_len)
624
+          if (pos + utf8_len - 1 <= 4096) then
625
+            text(pos:pos+utf8_len-1) = utf8(1:utf8_len)
626
+            pos = pos + utf8_len
627
+          end if
628
+        end if
629
+      end do
630
+
631
+      ! Add newline between rows (except last row)
632
+      if (row < r2 .and. pos < 4096) then
633
+        text(pos:pos) = char(10)
634
+        pos = pos + 1
635
+      end if
636
+    end do
637
+
638
+    ! Set clipboard
639
+    if (pos > 1) then
640
+      call glfwSetClipboardString(window, trim(text(1:pos-1)) // c_null_char)
641
+    end if
642
+  end subroutine copy_selection_to_clipboard
643
+
644
+  ! Handle paste from clipboard
645
+  subroutine handle_paste(window)
646
+    type(c_ptr), intent(in) :: window
647
+    type(c_ptr) :: clip_ptr
648
+    character(len=1), pointer :: chars(:)
649
+    integer :: i, length
650
+
651
+    if (.not. associated(active_pty)) return
652
+
653
+    clip_ptr = glfwGetClipboardString(window)
654
+    if (.not. c_associated(clip_ptr)) return
655
+
656
+    ! Find length of C string
657
+    call c_f_pointer(clip_ptr, chars, [4096])
658
+    length = 0
659
+    do i = 1, 4096
660
+      if (chars(i) == c_null_char) exit
661
+      length = i
662
+    end do
663
+
664
+    ! Write to PTY
665
+    if (length > 0) then
666
+      block
667
+        character(len=4096) :: paste_text
668
+        integer :: j
669
+        paste_text = ''
670
+        do j = 1, length
671
+          paste_text(j:j) = chars(j)
672
+        end do
673
+        call pty_write(active_pty, paste_text, length)
674
+      end block
675
+    end if
676
+  end subroutine handle_paste
677
+
448
 end module window_mod
678
 end module window_mod