Implement native vterm copy mode (visible screen)

ober

ee40bbb2404bf8752f853f8a2089df0c9c0ac3de

diff --git a/lib/jerboa-emacs/qt/app.sls b/lib/jerboa-emacs/qt/app.sls
index 3a88f8a..31e10af 100644
--- a/lib/jerboa-emacs/qt/app.sls
+++ b/lib/jerboa-emacs/qt/app.sls
@@ -1947,11 +1947,15 @@
              (lambda (fr)
                (let* ([win (qt-current-window fr)]
                       [buf (qt-edit-window-buffer win)]
+                      [container (qt-edit-window-container win)]
                       [view (and buf
                                  (hash-get *terminal-widget-map* buf)
+                                 (> (qt-stacked-widget-current-index
+                                      container)
+                                    0)
                                  (terminal-view-for-container
                                    buf
-                                   (qt-edit-window-container win)))])
+                                   container))])
                  (or view (qt-current-editor fr)))))
            (qt-window-set-pre-container-destroy-fn!
              (lambda (container)
diff --git a/lib/jerboa-emacs/qt/commands-aliases.sls b/lib/jerboa-emacs/qt/commands-aliases.sls
index 476ab6c..494621a 100644
--- a/lib/jerboa-emacs/qt/commands-aliases.sls
+++ b/lib/jerboa-emacs/qt/commands-aliases.sls
@@ -1120,6 +1120,9 @@
    (register-command! 'multi-vterm cmd-multi-vterm)
    (register-command! 'vterm-copy-mode cmd-vterm-copy-mode)
    (register-command! 'vterm-copy-done cmd-vterm-copy-done)
+   (register-command!
+     'vterm-copy-mode-done
+     cmd-vterm-copy-done)
    (register-command! 'lsp cmd-toggle-lsp)
    (register-command! 'lsp-start cmd-toggle-lsp)
    (register-command!
diff --git a/lib/jerboa-emacs/qt/commands-config.sls b/lib/jerboa-emacs/qt/commands-config.sls
index a4da9b0..e9075da 100644
--- a/lib/jerboa-emacs/qt/commands-config.sls
+++ b/lib/jerboa-emacs/qt/commands-config.sls
@@ -21,8 +21,8 @@
    *SCI_SETCODEPAGE* *SC_CP_UTF8* qt-insert-prompt!
    terminal-buffer-counter cmd-term cmd-terminal-send
    cmd-term-interrupt cmd-term-send-eof cmd-term-send-tab
-   cmd-multi-vterm *terminal-copy-mode* cmd-vterm-copy-mode
-   cmd-vterm-copy-done get-terminal-buffers
+   cmd-multi-vterm *terminal-copy-mode* vterm-native-session
+   cmd-vterm-copy-mode cmd-vterm-copy-done get-terminal-buffers
    qt-switch-to-terminal! cmd-term-list cmd-term-next
    cmd-term-prev cmd-ediff-files cmd-comment-dwim)
   (import
@@ -1174,35 +1174,71 @@
        (cmd-term app))
   (define *terminal-copy-mode*--cell
     (vector (make-hash-table)))
+  (def (vterm-native-session buf)
+       "Return the native QTerminalSession backing BUF, or #f."
+       (and buf (hash-get *terminal-widget-map* buf)))
   (def (cmd-vterm-copy-mode app)
-       "Toggle terminal copy mode — makes terminal read-only for text selection."
-       (let* ([buf (current-qt-buffer app)]
+       "Enter terminal copy mode: freeze the visible terminal screen into the editor\n   so you can move the cursor and select/copy text. Exit with vterm-copy-mode-done.\n   For a native QTerminalSession vterm this swaps the live view for a read-only\n   editor holding the screen text; the editor focus disengages the PTY fast-path\n   so ordinary motion (C-p/C-n/arrows) and mark/region (C-SPC … M-w) just work."
+       (let* ([fr (app-state-frame app)]
+              [win (qt-current-window fr)]
+              [buf (current-qt-buffer app)]
               [ed (current-qt-editor app)]
-              [echo (app-state-echo app)])
-         (if (terminal-buffer? buf)
-             (let ([in-copy (hash-get *terminal-copy-mode* buf)])
-               (if in-copy
-                   (begin
-                     (hash-put! *terminal-copy-mode* buf #f)
-                     (qt-plain-text-edit-set-read-only! ed #f)
-                     (echo-message! echo "Terminal copy mode OFF"))
-                   (begin
-                     (hash-put! *terminal-copy-mode* buf #t)
-                     (qt-plain-text-edit-set-read-only! ed #t)
-                     (echo-message!
-                       echo
-                       "Terminal copy mode ON — select text, C-w/M-w to copy"))))
-             (echo-message! echo "Not in a terminal buffer"))))
+              [echo (app-state-echo app)]
+              [session (vterm-native-session buf)])
+         (cond
+           [session
+            (if (hash-get *terminal-copy-mode* buf)
+                (echo-message!
+                  echo
+                  "Already in copy mode (vterm-copy-mode-done to exit)")
+                (let ([container (qt-edit-window-container win)]
+                      [text (qt-terminal-get-screen-text session)])
+                  (qt-plain-text-edit-set-read-only! ed #f)
+                  (qt-plain-text-edit-set-text! ed text)
+                  (qt-plain-text-edit-set-read-only! ed #t)
+                  (qt-stacked-widget-set-current-widget! container ed)
+                  (qt-widget-set-focus! ed)
+                  (qt-plain-text-edit-move-cursor! ed QT_CURSOR_END)
+                  (hash-put! *terminal-copy-mode* buf #t)
+                  (echo-message!
+                    echo
+                    "Copy mode: move/select text, then vterm-copy-mode-done")))]
+           [(terminal-buffer? buf)
+            (hash-put! *terminal-copy-mode* buf #t)
+            (qt-plain-text-edit-set-read-only! ed #t)
+            (echo-message!
+              echo
+              "Terminal copy mode ON — select text, C-w/M-w to copy")]
+           [else (echo-message! echo "Not in a terminal buffer")])))
   (def (cmd-vterm-copy-done app)
-       "Exit terminal copy mode and resume terminal."
-       (let* ([buf (current-qt-buffer app)]
+       "Exit terminal copy mode: copy any active selection to the kill ring, then\n   return to the live terminal."
+       (let* ([fr (app-state-frame app)]
+              [win (qt-current-window fr)]
+              [buf (current-qt-buffer app)]
               [ed (current-qt-editor app)]
-              [echo (app-state-echo app)])
-         (when (and (terminal-buffer? buf)
-                    (hash-get *terminal-copy-mode* buf))
-           (hash-put! *terminal-copy-mode* buf #f)
-           (qt-plain-text-edit-set-read-only! ed #f)
-           (echo-message! echo "Terminal copy mode OFF"))))
+              [echo (app-state-echo app)]
+              [session (vterm-native-session buf)])
+         (cond
+           [(and session (hash-get *terminal-copy-mode* buf))
+            (when (qt-plain-text-edit-has-selection? ed)
+              (let ([sel (qt-plain-text-edit-selected-text ed)])
+                (when (and sel (> (string-length sel) 0))
+                  (qt-kill-ring-push! app sel)
+                  (qt-plain-text-edit-copy! ed))))
+            (qt-plain-text-edit-set-read-only! ed #f)
+            (let* ([container (qt-edit-window-container win)]
+                   [view (terminal-view-for-container buf container)])
+              (when view
+                (qt-stacked-widget-set-current-widget! container view)
+                (qt-terminal-view-focus! view)))
+            (hash-put! *terminal-copy-mode* buf #f)
+            (echo-message! echo "Copy mode done")]
+           [(and (terminal-buffer? buf)
+                 (hash-get *terminal-copy-mode* buf))
+            (hash-put! *terminal-copy-mode* buf #f)
+            (qt-plain-text-edit-set-read-only! ed #f)
+            (echo-message! echo "Terminal copy mode OFF")]
+           [else (echo-message! echo "Not in copy mode")])))
   (def (get-terminal-buffers)
        "Return list of terminal buffers from buffer-list."
        (filter terminal-buffer? *buffer-list*))
diff --git a/lib/jerboa-emacs/qt/sci-shim.sls b/lib/jerboa-emacs/qt/sci-shim.sls
index 23a9280..4f53a25 100644
--- a/lib/jerboa-emacs/qt/sci-shim.sls
+++ b/lib/jerboa-emacs/qt/sci-shim.sls
@@ -140,10 +140,11 @@
    qt-terminal-create qt-terminal-destroy! qt-terminal-spawn!
    qt-terminal-connect-fd! qt-terminal-send-key-event!
    qt-terminal-send-input! qt-terminal-is-running?
-   qt-terminal-interrupt! qt-terminal-set-font!
-   qt-terminal-set-colors! qt-terminal-focus!
-   qt-terminal-widget qt-terminal-view-create
-   qt-terminal-view-destroy! qt-terminal-view-focus!)
+   qt-terminal-get-screen-text qt-terminal-interrupt!
+   qt-terminal-set-font! qt-terminal-set-colors!
+   qt-terminal-focus! qt-terminal-widget
+   qt-terminal-view-create qt-terminal-view-destroy!
+   qt-terminal-view-focus!)
   (import
     (except (chezscheme) make-hash-table hash-table? iota \x31;+ \x31;-
       getenv path-extension path-absolute? thread? make-mutex
@@ -655,6 +656,12 @@
        (= 1
           ((foreign-procedure "qt_terminal_is_running" (void*) int)
             term)))
+  (def (qt-terminal-get-screen-text term)
+       "Return the visible terminal screen as plain text (for copy mode)."
+       ((foreign-procedure "qt_terminal_get_screen_text"
+          (void*)
+          string)
+         term))
   (def (qt-terminal-interrupt! term)
        "Send SIGINT to the terminal's child process."
        ((foreign-procedure "qt_terminal_interrupt" (void*) void)
diff --git a/src/jerboa-emacs/qt/app.ss b/src/jerboa-emacs/qt/app.ss
index 3165842..cbf6768 100644
--- a/src/jerboa-emacs/qt/app.ss
+++ b/src/jerboa-emacs/qt/app.ss
@@ -1506,10 +1506,14 @@
           (lambda (fr)
             (let* ((win (qt-current-window fr))
                    (buf (qt-edit-window-buffer win))
+                   (container (qt-edit-window-container win))
+                   ;; Only target the live terminal view when it is actually the
+                   ;; shown widget (stack page > 0). In vterm copy mode the stack
+                   ;; shows the editor (page 0), so keys must go to the editor.
                    (view (and buf
                               (hash-get *terminal-widget-map* buf)
-                              (terminal-view-for-container
-                                buf (qt-edit-window-container win)))))
+                              (> (qt-stacked-widget-current-index container) 0)
+                              (terminal-view-for-container buf container))))
               (or view (qt-current-editor fr)))))
 
         ;; Install pre-container-destroy hook: when any window container
diff --git a/src/jerboa-emacs/qt/commands-aliases.ss b/src/jerboa-emacs/qt/commands-aliases.ss
index e9a7061..c67e439 100644
--- a/src/jerboa-emacs/qt/commands-aliases.ss
+++ b/src/jerboa-emacs/qt/commands-aliases.ss
@@ -822,6 +822,7 @@
   (register-command! 'multi-vterm cmd-multi-vterm)
   (register-command! 'vterm-copy-mode cmd-vterm-copy-mode)
   (register-command! 'vterm-copy-done cmd-vterm-copy-done)
+  (register-command! 'vterm-copy-mode-done cmd-vterm-copy-done)  ;; emacs-libvterm name
   ;; Image mode commands — moved to commands-aliases2.ss
   ;; LSP commands
   (register-command! 'lsp cmd-toggle-lsp)   ; alias: M-x lsp
diff --git a/src/jerboa-emacs/qt/commands-config.ss b/src/jerboa-emacs/qt/commands-config.ss
index 87f261b..7e3e1c5 100644
--- a/src/jerboa-emacs/qt/commands-config.ss
+++ b/src/jerboa-emacs/qt/commands-config.ss
@@ -998,35 +998,77 @@ modified so the next save uses the new encoding."
 ;; Track which terminal buffers are in copy mode
 (def *terminal-copy-mode* (make-hash-table))
 
+(def (vterm-native-session buf)
+  "Return the native QTerminalSession backing BUF, or #f."
+  (and buf (hash-get *terminal-widget-map* buf)))
+
 (def (cmd-vterm-copy-mode app)
-  "Toggle terminal copy mode — makes terminal read-only for text selection."
-  (let* ((buf (current-qt-buffer app))
+  "Enter terminal copy mode: freeze the visible terminal screen into the editor
+   so you can move the cursor and select/copy text. Exit with vterm-copy-mode-done.
+   For a native QTerminalSession vterm this swaps the live view for a read-only
+   editor holding the screen text; the editor focus disengages the PTY fast-path
+   so ordinary motion (C-p/C-n/arrows) and mark/region (C-SPC … M-w) just work."
+  (let* ((fr (app-state-frame app))
+         (win (qt-current-window fr))
+         (buf (current-qt-buffer app))
          (ed (current-qt-editor app))
-         (echo (app-state-echo app)))
-    (if (terminal-buffer? buf)
-      (let ((in-copy (hash-get *terminal-copy-mode* buf)))
-        (if in-copy
-          ;; Exit copy mode
-          (begin
-            (hash-put! *terminal-copy-mode* buf #f)
-            (qt-plain-text-edit-set-read-only! ed #f)
-            (echo-message! echo "Terminal copy mode OFF"))
-          ;; Enter copy mode
-          (begin
-            (hash-put! *terminal-copy-mode* buf #t)
-            (qt-plain-text-edit-set-read-only! ed #t)
-            (echo-message! echo "Terminal copy mode ON — select text, C-w/M-w to copy"))))
-      (echo-message! echo "Not in a terminal buffer"))))
+         (echo (app-state-echo app))
+         (session (vterm-native-session buf)))
+    (cond
+      (session
+       (if (hash-get *terminal-copy-mode* buf)
+         (echo-message! echo "Already in copy mode (vterm-copy-mode-done to exit)")
+         (let ((container (qt-edit-window-container win))
+               (text (qt-terminal-get-screen-text session)))
+           (qt-plain-text-edit-set-read-only! ed #f)   ;; allow the screen dump in
+           (qt-plain-text-edit-set-text! ed text)
+           (qt-plain-text-edit-set-read-only! ed #t)
+           (qt-stacked-widget-set-current-widget! container ed)
+           (qt-widget-set-focus! ed)
+           (qt-plain-text-edit-move-cursor! ed QT_CURSOR_END)
+           (hash-put! *terminal-copy-mode* buf #t)
+           (echo-message! echo "Copy mode: move/select text, then vterm-copy-mode-done"))))
+      ;; Legacy line-terminal: read-only toggle on the editor (old behavior).
+      ((terminal-buffer? buf)
+       (hash-put! *terminal-copy-mode* buf #t)
+       (qt-plain-text-edit-set-read-only! ed #t)
+       (echo-message! echo "Terminal copy mode ON — select text, C-w/M-w to copy"))
+      (else
+       (echo-message! echo "Not in a terminal buffer")))))
 
 (def (cmd-vterm-copy-done app)
-  "Exit terminal copy mode and resume terminal."
-  (let* ((buf (current-qt-buffer app))
+  "Exit terminal copy mode: copy any active selection to the kill ring, then
+   return to the live terminal."
+  (let* ((fr (app-state-frame app))
+         (win (qt-current-window fr))
+         (buf (current-qt-buffer app))
          (ed (current-qt-editor app))
-         (echo (app-state-echo app)))
-    (when (and (terminal-buffer? buf) (hash-get *terminal-copy-mode* buf))
-      (hash-put! *terminal-copy-mode* buf #f)
-      (qt-plain-text-edit-set-read-only! ed #f)
-      (echo-message! echo "Terminal copy mode OFF"))))
+         (echo (app-state-echo app))
+         (session (vterm-native-session buf)))
+    (cond
+      ((and session (hash-get *terminal-copy-mode* buf))
+       ;; Grab the active selection (if any) before leaving.
+       (when (qt-plain-text-edit-has-selection? ed)
+         (let ((sel (qt-plain-text-edit-selected-text ed)))
+           (when (and sel (> (string-length sel) 0))
+             (qt-kill-ring-push! app sel)
+             (qt-plain-text-edit-copy! ed))))   ;; also to the system clipboard
+       (qt-plain-text-edit-set-read-only! ed #f)
+       ;; Restore the live terminal view for this window.
+       (let* ((container (qt-edit-window-container win))
+              (view (terminal-view-for-container buf container)))
+         (when view
+           (qt-stacked-widget-set-current-widget! container view)
+           (qt-terminal-view-focus! view)))
+       (hash-put! *terminal-copy-mode* buf #f)
+       (echo-message! echo "Copy mode done"))
+      ;; Legacy line-terminal copy mode
+      ((and (terminal-buffer? buf) (hash-get *terminal-copy-mode* buf))
+       (hash-put! *terminal-copy-mode* buf #f)
+       (qt-plain-text-edit-set-read-only! ed #f)
+       (echo-message! echo "Terminal copy mode OFF"))
+      (else
+       (echo-message! echo "Not in copy mode")))))
 
 (def (get-terminal-buffers)
   "Return list of terminal buffers from buffer-list."
diff --git a/src/jerboa-emacs/qt/sci-shim.ss b/src/jerboa-emacs/qt/sci-shim.ss
index 6b40cd4..221cb92 100644
--- a/src/jerboa-emacs/qt/sci-shim.ss
+++ b/src/jerboa-emacs/qt/sci-shim.ss
@@ -141,7 +141,7 @@
   qt-terminal-create qt-terminal-destroy! qt-terminal-spawn!
   qt-terminal-connect-fd!
   qt-terminal-send-key-event! qt-terminal-send-input!
-  qt-terminal-is-running? qt-terminal-interrupt!
+  qt-terminal-is-running? qt-terminal-get-screen-text qt-terminal-interrupt!
   qt-terminal-set-font! qt-terminal-set-colors!
   qt-terminal-focus! qt-terminal-widget
   qt-terminal-view-create qt-terminal-view-destroy! qt-terminal-view-focus!)
@@ -645,6 +645,10 @@
   "Check if the terminal's child process is still running."
   (= 1 ((foreign-procedure "qt_terminal_is_running" (void*) int) term)))
 
+(def (qt-terminal-get-screen-text term)
+  "Return the visible terminal screen as plain text (for copy mode)."
+  ((foreign-procedure "qt_terminal_get_screen_text" (void*) string) term))
+
 (def (qt-terminal-interrupt! term)
   "Send SIGINT to the terminal's child process."
   ((foreign-procedure "qt_terminal_interrupt" (void*) void) term))
diff --git a/support/vendor-overrides/qt_shim.cpp b/support/vendor-overrides/qt_shim.cpp
index 48b8e20..441da4e 100644
--- a/support/vendor-overrides/qt_shim.cpp
+++ b/support/vendor-overrides/qt_shim.cpp
@@ -8266,6 +8266,42 @@ extern "C" int qt_terminal_is_running(qt_terminal_t term) {
     );
 }
 
+// Dump the visible terminal screen (rows x cols) as plain UTF-8 text, one row
+// per line, trailing spaces and trailing blank lines stripped. Used by the
+// Scheme `vterm-copy-mode` command to freeze the screen into an editor buffer
+// so the user can move the cursor and select/copy text. (Scrollback is not
+// retained — see cb_sb_pushline — so only the current screen is returned.)
+extern "C" const char* qt_terminal_get_screen_text(qt_terminal_t term) {
+    QT_NULL_CHECK_RET(term, "");
+    QTerminalSession* s = static_cast<QTerminalSession*>(term);
+    QT_RETURN_STRING(([&]() -> std::string {
+        VTermScreen* screen = s->m_screen;
+        if (!screen) return std::string();
+        int rows = s->m_rows, cols = s->m_cols;
+        QString out;
+        for (int row = 0; row < rows; ++row) {
+            QString line;
+            for (int col = 0; col < cols; ++col) {
+                VTermPos pos = { row, col };
+                VTermScreenCell cell;
+                vterm_screen_get_cell(screen, pos, &cell);
+                if (cell.chars[0] != 0) {
+                    line += QString::fromUcs4(&cell.chars[0], 1);
+                    if (cell.width > 1) col += (cell.width - 1);
+                } else {
+                    line += QLatin1Char(' ');
+                }
+            }
+            while (line.endsWith(QLatin1Char(' '))) line.chop(1);
+            out += line;
+            out += QLatin1Char('\n');
+        }
+        while (out.endsWith(QLatin1String("\n\n"))) out.chop(1);
+        if (out.endsWith(QLatin1Char('\n'))) out.chop(1);
+        return std::string(out.toUtf8().constData());
+    })());
+}
+
 // Compatibility shim: return a representative view (active-or-first) as a
 // QWidget*. The session itself is NOT a widget. Prefer the per-view FFI.
 extern "C" qt_widget_t qt_terminal_widget(qt_terminal_t term) {