This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-24395
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 3469ba8bfb549eb43a0c3c03673b1a280959ee4a
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 2 19:44:39 2026 +0200

    CAMEL-24395: camel-jbang - keep the code in place when switching between 
viewing and editing a source file
    
    F4 opens the editor on the same screen as the view: the top line stays and 
the cursor is on the selected line,
    instead of scrolling the cursor to two thirds down. Leaving the editor with 
Esc, or with F5 save and close, selects
    the cursor line in the view and keeps the top line, where before the view 
went back to the line it had before
    editing, or to the top of the file after a save.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../dsl/jbang/core/commands/tui/SourceViewer.java  |  47 +++++-
 .../commands/tui/SourceViewerEditPositionTest.java | 165 +++++++++++++++++++++
 2 files changed, 207 insertions(+), 5 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
index 63a1c5feb592..08d761bdf6bc 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
@@ -238,6 +238,8 @@ class SourceViewer {
      */
     private Map<Integer, String> viewErrors = Collections.emptyMap();
     private boolean editInitialScroll;
+    /** The top line of the view when F4 was pressed, so the editor opens on 
the same screen; -1 when not known. */
+    private int editStartTop = -1;
     private long lastBackgroundValidationTime;
     private String lastBackgroundValidationContent;
     private static final long BACKGROUND_VALIDATION_INTERVAL_MS = 2000;
@@ -1134,6 +1136,7 @@ class SourceViewer {
         }
         editState.moveCursorToLineStart();
         editInitialScroll = true;
+        editStartTop = markdownMode ? -1 : scrollY;
         markdownModeBeforeEdit = markdownMode;
         markdownMode = false;
         quickDocEnabled = false;
@@ -1206,6 +1209,8 @@ class SourceViewer {
 
     private void exitEditMode() {
         boolean wasEditing = editMode;
+        int cursorRow = editState.cursorRow();
+        int top = editState.scrollRow();
         editMode = false;
         editState.clear();
         editHistory.clear();
@@ -1223,6 +1228,29 @@ class SourceViewer {
             markdownMode = markdownModeBeforeEdit;
         }
         markdownModeBeforeEdit = false;
+        editStartTop = -1;
+        if (wasEditing) {
+            keepEditorPosition(cursorRow, top);
+        }
+    }
+
+    /**
+     * The view continues where the editor was: the cursor line is selected 
and the same line is at the top, so leaving
+     * the editor does not move the code on the screen.
+     */
+    /** The top line the editor opens on: the top line of the view, moved only 
as far as the cursor must stay seen. */
+    static int editorTopKeepingCursor(int viewTop, int cursorRow, int 
viewportHeight) {
+        int top = Math.min(Math.max(0, viewTop), cursorRow);
+        return Math.max(top, cursorRow - Math.max(1, viewportHeight) + 1);
+    }
+
+    private void keepEditorPosition(int cursorRow, int top) {
+        if (lines.isEmpty()) {
+            return;
+        }
+        selectedLine = Math.min(Math.max(0, cursorRow), lines.size() - 1);
+        scrollY = Math.min(Math.max(0, top), selectedLine);
+        pendingScroll = false;
     }
 
     private boolean isPropertiesFile() {
@@ -1925,11 +1953,15 @@ class SourceViewer {
             dirty = false;
             Path path = editableFile;
             boolean restoreMarkdownMode = markdownModeBeforeEdit;
+            int cursorRow = editState.cursorRow();
+            int top = editState.scrollRow();
             notifySaved(editableFile);
             editMode = false;
             editState.clear();
             markdownModeBeforeEdit = false;
+            editStartTop = -1;
             loadFile(path);
+            keepEditorPosition(cursorRow, top);
             if (isMarkdownFile) {
                 markdownMode = restoreMarkdownMode;
             }
@@ -2626,14 +2658,19 @@ class SourceViewer {
                 .showLineNumbers(!plainMode)
                 .lineNumberStyle(Style.EMPTY.dim())
                 .build();
-        // on first render, position cursor at 2/3 of viewport before TextArea 
renders
+        // on first render, keep the top line of the view (F4), else position 
the cursor at 2/3 of the viewport
         if (editInitialScroll) {
             editInitialScroll = false;
             int viewportH = textAreaRect.height();
-            int twoThirds = viewportH * 2 / 3;
-            int targetScroll = Math.max(0, editState.cursorRow() - twoThirds);
-            if (targetScroll > 0) {
-                editState.scrollDown(targetScroll, viewportH);
+            int targetScroll = editStartTop >= 0
+                    ? editorTopKeepingCursor(editStartTop, 
editState.cursorRow(), viewportH)
+                    : Math.max(0, editState.cursorRow() - viewportH * 2 / 3);
+            editStartTop = -1;
+            int delta = targetScroll - editState.scrollRow();
+            if (delta > 0) {
+                editState.scrollDown(delta, viewportH);
+            } else if (delta < 0) {
+                editState.scrollUp(-delta);
             }
         }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditPositionTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditPositionTest.java
new file mode 100644
index 000000000000..efd944896faa
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditPositionTest.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Switching between viewing and editing a file (F4, Esc, F5) keeps the code 
where it is on the screen (CAMEL-24395).
+ */
+class SourceViewerEditPositionTest {
+
+    private static final Pattern LINE_NUMBER = Pattern.compile("^│(>>| . |   
)\\s*(\\d+) \\|");
+
+    @TempDir
+    Path tempDir;
+
+    private SourceViewer viewer;
+    private Path sourceFile;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        Theme.resetForTesting();
+        viewer = new SourceViewer();
+        viewer.setValidateOnSave(false);
+        List<String> lines = new ArrayList<>();
+        for (int i = 1; i <= 100; i++) {
+            lines.add("key" + i + "=value" + i);
+        }
+        sourceFile = tempDir.resolve("notes.properties");
+        Files.writeString(sourceFile, String.join("\n", lines) + "\n");
+        viewer.loadFile(sourceFile);
+    }
+
+    @Test
+    void editorOpensOnTheScreenOfTheView() {
+        viewer.goToLine(49);
+        render();
+        // move the selection up within the screen, away from where the editor 
used to put the cursor
+        for (int i = 0; i < 8; i++) {
+            key(KeyCode.UP);
+        }
+        Screen view = render();
+        assertThat(view.selectedLine()).isEqualTo(42);
+
+        key(KeyCode.F4);
+        Screen edit = render();
+
+        assertThat(viewer.isEditMode()).isTrue();
+        assertThat(edit.topLine()).isEqualTo(view.topLine());
+        assertThat(edit.selectedLine()).isEqualTo(42);
+        assertThat(edit.selectedRow()).isEqualTo(view.selectedRow());
+    }
+
+    @Test
+    void leavingTheEditorKeepsTheCursorLineAndTheScreen() {
+        viewer.goToLine(49);
+        render();
+        key(KeyCode.F4);
+        render();
+        for (int i = 0; i < 10; i++) {
+            key(KeyCode.UP);
+        }
+        Screen edit = render();
+        assertThat(edit.selectedLine()).isEqualTo(40);
+
+        key(KeyCode.ESCAPE);
+        Screen view = render();
+
+        assertThat(viewer.isEditMode()).isFalse();
+        assertThat(viewer.getSelectedLine()).isEqualTo(39);
+        assertThat(view.topLine()).isEqualTo(edit.topLine());
+        assertThat(view.selectedRow()).isEqualTo(edit.selectedRow());
+    }
+
+    @Test
+    void saveAndCloseKeepsTheCursorLine() throws Exception {
+        viewer.goToLine(49);
+        render();
+        key(KeyCode.F4);
+        render();
+        for (int i = 0; i < 5; i++) {
+            key(KeyCode.DOWN);
+        }
+        viewer.handleKeyEvent(KeyEvent.ofChar('x', KeyModifiers.NONE));
+        Screen edit = render();
+
+        key(KeyCode.F5);
+        Screen view = render();
+
+        assertThat(viewer.isEditMode()).isFalse();
+        assertThat(Files.readString(sourceFile)).contains("xkey55=value55");
+        assertThat(view.selectedLine()).isEqualTo(55);
+        assertThat(view.topLine()).isEqualTo(edit.topLine());
+    }
+
+    @Test
+    void editorTopMovesOnlyAsFarAsTheCursorMustBeSeen() {
+        assertThat(SourceViewer.editorTopKeepingCursor(27, 49, 
24)).isEqualTo(27);
+        assertThat(SourceViewer.editorTopKeepingCursor(0, 49, 
24)).isEqualTo(26);
+        assertThat(SourceViewer.editorTopKeepingCursor(60, 49, 
24)).isEqualTo(49);
+    }
+
+    private void key(KeyCode code) {
+        viewer.handleKeyEvent(KeyEvent.ofKey(code, KeyModifiers.NONE));
+    }
+
+    private Screen render() {
+        Rect area = new Rect(0, 0, 60, 20);
+        Buffer buffer = Buffer.empty(area);
+        viewer.render(Frame.forTesting(buffer), area);
+        String[] rows = TuiTestHelper.bufferToString(buffer).split("\n");
+        int top = -1;
+        int selectedRow = -1;
+        int selected = -1;
+        for (int r = 0; r < rows.length; r++) {
+            Matcher m = LINE_NUMBER.matcher(rows[r]);
+            if (m.find()) {
+                int number = Integer.parseInt(m.group(2));
+                if (top < 0) {
+                    top = number;
+                }
+                if (">>".equals(m.group(1))) {
+                    selectedRow = r;
+                    selected = number;
+                }
+            }
+        }
+        return new Screen(top, selectedRow, selected);
+    }
+
+    /** The first line number on the screen, and the screen row and number of 
the line marked with >>. */
+    private record Screen(int topLine, int selectedRow, int selectedLine) {
+    }
+}

Reply via email to