@@ -209,6 +209,13 @@ module readline |
| 209 | 209 | type(string_ref) :: menu_prefix_ref |
| 210 | 210 | type(string_ref) :: selected_process_name_ref |
| 211 | 211 | #endif |
| 212 | + |
| 213 | + ! Text selection state (shift phase, Sprint 1) |
| 214 | + ! Appended at end of type per overview.md pattern #5 — do not reorder. |
| 215 | + ! Selection range is [min(anchor, cursor_pos) .. max(anchor, cursor_pos)) |
| 216 | + ! measured in BYTES (consistent with cursor_pos and length — pattern #11). |
| 217 | + integer :: selection_anchor = -1 ! -1 = no anchor set |
| 218 | + logical :: selection_active = .false. ! .true. iff a selection is live |
| 212 | 219 | end type input_state_t |
| 213 | 220 | |
| 214 | 221 | type :: history_t |
@@ -258,6 +265,19 @@ module readline |
| 258 | 265 | ! Track whether the search status line is currently displayed below the prompt |
| 259 | 266 | logical, save :: module_search_status_shown = .false. |
| 260 | 267 | |
| 268 | + ! Shift-phase selection state (Sprint 1) |
| 269 | + ! When .true., the next base movement handler call should extend the active |
| 270 | + ! selection rather than collapse it. Set by the shift-arrow dispatch in |
| 271 | + ! handle_extended_escape_sequence immediately before calling a base handler, |
| 272 | + ! cleared immediately after. Module-level rather than per-state so handlers |
| 273 | + ! don't need a new parameter (avoids flang-new derived-type ABI issues — #6). |
| 274 | + logical, save :: module_extending_selection = .false. |
| 275 | + |
| 276 | + ! FORTSH_DEBUG_SELECTION env flag — dumps selection state to stderr when set. |
| 277 | + ! Probed once at init; cached. Pattern #20 from overview.md. |
| 278 | + logical, save :: debug_selection = .false. |
| 279 | + logical, save :: debug_selection_initialized = .false. |
| 280 | + |
| 261 | 281 | contains |
| 262 | 282 | |
| 263 | 283 | !============================================================================ |
@@ -592,6 +612,138 @@ contains |
| 592 | 612 | ! END BUFFER OPERATION WRAPPERS |
| 593 | 613 | !============================================================================ |
| 594 | 614 | |
| 615 | + !============================================================================ |
| 616 | + ! TEXT SELECTION HELPERS (shift phase, Sprint 1) |
| 617 | + !============================================================================ |
| 618 | + ! Three-state machine: |
| 619 | + ! - Inactive: selection_anchor = -1, selection_active = .false. |
| 620 | + ! - Active: selection_anchor in [0, length], selection_active = .true., |
| 621 | + ! selected range = [min(anchor, cursor_pos), max(anchor, cursor_pos)) |
| 622 | + ! |
| 623 | + ! Extending vs collapsing: |
| 624 | + ! - Shift+motion calls set module_extending_selection=.true. before calling |
| 625 | + ! a base movement handler, then call update_selection_on_shift_motion() |
| 626 | + ! after to install/extend the selection against the old cursor position. |
| 627 | + ! - Plain motion handlers check module_extending_selection at the top; if |
| 628 | + ! .false. and selection_active, they collapse (char motions snap to the |
| 629 | + ! appropriate edge; word/line motions just clear state and proceed). |
| 630 | + !============================================================================ |
| 631 | + |
| 632 | + ! Initialize debug flag from environment (idempotent). |
| 633 | + subroutine init_debug_selection() |
| 634 | + integer :: status |
| 635 | + character(len=8) :: env_val |
| 636 | + if (debug_selection_initialized) return |
| 637 | + call get_environment_variable('FORTSH_DEBUG_SELECTION', env_val, status=status) |
| 638 | + debug_selection = (status == 0 .and. trim(env_val) == '1') |
| 639 | + debug_selection_initialized = .true. |
| 640 | + end subroutine init_debug_selection |
| 641 | + |
| 642 | + ! Emit a debug trace line if FORTSH_DEBUG_SELECTION=1. |
| 643 | + subroutine debug_selection_log(tag, state) |
| 644 | + use iso_fortran_env, only: error_unit |
| 645 | + character(len=*), intent(in) :: tag |
| 646 | + type(input_state_t), intent(in) :: state |
| 647 | + if (.not. debug_selection_initialized) call init_debug_selection() |
| 648 | + if (.not. debug_selection) return |
| 649 | + if (state%selection_active) then |
| 650 | + write(error_unit, '(a,a,a,i0,a,i0,a,i0,a,l1)') & |
| 651 | + '[SEL:', trim(tag), '] cursor_pos=', state%cursor_pos, & |
| 652 | + ' anchor=', state%selection_anchor, & |
| 653 | + ' length=', state%length, & |
| 654 | + ' active=', state%selection_active |
| 655 | + else |
| 656 | + write(error_unit, '(a,a,a,i0,a,i0,a,l1)') & |
| 657 | + '[SEL:', trim(tag), '] cursor_pos=', state%cursor_pos, & |
| 658 | + ' length=', state%length, & |
| 659 | + ' active=', state%selection_active |
| 660 | + end if |
| 661 | + end subroutine debug_selection_log |
| 662 | + |
| 663 | + ! Clear selection state (no cursor motion, no dirty flag). |
| 664 | + ! Caller is responsible for setting dirty if a redraw is needed. |
| 665 | + subroutine collapse_selection(state) |
| 666 | + type(input_state_t), intent(inout) :: state |
| 667 | + if (.not. state%selection_active) return |
| 668 | + state%selection_anchor = -1 |
| 669 | + state%selection_active = .false. |
| 670 | + call debug_selection_log('collapse', state) |
| 671 | + end subroutine collapse_selection |
| 672 | + |
| 673 | + ! Called AFTER a base movement handler has moved the cursor while |
| 674 | + ! module_extending_selection is .true. Establishes a new selection anchored |
| 675 | + ! at old_cursor_pos if one isn't already active, or extends the existing |
| 676 | + ! one. If the motion brings cursor back to anchor, auto-collapses. |
| 677 | + subroutine update_selection_on_shift_motion(state, old_cursor_pos) |
| 678 | + type(input_state_t), intent(inout) :: state |
| 679 | + integer, intent(in) :: old_cursor_pos |
| 680 | + |
| 681 | + if (state%cursor_pos == old_cursor_pos) then |
| 682 | + ! No actual motion occurred (e.g. Shift+Left at pos 0). Leave state alone. |
| 683 | + return |
| 684 | + end if |
| 685 | + |
| 686 | + if (.not. state%selection_active) then |
| 687 | + ! Starting a fresh selection — anchor at the position before this motion. |
| 688 | + state%selection_anchor = old_cursor_pos |
| 689 | + state%selection_active = .true. |
| 690 | + end if |
| 691 | + |
| 692 | + ! If the motion brought cursor back to anchor, the selection is empty — collapse. |
| 693 | + if (state%selection_anchor == state%cursor_pos) then |
| 694 | + call collapse_selection(state) |
| 695 | + end if |
| 696 | + |
| 697 | + ! Selection rendering needs a full redraw (Sprint 2 handles the highlight). |
| 698 | + state%dirty = .true. |
| 699 | + call debug_selection_log('extend', state) |
| 700 | + end subroutine update_selection_on_shift_motion |
| 701 | + |
| 702 | + ! Remove the selected byte range from the buffer, set cursor to the left |
| 703 | + ! edge, and clear selection state. No-op if selection is not active. |
| 704 | + ! Unused in Sprint 1 itself, but lands now for use in Sprint 3. |
| 705 | + subroutine delete_selection(state) |
| 706 | + type(input_state_t), intent(inout) :: state |
| 707 | + integer :: sel_start, sel_end, span, i |
| 708 | + character(len=MAX_LINE_LEN) :: temp_buf |
| 709 | + |
| 710 | + if (.not. state%selection_active) return |
| 711 | + if (state%selection_anchor < 0) then |
| 712 | + ! Defensive: active flag set without an anchor; just clear state. |
| 713 | + call collapse_selection(state) |
| 714 | + return |
| 715 | + end if |
| 716 | + |
| 717 | + sel_start = min(state%selection_anchor, state%cursor_pos) |
| 718 | + sel_end = max(state%selection_anchor, state%cursor_pos) |
| 719 | + span = sel_end - sel_start |
| 720 | + |
| 721 | + if (span <= 0) then |
| 722 | + call collapse_selection(state) |
| 723 | + return |
| 724 | + end if |
| 725 | + |
| 726 | + ! Read current buffer, shift bytes after sel_end leftward, rewrite. |
| 727 | + call state_buffer_get(state, temp_buf) |
| 728 | + do i = sel_end + 1, state%length |
| 729 | + call state_buffer_set_char(state, i - span, temp_buf(i:i)) |
| 730 | + end do |
| 731 | + ! Pad the now-unused tail so stale bytes don't leak on later reads. |
| 732 | + do i = state%length - span + 1, state%length |
| 733 | + call state_buffer_set_char(state, i, ' ') |
| 734 | + end do |
| 735 | + |
| 736 | + state%length = state%length - span |
| 737 | + state%cursor_pos = sel_start |
| 738 | + state%dirty = .true. |
| 739 | + call collapse_selection(state) |
| 740 | + call debug_selection_log('delete', state) |
| 741 | + end subroutine delete_selection |
| 742 | + |
| 743 | + !============================================================================ |
| 744 | + ! END TEXT SELECTION HELPERS |
| 745 | + !============================================================================ |
| 746 | + |
| 595 | 747 | ! Initialize input_state_t with allocated strings |
| 596 | 748 | subroutine init_input_state(state) |
| 597 | 749 | type(input_state_t), intent(inout) :: state |
@@ -2817,6 +2969,17 @@ contains |
| 2817 | 2969 | subroutine move_to_next_word(input_state) |
| 2818 | 2970 | type(input_state_t), intent(inout) :: input_state |
| 2819 | 2971 | integer :: pos |
| 2972 | + integer :: old_cursor_pos |
| 2973 | + |
| 2974 | + ! Plain word-motion with active selection: clear, then proceed from |
| 2975 | + ! current cursor. No snap — word motion runs through the old cursor |
| 2976 | + ! anyway (#25, #26). |
| 2977 | + if (input_state%selection_active .and. .not. module_extending_selection) then |
| 2978 | + call collapse_selection(input_state) |
| 2979 | + input_state%dirty = .true. |
| 2980 | + end if |
| 2981 | + |
| 2982 | + old_cursor_pos = input_state%cursor_pos |
| 2820 | 2983 | |
| 2821 | 2984 | pos = input_state%cursor_pos + 1 |
| 2822 | 2985 | |
@@ -2847,13 +3010,32 @@ contains |
| 2847 | 3010 | end if |
| 2848 | 3011 | |
| 2849 | 3012 | input_state%dirty = .true. |
| 3013 | + |
| 3014 | + if (module_extending_selection) then |
| 3015 | + call update_selection_on_shift_motion(input_state, old_cursor_pos) |
| 3016 | + end if |
| 2850 | 3017 | end subroutine |
| 2851 | 3018 | |
| 2852 | 3019 | subroutine move_to_previous_word(input_state) |
| 2853 | 3020 | type(input_state_t), intent(inout) :: input_state |
| 2854 | 3021 | integer :: pos |
| 2855 | | - |
| 2856 | | - if (input_state%cursor_pos <= 0) return |
| 3022 | + integer :: old_cursor_pos |
| 3023 | + |
| 3024 | + ! Plain word-motion with active selection: clear, then proceed from |
| 3025 | + ! current cursor (#25, #26). |
| 3026 | + if (input_state%selection_active .and. .not. module_extending_selection) then |
| 3027 | + call collapse_selection(input_state) |
| 3028 | + input_state%dirty = .true. |
| 3029 | + end if |
| 3030 | + |
| 3031 | + old_cursor_pos = input_state%cursor_pos |
| 3032 | + |
| 3033 | + if (input_state%cursor_pos <= 0) then |
| 3034 | + if (module_extending_selection) then |
| 3035 | + call update_selection_on_shift_motion(input_state, old_cursor_pos) |
| 3036 | + end if |
| 3037 | + return |
| 3038 | + end if |
| 2857 | 3039 | |
| 2858 | 3040 | pos = input_state%cursor_pos - 1 |
| 2859 | 3041 | |
@@ -2872,6 +3054,10 @@ contains |
| 2872 | 3054 | ! so space position is correct (cursor will be after space, before first char of word) |
| 2873 | 3055 | input_state%cursor_pos = pos |
| 2874 | 3056 | input_state%dirty = .true. |
| 3057 | + |
| 3058 | + if (module_extending_selection) then |
| 3059 | + call update_selection_on_shift_motion(input_state, old_cursor_pos) |
| 3060 | + end if |
| 2875 | 3061 | end subroutine |
| 2876 | 3062 | |
| 2877 | 3063 | subroutine delete_char_at_cursor(input_state) |
@@ -5692,12 +5878,24 @@ contains |
| 5692 | 5878 | case('b') |
| 5693 | 5879 | ! Alt+b - Move backward one word |
| 5694 | 5880 | call move_to_previous_word(input_state) |
| 5881 | + case('B') |
| 5882 | + ! Alt+Shift+b - Extend selection one word back (shift phase, Sprint 1) |
| 5883 | + ! ESC-uppercase is xterm's encoding for Alt+Shift+letter. Routes through |
| 5884 | + ! the shift-extending path so move_to_previous_word grows the selection. |
| 5885 | + module_extending_selection = .true. |
| 5886 | + call move_to_previous_word(input_state) |
| 5887 | + module_extending_selection = .false. |
| 5695 | 5888 | case('d') |
| 5696 | 5889 | ! Alt+d - Delete forward one word (emacs standard) |
| 5697 | 5890 | call handle_kill_word_forward(input_state) |
| 5698 | 5891 | case('f') |
| 5699 | 5892 | ! Alt+f - Move forward one word |
| 5700 | 5893 | call move_to_next_word(input_state) |
| 5894 | + case('F') |
| 5895 | + ! Alt+Shift+f - Extend selection one word forward (shift phase, Sprint 1) |
| 5896 | + module_extending_selection = .true. |
| 5897 | + call move_to_next_word(input_state) |
| 5898 | + module_extending_selection = .false. |
| 5701 | 5899 | case('j') |
| 5702 | 5900 | ! Alt+j - Jump to directory with fzf |
| 5703 | 5901 | call launch_fzf_directory_browser(input_state) |
@@ -5906,6 +6104,50 @@ contains |
| 5906 | 6104 | input_state%suggestion_length > 0) then |
| 5907 | 6105 | call accept_autosuggestion_word(input_state) |
| 5908 | 6106 | end if |
| 6107 | + ! ============================================================ |
| 6108 | + ! Shift-phase selection extension (modifiers 2 and 6) |
| 6109 | + ! Sprint 1: state only; Sprint 2 adds the visible highlight. |
| 6110 | + ! ============================================================ |
| 6111 | + ! Shift+Left — extend selection one char back |
| 6112 | + else if (modifier == '2' .and. terminator == 'D') then |
| 6113 | + module_extending_selection = .true. |
| 6114 | + call handle_cursor_left(input_state) |
| 6115 | + module_extending_selection = .false. |
| 6116 | + ! Shift+Right — extend selection one char forward |
| 6117 | + else if (modifier == '2' .and. terminator == 'C') then |
| 6118 | + module_extending_selection = .true. |
| 6119 | + call handle_cursor_right(input_state) |
| 6120 | + module_extending_selection = .false. |
| 6121 | + ! Shift+Up — treat as Shift+Home on single-line prompt (#25) |
| 6122 | + else if (modifier == '2' .and. terminator == 'A') then |
| 6123 | + module_extending_selection = .true. |
| 6124 | + call handle_home(input_state) |
| 6125 | + module_extending_selection = .false. |
| 6126 | + ! Shift+Down — treat as Shift+End on single-line prompt (#25) |
| 6127 | + else if (modifier == '2' .and. terminator == 'B') then |
| 6128 | + module_extending_selection = .true. |
| 6129 | + call handle_end(input_state) |
| 6130 | + module_extending_selection = .false. |
| 6131 | + ! Shift+Home — extend selection to start of line |
| 6132 | + else if (modifier == '2' .and. terminator == 'H') then |
| 6133 | + module_extending_selection = .true. |
| 6134 | + call handle_home(input_state) |
| 6135 | + module_extending_selection = .false. |
| 6136 | + ! Shift+End — extend selection to end of line |
| 6137 | + else if (modifier == '2' .and. terminator == 'F') then |
| 6138 | + module_extending_selection = .true. |
| 6139 | + call handle_end(input_state) |
| 6140 | + module_extending_selection = .false. |
| 6141 | + ! Ctrl+Shift+Left — extend selection by one word back |
| 6142 | + else if (modifier == '6' .and. terminator == 'D') then |
| 6143 | + module_extending_selection = .true. |
| 6144 | + call move_to_previous_word(input_state) |
| 6145 | + module_extending_selection = .false. |
| 6146 | + ! Ctrl+Shift+Right — extend selection by one word forward |
| 6147 | + else if (modifier == '6' .and. terminator == 'C') then |
| 6148 | + module_extending_selection = .true. |
| 6149 | + call move_to_next_word(input_state) |
| 6150 | + module_extending_selection = .false. |
| 5909 | 6151 | ! Check for Alt+Left/Right for word movement (modifier=3) |
| 5910 | 6152 | else if (modifier == '3' .and. terminator == 'D') then |
| 5911 | 6153 | ! Alt+Left - Move cursor backward one word (standard behavior) |
@@ -5963,9 +6205,23 @@ contains |
| 5963 | 6205 | type(input_state_t), intent(inout) :: input_state |
| 5964 | 6206 | integer :: old_row, old_col, new_row, new_col, term_cols |
| 5965 | 6207 | integer :: bytes_to_move |
| 6208 | + integer :: old_cursor_pos |
| 5966 | 6209 | logical :: debug_utf8 |
| 5967 | 6210 | integer :: debug_stat |
| 5968 | 6211 | |
| 6212 | + ! Shift-phase: plain Left with an active selection snaps cursor to the |
| 6213 | + ! LEFT edge and clears selection, without further motion (#25, #26). |
| 6214 | + ! Char-motion uses the snap-to-edge convention (matches VS Code/TextEdit). |
| 6215 | + if (input_state%selection_active .and. .not. module_extending_selection) then |
| 6216 | + input_state%cursor_pos = min(input_state%selection_anchor, input_state%cursor_pos) |
| 6217 | + call collapse_selection(input_state) |
| 6218 | + input_state%dirty = .true. |
| 6219 | + return |
| 6220 | + end if |
| 6221 | + |
| 6222 | + ! Capture pre-motion cursor so shift-extending can anchor the selection. |
| 6223 | + old_cursor_pos = input_state%cursor_pos |
| 6224 | + |
| 5969 | 6225 | ! Check if UTF-8 debug mode is enabled |
| 5970 | 6226 | call get_environment_variable('FORTSH_DEBUG_UTF8', status=debug_stat) |
| 5971 | 6227 | debug_utf8 = (debug_stat == 0) |
@@ -6010,12 +6266,32 @@ contains |
| 6010 | 6266 | module_cursor_screen_row = new_row |
| 6011 | 6267 | module_cursor_screen_col = new_col |
| 6012 | 6268 | end if |
| 6269 | + |
| 6270 | + ! Shift-phase: if this call is extending a selection, update it now. |
| 6271 | + if (module_extending_selection) then |
| 6272 | + call update_selection_on_shift_motion(input_state, old_cursor_pos) |
| 6273 | + end if |
| 6013 | 6274 | end subroutine |
| 6014 | | - |
| 6275 | + |
| 6015 | 6276 | subroutine handle_cursor_right(input_state) |
| 6016 | 6277 | type(input_state_t), intent(inout) :: input_state |
| 6017 | 6278 | integer :: old_row, old_col, new_row, new_col, term_cols |
| 6018 | 6279 | integer :: bytes_to_move |
| 6280 | + integer :: old_cursor_pos |
| 6281 | + |
| 6282 | + ! Shift-phase: plain Right with an active selection snaps cursor to the |
| 6283 | + ! RIGHT edge and clears selection, without further motion (#25, #26). |
| 6284 | + if (input_state%selection_active .and. .not. module_extending_selection) then |
| 6285 | + input_state%cursor_pos = max(input_state%selection_anchor, input_state%cursor_pos) |
| 6286 | + if (input_state%cursor_pos > input_state%length) then |
| 6287 | + input_state%cursor_pos = input_state%length |
| 6288 | + end if |
| 6289 | + call collapse_selection(input_state) |
| 6290 | + input_state%dirty = .true. |
| 6291 | + return |
| 6292 | + end if |
| 6293 | + |
| 6294 | + old_cursor_pos = input_state%cursor_pos |
| 6019 | 6295 | |
| 6020 | 6296 | if (input_state%cursor_pos < input_state%length) then |
| 6021 | 6297 | ! Get terminal size |
@@ -6047,17 +6323,28 @@ contains |
| 6047 | 6323 | ! Update module cursor tracking |
| 6048 | 6324 | module_cursor_screen_row = new_row |
| 6049 | 6325 | module_cursor_screen_col = new_col |
| 6050 | | - else if (input_state%cursor_pos == input_state%length .and. input_state%suggestion_length > 0) then |
| 6051 | | - ! At end of line with suggestion - accept it |
| 6326 | + else if (input_state%cursor_pos == input_state%length .and. input_state%suggestion_length > 0 & |
| 6327 | + .and. .not. module_extending_selection) then |
| 6328 | + ! At end of line with suggestion - accept it (but not during shift-extension — |
| 6329 | + ! Shift+Right at the end of the line should not eat an autosuggestion). |
| 6052 | 6330 | call accept_autosuggestion(input_state) |
| 6053 | 6331 | end if |
| 6332 | + |
| 6333 | + ! Shift-phase: if this call is extending a selection, update it now. |
| 6334 | + if (module_extending_selection) then |
| 6335 | + call update_selection_on_shift_motion(input_state, old_cursor_pos) |
| 6336 | + end if |
| 6054 | 6337 | end subroutine |
| 6055 | | - |
| 6338 | + |
| 6056 | 6339 | subroutine handle_history_up(input_state) |
| 6057 | 6340 | type(input_state_t), intent(inout) :: input_state |
| 6058 | 6341 | character(len=MAX_LINE_LEN) :: history_line |
| 6059 | 6342 | logical :: found |
| 6060 | 6343 | |
| 6344 | + ! History navigation replaces the buffer wholesale — selection byte |
| 6345 | + ! offsets from the old buffer would point into stale data (#27). |
| 6346 | + if (input_state%selection_active) call collapse_selection(input_state) |
| 6347 | + |
| 6061 | 6348 | ! If there's text on the line and we're not yet in any history mode, |
| 6062 | 6349 | ! enter prefix search mode (fish-style) |
| 6063 | 6350 | if (.not. input_state%in_history .and. .not. input_state%in_prefix_search & |
@@ -6104,6 +6391,9 @@ contains |
| 6104 | 6391 | character(len=MAX_LINE_LEN) :: history_line |
| 6105 | 6392 | logical :: found |
| 6106 | 6393 | |
| 6394 | + ! Buffer replacement — clear any stale selection (#27). |
| 6395 | + if (input_state%selection_active) call collapse_selection(input_state) |
| 6396 | + |
| 6107 | 6397 | ! Prefix search: find next match or return to present |
| 6108 | 6398 | if (input_state%in_prefix_search) then |
| 6109 | 6399 | call prefix_search_move(input_state, +1) |
@@ -6733,6 +7023,17 @@ contains |
| 6733 | 7023 | ! Advanced line editing functions for Phase 5 |
| 6734 | 7024 | subroutine handle_home(input_state) |
| 6735 | 7025 | type(input_state_t), intent(inout) :: input_state |
| 7026 | + integer :: old_cursor_pos |
| 7027 | + |
| 7028 | + ! Plain motion with active selection: clear selection, then proceed with |
| 7029 | + ! normal motion. Home/End don't snap — they always go to 0/length — so a |
| 7030 | + ! simple clear is correct (#25, #26). |
| 7031 | + if (input_state%selection_active .and. .not. module_extending_selection) then |
| 7032 | + call collapse_selection(input_state) |
| 7033 | + input_state%dirty = .true. |
| 7034 | + end if |
| 7035 | + |
| 7036 | + old_cursor_pos = input_state%cursor_pos |
| 6736 | 7037 | |
| 6737 | 7038 | ! Move cursor to beginning of line |
| 6738 | 7039 | if (input_state%cursor_pos > 0) then |
@@ -6740,10 +7041,22 @@ contains |
| 6740 | 7041 | ! Mark dirty to trigger full redraw with correct cursor position |
| 6741 | 7042 | input_state%dirty = .true. |
| 6742 | 7043 | end if |
| 7044 | + |
| 7045 | + if (module_extending_selection) then |
| 7046 | + call update_selection_on_shift_motion(input_state, old_cursor_pos) |
| 7047 | + end if |
| 6743 | 7048 | end subroutine |
| 6744 | | - |
| 7049 | + |
| 6745 | 7050 | subroutine handle_end(input_state) |
| 6746 | 7051 | type(input_state_t), intent(inout) :: input_state |
| 7052 | + integer :: old_cursor_pos |
| 7053 | + |
| 7054 | + if (input_state%selection_active .and. .not. module_extending_selection) then |
| 7055 | + call collapse_selection(input_state) |
| 7056 | + input_state%dirty = .true. |
| 7057 | + end if |
| 7058 | + |
| 7059 | + old_cursor_pos = input_state%cursor_pos |
| 6747 | 7060 | |
| 6748 | 7061 | ! Move cursor to end of line |
| 6749 | 7062 | if (input_state%cursor_pos < input_state%length) then |
@@ -6751,6 +7064,10 @@ contains |
| 6751 | 7064 | ! Mark dirty to trigger full redraw with correct cursor position |
| 6752 | 7065 | input_state%dirty = .true. |
| 6753 | 7066 | end if |
| 7067 | + |
| 7068 | + if (module_extending_selection) then |
| 7069 | + call update_selection_on_shift_motion(input_state, old_cursor_pos) |
| 7070 | + end if |
| 6754 | 7071 | end subroutine |
| 6755 | 7072 | |
| 6756 | 7073 | subroutine handle_kill_to_end(input_state) |
@@ -7252,6 +7569,9 @@ contains |
| 7252 | 7569 | logical, intent(inout) :: done |
| 7253 | 7570 | character(len=5) :: cmd |
| 7254 | 7571 | |
| 7572 | + ! Buffer replacement — clear any stale selection (#27). |
| 7573 | + if (input_state%selection_active) call collapse_selection(input_state) |
| 7574 | + |
| 7255 | 7575 | cmd = 'cd ..' |
| 7256 | 7576 | |
| 7257 | 7577 | ! Clear current buffer and insert "cd .." |
@@ -7279,6 +7599,8 @@ contains |
| 7279 | 7599 | logical, intent(inout) :: done |
| 7280 | 7600 | character(len=5) :: cmd |
| 7281 | 7601 | |
| 7602 | + if (input_state%selection_active) call collapse_selection(input_state) |
| 7603 | + |
| 7282 | 7604 | cmd = 'prevd' |
| 7283 | 7605 | |
| 7284 | 7606 | ! Clear current buffer and insert "prevd" |
@@ -7306,6 +7628,8 @@ contains |
| 7306 | 7628 | logical, intent(inout) :: done |
| 7307 | 7629 | character(len=5) :: cmd |
| 7308 | 7630 | |
| 7631 | + if (input_state%selection_active) call collapse_selection(input_state) |
| 7632 | + |
| 7309 | 7633 | cmd = 'nextd' |
| 7310 | 7634 | |
| 7311 | 7635 | ! Clear current buffer and insert "nextd" |
@@ -8242,6 +8566,9 @@ contains |
| 8242 | 8566 | |
| 8243 | 8567 | if (input_state%suggestion_length == 0) return |
| 8244 | 8568 | |
| 8569 | + ! Buffer is about to be extended — any lingering selection is stale (#27). |
| 8570 | + if (input_state%selection_active) call collapse_selection(input_state) |
| 8571 | + |
| 8245 | 8572 | ! Safety check: ensure we won't overflow |
| 8246 | 8573 | new_length = input_state%length + input_state%suggestion_length |
| 8247 | 8574 | if (new_length > MAX_LINE_LEN) then |
@@ -8269,6 +8596,9 @@ contains |
| 8269 | 8596 | |
| 8270 | 8597 | if (input_state%suggestion_length == 0) return |
| 8271 | 8598 | |
| 8599 | + ! Buffer is about to be extended — any lingering selection is stale (#27). |
| 8600 | + if (input_state%selection_active) call collapse_selection(input_state) |
| 8601 | + |
| 8272 | 8602 | ! Find the end of the first word in the suggestion |
| 8273 | 8603 | word_end = 0 |
| 8274 | 8604 | do i = 1, input_state%suggestion_length |