This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch tui-click-pair-keys in repository https://gitbox.apache.org/repos/asf/camel.git
commit 0b7d4e99018746e341674f4c1fdf93fc45710112 Author: Claus Ibsen <[email protected]> AuthorDate: Sun Oct 25 19:39:23 2026 +0100 chore: camel-jbang - a click on the +/-, f/F and Space hints of the route diagram presses the key A footer hint was clickable only when its key was one character, an F-key, Enter, Esc or Tab, so +/- (zoom), f/F (fold/expand all) and Space (fold) did nothing when clicked, in the footer and on the view bar. A hint of two keys now presses the key on the side of the slash that was clicked, and a label naming both (fold/expand all) does the same. Space is a key. The hint clicks move out of CamelMonitor into HintClicks. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../dsl/jbang/core/commands/tui/CamelMonitor.java | 126 +------------- .../dsl/jbang/core/commands/tui/HintClicks.java | 192 +++++++++++++++++++++ .../dsl/jbang/core/commands/tui/SubViewBar.java | 18 +- .../jbang/core/commands/tui/CamelMonitorTest.java | 46 ----- .../jbang/core/commands/tui/HintClicksTest.java | 130 ++++++++++++++ .../jbang/core/commands/tui/SourceSizeTest.java | 2 +- .../jbang/core/commands/tui/SubViewBarTest.java | 19 ++ 7 files changed, 367 insertions(+), 166 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index f498f5d935fb..7dc26fb107db 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -51,7 +51,6 @@ import dev.tamboui.tui.TuiRunner; import dev.tamboui.tui.event.Event; import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; -import dev.tamboui.tui.event.KeyModifiers; import dev.tamboui.tui.event.MouseEvent; import dev.tamboui.tui.event.MouseEventKind; import dev.tamboui.tui.event.PasteEvent; @@ -260,12 +259,8 @@ public class CamelMonitor extends CamelCommand { private final DragSplit panelSplit = new DragSplit(); /** The views and view settings of the active tab, on the first row of its content. */ private final SubViewBar subViewBar = new SubViewBar(); - // Footer key-binding hit-testing: each clickable hint records its [startX, endX) column range on - // the footer row and the KeyEvent to synthesize when clicked. - private int footerRowY = -1; - private int[] footerRegionStartX = new int[0]; - private int[] footerRegionEndX = new int[0]; - private KeyEvent[] footerRegionKey = new KeyEvent[0]; + /** Where the key hints of the footer are, so a click on one presses its key. */ + private final HintClicks footerClicks = new HintClicks(); private final ClassLoader classLoader; @@ -1604,7 +1599,7 @@ public class CamelMonitor extends CamelCommand { view.select().run(); return true; } - KeyEvent key = footerKeyEvent(subViewBar.keyAt(me.x(), me.y())); + KeyEvent key = HintClicks.keyEvent(subViewBar.keyAt(me.x(), me.y())); if (key != null) { return handleEvent(key, runner); } @@ -1663,115 +1658,10 @@ public class CamelMonitor extends CamelCommand { return -1; } - /** - * Records the clickable footer key-binding regions from the final list of footer spans. A hint is drawn by - * {@link TuiHelper#hint} as a key span styled with {@link Theme#hintKey()} immediately followed by its label span, - * so each key span styled that way (whose token maps to an unambiguous single key) contributes a clickable region - * covering both the key and its label. {@code area} is the single footer row. - */ - private void captureFooterRegions(Rect area, List<Span> spans) { - List<int[]> bounds = new ArrayList<>(); - List<KeyEvent> keys = new ArrayList<>(); - Style hintKeyStyle = Theme.hintKey(); - int x = area.x(); - for (int i = 0; i < spans.size(); i++) { - Span s = spans.get(i); - int w = s.width(); - if (hintKeyStyle.equals(s.style())) { - KeyEvent ke = footerKeyEvent(s.content()); - if (ke != null) { - // Extend the region over the following label span so clicking the label works too. - int end = x + w + (i + 1 < spans.size() ? spans.get(i + 1).width() : 0); - bounds.add(new int[] { x, end }); - keys.add(ke); - } - } - x += w; - } - footerRowY = area.y(); - footerRegionStartX = bounds.stream().mapToInt(b -> b[0]).toArray(); - footerRegionEndX = bounds.stream().mapToInt(b -> b[1]).toArray(); - footerRegionKey = keys.toArray(new KeyEvent[0]); - } - - /** - * Hit-tests a click against the footer key-binding regions captured during the last render and, when it lands on a - * hint, feeds the synthesized key back through the normal key path so it acts exactly like pressing that key. - */ + /** A click on a key hint of the footer presses its key, as if it was typed. */ private boolean handleFooterClick(MouseEvent me, TuiRunner runner) { - if (footerRowY < 0 || me.y() != footerRowY) { - return false; - } - int idx = footerRegionAt(footerRegionStartX, footerRegionEndX, me.x()); - if (idx < 0) { - return false; - } - return handleEvent(footerRegionKey[idx], runner); - } - - /** - * Returns the index of the footer hint region whose column range contains {@code mouseX}, or {@code -1} when the - * click is outside every region. Each region spans the half-open range {@code [startX[i], endX[i])}. - */ - static int footerRegionAt(int[] startX, int[] endX, int mouseX) { - if (startX == null) { - return -1; - } - for (int i = 0; i < startX.length; i++) { - if (mouseX >= startX[i] && mouseX < endX[i]) { - return i; - } - } - return -1; - } - - /** - * Maps a footer hint key token (the trimmed content of a {@link Theme#hintKey()} span) to the {@link KeyEvent} that - * pressing it would produce, or {@code null} when the token is not an unambiguous single key. Recognizes the - * function keys {@code F1}-{@code F12}, {@code Enter}, {@code Esc}, {@code Tab} and any single character; ambiguous - * multi-key hints such as {@code Up/Down}, {@code PgUp/PgDn} or arrow glyphs are left non-clickable. - */ - static KeyEvent footerKeyEvent(String token) { - if (token == null) { - return null; - } - String t = token.trim(); - if (t.isEmpty()) { - return null; - } - if (t.length() >= 2 && (t.charAt(0) == 'F' || t.charAt(0) == 'f') - && t.chars().skip(1).allMatch(Character::isDigit)) { - KeyCode fkey = functionKeyCode(Integer.parseInt(t.substring(1))); - return fkey != null ? KeyEvent.ofKey(fkey) : null; - } - switch (t) { - case "Enter": - return KeyEvent.ofKey(KeyCode.ENTER); - case "Esc": - return KeyEvent.ofKey(KeyCode.ESCAPE); - case "Tab": - return KeyEvent.ofKey(KeyCode.TAB); - default: - return t.length() == 1 ? KeyEvent.ofChar(t.charAt(0), KeyModifiers.NONE) : null; - } - } - - private static KeyCode functionKeyCode(int n) { - return switch (n) { - case 1 -> KeyCode.F1; - case 2 -> KeyCode.F2; - case 3 -> KeyCode.F3; - case 4 -> KeyCode.F4; - case 5 -> KeyCode.F5; - case 6 -> KeyCode.F6; - case 7 -> KeyCode.F7; - case 8 -> KeyCode.F8; - case 9 -> KeyCode.F9; - case 10 -> KeyCode.F10; - case 11 -> KeyCode.F11; - case 12 -> KeyCode.F12; - default -> null; - }; + KeyEvent key = footerClicks.keyAt(me.x(), me.y()); + return key != null && handleEvent(key, runner); } private boolean handlePasteEvent(PasteEvent pe) { @@ -2372,7 +2262,7 @@ public class CamelMonitor extends CamelCommand { private void renderFooter(Frame frame, Rect area) { // Disable footer key-binding clicks until the normal path below re-captures the hint regions. // Overlay/early-return states (screenshot flash, help, caption) leave the footer non-clickable. - footerRowY = -1; + footerClicks.clear(); // Show screenshot flash message briefly String msg = recordingManager.screenshotFlashMessage(); @@ -2511,7 +2401,7 @@ public class CamelMonitor extends CamelCommand { spans.addAll(rightSpans); } - captureFooterRegions(area, spans); + footerClicks.capture(area, spans); frame.renderWidget(Paragraph.from(Line.from(spans)), area); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HintClicks.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HintClicks.java new file mode 100644 index 000000000000..2d46325a61fc --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HintClicks.java @@ -0,0 +1,192 @@ +/* + * 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.util.ArrayList; +import java.util.List; + +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Style; +import dev.tamboui.text.CharWidth; +import dev.tamboui.text.Span; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; + +/** + * Where the key hints of the footer are, so a click on one presses its key. A hint is drawn by {@link TuiHelper#hint} + * as a key span styled with {@link Theme#hintKey()} followed by its label; a click on either presses the key. A hint of + * two keys ({@code +/-}, {@code f/F}, {@code c/Esc}) presses the key on the side of the slash that was clicked, and so + * does a label naming both ({@code fold/expand all}); a label of one name ({@code zoom}, {@code close}) presses the + * first key. + */ +final class HintClicks { + + private int row = -1; + private int[] startX = new int[0]; + private int[] endX = new int[0]; + private KeyEvent[] keys = new KeyEvent[0]; + + /** Records the hints of the spans drawn in the one-row {@code area}. */ + void capture(Rect area, List<Span> spans) { + List<int[]> bounds = new ArrayList<>(); + List<KeyEvent> found = new ArrayList<>(); + Style hintKeyStyle = Theme.hintKey(); + int x = area.x(); + for (int i = 0; i < spans.size(); i++) { + Span s = spans.get(i); + if (hintKeyStyle.equals(s.style())) { + Span label = i + 1 < spans.size() && !hintKeyStyle.equals(spans.get(i + 1).style()) ? spans.get(i + 1) : null; + addHint(bounds, found, x, s, label); + } + x += s.width(); + } + row = area.y(); + startX = bounds.stream().mapToInt(b -> b[0]).toArray(); + endX = bounds.stream().mapToInt(b -> b[1]).toArray(); + keys = found.toArray(new KeyEvent[0]); + } + + private static void addHint(List<int[]> bounds, List<KeyEvent> found, int x, Span key, Span label) { + int keyEnd = x + key.width(); + int labelEnd = keyEnd + (label != null ? label.width() : 0); + KeyEvent single = keyEvent(key.content()); + if (single != null) { + bounds.add(new int[] { x, labelEnd }); + found.add(single); + return; + } + KeyEvent[] pair = keyPair(key.content()); + if (pair == null) { + return; + } + // the slash itself presses neither key + int slash = x + CharWidth.of(key.content().substring(0, key.content().indexOf('/'))); + bounds.add(new int[] { x, slash }); + found.add(pair[0]); + bounds.add(new int[] { slash + 1, keyEnd }); + found.add(pair[1]); + if (label == null) { + return; + } + int labelSlash = label.content().indexOf('/'); + if (labelSlash < 0) { + bounds.add(new int[] { keyEnd, labelEnd }); + found.add(pair[0]); + } else { + int split = keyEnd + CharWidth.of(label.content().substring(0, labelSlash)); + bounds.add(new int[] { keyEnd, split }); + found.add(pair[0]); + bounds.add(new int[] { split + 1, labelEnd }); + found.add(pair[1]); + } + } + + /** Forgets the hints: the footer drawn now has none to click (a flash message, the help). */ + void clear() { + row = -1; + } + + /** The key of the hint under a click, or null. */ + KeyEvent keyAt(int x, int y) { + if (row < 0 || y != row) { + return null; + } + int idx = regionAt(startX, endX, x); + return idx >= 0 ? keys[idx] : null; + } + + /** + * Returns the index of the region whose column range contains {@code mouseX}, or {@code -1} when the click is + * outside every region. Each region spans the half-open range {@code [startX[i], endX[i])}. + */ + static int regionAt(int[] startX, int[] endX, int mouseX) { + if (startX == null) { + return -1; + } + for (int i = 0; i < startX.length; i++) { + if (mouseX >= startX[i] && mouseX < endX[i]) { + return i; + } + } + return -1; + } + + /** + * Maps a hint key token (the trimmed content of a {@link Theme#hintKey()} span) to the {@link KeyEvent} that + * pressing it would produce, or {@code null} when the token is not one key. Recognizes the function keys + * {@code F1}-{@code F12}, {@code Enter}, {@code Esc}, {@code Tab}, {@code Space} and any single character; hints + * such as {@code Up/Down} or arrow glyphs are not one key. + */ + static KeyEvent keyEvent(String token) { + if (token == null) { + return null; + } + String t = token.trim(); + if (t.isEmpty()) { + return null; + } + if (t.length() >= 2 && (t.charAt(0) == 'F' || t.charAt(0) == 'f') + && t.chars().skip(1).allMatch(Character::isDigit)) { + KeyCode fkey = functionKeyCode(Integer.parseInt(t.substring(1))); + return fkey != null ? KeyEvent.ofKey(fkey) : null; + } + return switch (t) { + case "Enter" -> KeyEvent.ofKey(KeyCode.ENTER); + case "Esc" -> KeyEvent.ofKey(KeyCode.ESCAPE); + case "Tab" -> KeyEvent.ofKey(KeyCode.TAB); + case "Space" -> KeyEvent.ofChar(' ', KeyModifiers.NONE); + default -> t.length() == 1 ? KeyEvent.ofChar(t.charAt(0), KeyModifiers.NONE) : null; + }; + } + + /** + * The two keys of a hint such as {@code +/-}, {@code f/F} or {@code Esc/c}, or null when the token is not two keys + * around one slash. + */ + static KeyEvent[] keyPair(String token) { + if (token == null) { + return null; + } + String t = token.trim(); + int slash = t.indexOf('/'); + if (slash <= 0 || slash != t.lastIndexOf('/')) { + return null; + } + KeyEvent first = keyEvent(t.substring(0, slash)); + KeyEvent second = keyEvent(t.substring(slash + 1)); + return first != null && second != null ? new KeyEvent[] { first, second } : null; + } + + private static KeyCode functionKeyCode(int n) { + return switch (n) { + case 1 -> KeyCode.F1; + case 2 -> KeyCode.F2; + case 3 -> KeyCode.F3; + case 4 -> KeyCode.F4; + case 5 -> KeyCode.F5; + case 6 -> KeyCode.F6; + case 7 -> KeyCode.F7; + case 8 -> KeyCode.F8; + case 9 -> KeyCode.F9; + case 10 -> KeyCode.F10; + case 11 -> KeyCode.F11; + case 12 -> KeyCode.F12; + default -> null; + }; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBar.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBar.java index 7b6e01d4b5e3..9f8706dbd5b6 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBar.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBar.java @@ -152,12 +152,28 @@ final class SubViewBar { spans.add(Span.raw(" ".repeat(area.width() - used - rightWidth))); spans.addAll(right); for (int i = 0; i < rightBoxes.size(); i++) { - hits.add(new Hit(start + rightBoxes.get(i)[0], start + rightBoxes.get(i)[1], null, rightKeys.get(i))); + addSettingHits(start + rightBoxes.get(i)[0], start + rightBoxes.get(i)[1], rightKeys.get(i)); } } frame.renderWidget(Paragraph.builder().text(Text.from(Line.from(spans))).build(), area); } + /** + * A setting presses its key wherever it is clicked. A setting of two keys ({@code +/-} zoom) presses the key on the + * side of the slash that was clicked, and its label (the mode, such as {@code compact 3/4}) presses neither. + */ + private void addSettingHits(int from, int to, String key) { + int slash = key.indexOf('/'); + if (slash < 0 || HintClicks.keyPair(key) == null) { + hits.add(new Hit(from, to, null, key)); + return; + } + // the chip is the key between two spaces + int split = from + 1 + CharWidth.of(key.substring(0, slash)); + hits.add(new Hit(from, split, null, key.substring(0, slash))); + hits.add(new Hit(split + 1, from + CharWidth.of(key) + 2, null, key.substring(slash + 1))); + } + /** The view under a click on the bar, or null. */ View viewAt(int x, int y) { Hit h = hitAt(x, y); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java index 10cb9f58b3f3..feac8ca43982 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java @@ -32,7 +32,6 @@ import picocli.CommandLine; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -88,51 +87,6 @@ class CamelMonitorTest { // (F6 first, then F3, F2). The first F-key pair must survive: F1 (help) when present, so the // user can always reach help, even on a narrow terminal. - // Footer key bindings are clickable only when the hint token maps to an unambiguous single key. - // footerKeyEvent parses the token; footerRegionAt maps a click x back to the region under it. - - @Test - void footerKeyEventParsesFunctionKeys() { - assertTrue(CamelMonitor.footerKeyEvent("F1").isKey(KeyCode.F1), "F1 maps to the F1 key"); - assertTrue(CamelMonitor.footerKeyEvent(" F5 ").isKey(KeyCode.F5), "surrounding spaces are trimmed"); - assertTrue(CamelMonitor.footerKeyEvent("F12").isKey(KeyCode.F12), "F12 is the highest function key"); - } - - @Test - void footerKeyEventParsesNamedAndSingleCharKeys() { - assertTrue(CamelMonitor.footerKeyEvent("Enter").isKey(KeyCode.ENTER), "Enter maps to the Enter key"); - assertTrue(CamelMonitor.footerKeyEvent("Esc").isKey(KeyCode.ESCAPE), "Esc maps to the Escape key"); - assertTrue(CamelMonitor.footerKeyEvent("Tab").isKey(KeyCode.TAB), "Tab maps to the Tab key"); - assertTrue(CamelMonitor.footerKeyEvent("d").isChar('d'), "a single letter maps to that character"); - assertTrue(CamelMonitor.footerKeyEvent("?").isChar('?'), "'?' (help) maps to that character"); - assertTrue(CamelMonitor.footerKeyEvent("1").isChar('1'), "a digit maps to that character"); - } - - @Test - void footerKeyEventRejectsAmbiguousAndInvalidTokens() { - assertNull(CamelMonitor.footerKeyEvent("Up/Down"), "a two-key hint is not clickable"); - assertNull(CamelMonitor.footerKeyEvent("PgUp/PgDn"), "a paging hint is not clickable"); - assertNull(CamelMonitor.footerKeyEvent(TuiIcons.HINT_SCROLL), "arrow glyphs are not a single key"); - assertNull(CamelMonitor.footerKeyEvent("F13"), "there is no F13 key"); - assertNull(CamelMonitor.footerKeyEvent(""), "an empty token is not clickable"); - assertNull(CamelMonitor.footerKeyEvent(null), "a null token is not clickable"); - } - - @Test - void footerRegionAtResolvesClicksToTheOwningRegion() { - // Two half-open regions [0, 5) and [10, 18) with a gap between them. - int[] startX = { 0, 10 }; - int[] endX = { 5, 18 }; - assertEquals(0, CamelMonitor.footerRegionAt(startX, endX, 0), "left edge of the first region"); - assertEquals(0, CamelMonitor.footerRegionAt(startX, endX, 4), "last cell of the first region"); - assertEquals(-1, CamelMonitor.footerRegionAt(startX, endX, 5), "column 5 is past the first region (exclusive end)"); - assertEquals(-1, CamelMonitor.footerRegionAt(startX, endX, 7), "column 7 is in the gap between regions"); - assertEquals(1, CamelMonitor.footerRegionAt(startX, endX, 10), "left edge of the second region"); - assertEquals(1, CamelMonitor.footerRegionAt(startX, endX, 17), "last cell of the second region"); - assertEquals(-1, CamelMonitor.footerRegionAt(startX, endX, 18), "column 18 is past the second region"); - assertEquals(-1, CamelMonitor.footerRegionAt(null, endX, 3), "no captured geometry yet"); - } - // Theme.applyBaseColors patches unset terminal cells with the active theme canvas colors while // preserving widget-specific foreground and background styles. diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HintClicksTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HintClicksTest.java new file mode 100644 index 000000000000..22a8a3b5662b --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HintClicksTest.java @@ -0,0 +1,130 @@ +/* + * 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.util.ArrayList; +import java.util.List; + +import dev.tamboui.layout.Rect; +import dev.tamboui.text.Span; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HintClicksTest { + + @BeforeEach + void resetTheme() { + Theme.resetForTesting(); + Theme.setMode("dark"); + } + + @Test + void keyEventParsesFunctionKeys() { + assertThat(HintClicks.keyEvent("F1").isKey(KeyCode.F1)).isTrue(); + assertThat(HintClicks.keyEvent(" F5 ").isKey(KeyCode.F5)).as("spaces around are trimmed").isTrue(); + assertThat(HintClicks.keyEvent("F12").isKey(KeyCode.F12)).isTrue(); + } + + @Test + void keyEventParsesNamedAndSingleCharKeys() { + assertThat(HintClicks.keyEvent("Enter").isKey(KeyCode.ENTER)).isTrue(); + assertThat(HintClicks.keyEvent("Esc").isKey(KeyCode.ESCAPE)).isTrue(); + assertThat(HintClicks.keyEvent("Tab").isKey(KeyCode.TAB)).isTrue(); + assertThat(HintClicks.keyEvent("Space").isChar(' ')).isTrue(); + assertThat(HintClicks.keyEvent("d").isChar('d')).isTrue(); + assertThat(HintClicks.keyEvent("?").isChar('?')).isTrue(); + assertThat(HintClicks.keyEvent("+").isChar('+')).isTrue(); + assertThat(HintClicks.keyEvent("F").isChar('F')).as("F alone is the letter").isTrue(); + } + + @Test + void keyEventRejectsWhatIsNotOneKey() { + assertThat(HintClicks.keyEvent("+/-")).isNull(); + assertThat(HintClicks.keyEvent("Up/Down")).isNull(); + assertThat(HintClicks.keyEvent(TuiIcons.HINT_SCROLL)).isNull(); + assertThat(HintClicks.keyEvent("F13")).isNull(); + assertThat(HintClicks.keyEvent("")).isNull(); + assertThat(HintClicks.keyEvent(null)).isNull(); + } + + @Test + void keyPairSplitsTwoKeysAroundASlash() { + KeyEvent[] zoom = HintClicks.keyPair(" +/- "); + assertThat(zoom[0].isChar('+')).isTrue(); + assertThat(zoom[1].isChar('-')).isTrue(); + KeyEvent[] fold = HintClicks.keyPair("f/F"); + assertThat(fold[0].isChar('f')).isTrue(); + assertThat(fold[1].isChar('F')).isTrue(); + KeyEvent[] close = HintClicks.keyPair("Esc/c"); + assertThat(close[0].isKey(KeyCode.ESCAPE)).isTrue(); + assertThat(close[1].isChar('c')).isTrue(); + + assertThat(HintClicks.keyPair("Up/Down")).as("Up and Down are not keys of a hint").isNull(); + assertThat(HintClicks.keyPair("/")).isNull(); + assertThat(HintClicks.keyPair("a/b/c")).isNull(); + assertThat(HintClicks.keyPair(null)).isNull(); + } + + @Test + void regionAtResolvesClicksToTheOwningRegion() { + // two half-open regions [0, 5) and [10, 18) with a gap between them + int[] startX = { 0, 10 }; + int[] endX = { 5, 18 }; + assertThat(HintClicks.regionAt(startX, endX, 0)).isZero(); + assertThat(HintClicks.regionAt(startX, endX, 4)).isZero(); + assertThat(HintClicks.regionAt(startX, endX, 5)).as("the end is exclusive").isEqualTo(-1); + assertThat(HintClicks.regionAt(startX, endX, 7)).as("the gap").isEqualTo(-1); + assertThat(HintClicks.regionAt(startX, endX, 10)).isEqualTo(1); + assertThat(HintClicks.regionAt(startX, endX, 17)).isEqualTo(1); + assertThat(HintClicks.regionAt(startX, endX, 18)).isEqualTo(-1); + assertThat(HintClicks.regionAt(null, endX, 3)).as("nothing captured yet").isEqualTo(-1); + } + + @Test + void aClickOnTheDiagramFooterPressesTheKeyUnderIt() { + List<Span> spans = new ArrayList<>(); + TuiHelper.hint(spans, "Esc", "back"); + TuiHelper.hint(spans, "+/-", "zoom"); + TuiHelper.hint(spans, "Space", "fold"); + TuiHelper.hint(spans, "f/F", "fold/expand all"); + TuiHelper.hint(spans, "Up/Down", "move"); + String line = spans.stream().map(Span::content).reduce("", String::concat); + HintClicks clicks = new HintClicks(); + clicks.capture(new Rect(0, 9, line.length(), 1), spans); + + assertThat(clicks.keyAt(line.indexOf("back"), 9).isKey(KeyCode.ESCAPE)).isTrue(); + assertThat(clicks.keyAt(line.indexOf("+/-"), 9).isChar('+')).isTrue(); + assertThat(clicks.keyAt(line.indexOf("+/-") + 1, 9)).as("the slash").isNull(); + assertThat(clicks.keyAt(line.indexOf("+/-") + 2, 9).isChar('-')).isTrue(); + assertThat(clicks.keyAt(line.indexOf("zoom"), 9).isChar('+')).as("a one-name label: the first key").isTrue(); + assertThat(clicks.keyAt(line.indexOf("Space"), 9).isChar(' ')).isTrue(); + assertThat(clicks.keyAt(line.indexOf(" fold "), 9).isChar(' ')).isTrue(); + assertThat(clicks.keyAt(line.indexOf("f/F"), 9).isChar('f')).isTrue(); + assertThat(clicks.keyAt(line.indexOf("f/F") + 2, 9).isChar('F')).isTrue(); + assertThat(clicks.keyAt(line.indexOf("fold/expand"), 9).isChar('f')).isTrue(); + assertThat(clicks.keyAt(line.indexOf("expand all"), 9).isChar('F')).isTrue(); + assertThat(clicks.keyAt(line.indexOf("Up/Down"), 9)).as("not keys of a hint").isNull(); + assertThat(clicks.keyAt(line.indexOf("back"), 8)).as("another row").isNull(); + + clicks.clear(); + assertThat(clicks.keyAt(line.indexOf("back"), 9)).as("a footer without hints").isNull(); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java index 7fe4ceb9278b..ef4964c182c5 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java @@ -59,7 +59,7 @@ class SourceSizeTest { AiPanel.java 3180 AiPanel.runAgentLoop 159 AiUsageStats.render 166 - CamelMonitor.java 2761 + CamelMonitor.java 2651 DiagramSupport.java 2360 DiagramSupport.loadHighlightedNativeDiagramInBackground 200 EndpointsTab.renderContent 170 diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBarTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBarTest.java index 25f9eb92b933..5e67546c8c89 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBarTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBarTest.java @@ -83,6 +83,25 @@ class SubViewBarTest { assertThat(line).contains(" m ●"); } + @Test + void aSettingOfTwoKeysPressesTheKeyOnTheClickedSide() { + SubViewBar bar = new SubViewBar(); + Spec spec = new Spec( + null, List.of(), + List.of(new Toggle("+/-", "", "compact 2/3"), new Toggle("m", "metrics", "on")), + true); + String line = render(bar, spec, 2); + int chip = line.indexOf(" +/- "); + + assertThat(bar.keyAt(chip, 2)).isEqualTo("+"); + assertThat(bar.keyAt(chip + 1, 2)).isEqualTo("+"); + assertThat(bar.keyAt(chip + 2, 2)).as("the slash").isNull(); + assertThat(bar.keyAt(chip + 3, 2)).isEqualTo("-"); + assertThat(bar.keyAt(chip + 4, 2)).isEqualTo("-"); + assertThat(bar.keyAt(line.indexOf("compact"), 2)).as("the mode is not a key").isNull(); + assertThat(bar.keyAt(line.indexOf("metrics"), 2)).isEqualTo("m"); + } + @Test void settingsThatDoNotFitEndInAnEllipsis() { SubViewBar bar = new SubViewBar();
