You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/10/12 14:29:04 UTC
(camel) branch main updated: chore: camel-jbang - fixes for emoji and wide text, scrolling and clicks in lists and popups, log escape codes and recorded keys
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 51e93447fbc7 chore: camel-jbang - fixes for emoji and wide text, scrolling and clicks in lists and popups, log escape codes and recorded keys
51e93447fbc7 is described below
commit 51e93447fbc76f82dd90db7ab5bf8551bfea7b80
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Mon Oct 12 16:27:44 2026 +0200
chore: camel-jbang - fixes for emoji and wide text, scrolling and clicks in lists and popups, log escape codes and recorded keys
Found while comparing the TUI with what TamboUI provides.
Emoji and wide text:
- text fields, the editor, the AI prompt and the list filters insert
the whole character, not half an emoji or U+FFFD, and a letter
pressed with Ctrl or Alt is no longer typed
- the editor moves and deletes by the columns TamboUI moves by, so
completions and line edits no longer cut an emoji or eat the start
of the next line
- captions, dialogs, the header notification, word wrap, the diagrams
and tui_locate measure text by display width
Scrolling and clicks:
- a click in a scrolled list picks the item under the mouse
- popup scrollbars sit on the border and follow the scroll position
- menus and pickers scroll to the selection on a short terminal
- an AI answer no longer pulls a scrolled-up chat back to the bottom
- PgUp/PgDn scroll the AI usage view, and a markdown file after End,
the help and the log popups stop at the end of their text
- the settings popup scrolls to the Default Folder and Shell History
rows, and the input history and SQL edit row popups have a frame
again
Log view:
- a window title or other escape sequence in log text no longer shows
as text, and reverse, strikethrough and colon-form colors are drawn
- lines keep their colors when scrolled sideways
Editor:
- Ctrl+/ comments out the block at the cursor or brings it back (# in
YAML and properties, // in Java); a comment inside the block stays a
comment
Tapes and MCP:
- the MCP tools and tapes read a copy of the last frame, not the
buffer TamboUI is drawing the next one into
- a recorded tape replays Ctrl, Alt and Shift keys as pressed
- the drawing tools take any TamboUI color, such as #ff8000
The redundant clear of the tab area, TuiHelper.contains (Rect.contains
does it) and dead source viewer helpers are removed.
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
.../ROOT/pages/camel-jbang-tui-source-editor.adoc | 1 +
.../dsl/jbang/core/commands/tui/AbstractTab.java | 2 +-
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 12 +-
.../dsl/jbang/core/commands/tui/AiLogPopup.java | 22 +-
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 74 +++--
.../jbang/core/commands/tui/AutocompletePopup.java | 20 +-
.../dsl/jbang/core/commands/tui/BeansTab.java | 3 +-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 13 +-
.../jbang/core/commands/tui/CaptionOverlay.java | 23 +-
.../dsl/jbang/core/commands/tui/CatalogTab.java | 3 +-
.../dsl/jbang/core/commands/tui/ClasspathTab.java | 16 +-
.../core/commands/tui/DiagramDetailSupport.java | 6 +-
.../dsl/jbang/core/commands/tui/DialogHelper.java | 7 +-
.../dsl/jbang/core/commands/tui/DoctorPopup.java | 8 +-
.../dsl/jbang/core/commands/tui/DrawOverlay.java | 38 +--
.../core/commands/tui/ExampleBrowserPopup.java | 16 +-
.../jbang/core/commands/tui/FileActionsPopup.java | 8 +-
.../jbang/core/commands/tui/FolderInputPopup.java | 8 +-
.../dsl/jbang/core/commands/tui/FormHelper.java | 46 ++-
.../dsl/jbang/core/commands/tui/FuzzyFilter.java | 9 +-
.../dsl/jbang/core/commands/tui/GotoNodePopup.java | 13 +-
.../jbang/core/commands/tui/GotoRoutePopup.java | 13 +-
.../core/commands/tui/GotoSourceNodePopup.java | 13 +-
.../dsl/jbang/core/commands/tui/GotoTabPopup.java | 18 +-
.../dsl/jbang/core/commands/tui/HelpOverlay.java | 20 +-
.../dsl/jbang/core/commands/tui/HistoryTab.java | 33 +-
.../dsl/jbang/core/commands/tui/HttpProbe.java | 15 +-
.../jbang/core/commands/tui/InfraBrowserPopup.java | 20 +-
.../dsl/jbang/core/commands/tui/InputHistory.java | 3 +-
.../camel/dsl/jbang/core/commands/tui/LogTab.java | 9 +-
.../core/commands/tui/MavenDependenciesTab.java | 3 +-
.../dsl/jbang/core/commands/tui/McpFacade.java | 15 +-
.../dsl/jbang/core/commands/tui/McpLogPopup.java | 10 +-
.../dsl/jbang/core/commands/tui/PopupManager.java | 33 +-
.../core/commands/tui/ProcessControlPopup.java | 2 +-
.../jbang/core/commands/tui/RecordingManager.java | 36 ++-
.../dsl/jbang/core/commands/tui/RefactorPopup.java | 9 +-
.../jbang/core/commands/tui/RunOptionsForm.java | 14 +-
.../jbang/core/commands/tui/SearchHighlighter.java | 6 +-
.../jbang/core/commands/tui/SemanticAuditView.java | 4 +-
.../core/commands/tui/SemanticEvaluationPopup.java | 4 +-
.../core/commands/tui/SemanticParameterForm.java | 2 +-
.../core/commands/tui/SemanticPlayground.java | 4 +-
.../core/commands/tui/SemanticRelationships.java | 2 +-
.../dsl/jbang/core/commands/tui/SemanticTab.java | 16 +-
.../jbang/core/commands/tui/SendMessagePopup.java | 10 +-
.../dsl/jbang/core/commands/tui/SettingsPopup.java | 17 +-
.../dsl/jbang/core/commands/tui/ShellPanel.java | 2 +-
.../core/commands/tui/SourceEditorNavigation.java | 48 ++-
.../dsl/jbang/core/commands/tui/SourceTab.java | 19 +-
.../dsl/jbang/core/commands/tui/SourceViewer.java | 290 ++++++------------
.../dsl/jbang/core/commands/tui/SpansTab.java | 3 +-
.../dsl/jbang/core/commands/tui/SqlQueryTab.java | 7 +-
.../dsl/jbang/core/commands/tui/TapeRecorder.java | 53 +++-
.../dsl/jbang/core/commands/tui/ThemePopup.java | 6 +-
.../dsl/jbang/core/commands/tui/TocPopup.java | 18 +-
.../dsl/jbang/core/commands/tui/TuiHelper.java | 341 ++++++++++++++++-----
.../core/commands/tui/TuiToolDefinitions.java | 4 +-
.../jbang/core/commands/tui/TypeConvertersTab.java | 3 +-
.../jbang/core/commands/tui/YamlBlockEditor.java | 48 ++-
.../core/commands/tui/diagram/DiagramText.java | 112 +++++++
.../commands/tui/diagram/RouteDiagramWidget.java | 119 +++----
.../tui/diagram/TopologyDiagramWidget.java | 66 +---
.../src/main/resources/tui/help/source.md | 2 +
.../jbang/core/commands/tui/AiLogPopupTest.java | 59 ++++
.../core/commands/tui/AiPanelGenAiUsageTest.java | 31 ++
.../dsl/jbang/core/commands/tui/AiPanelTest.java | 101 ++++++
.../core/commands/tui/CaptionOverlayTest.java | 59 ++++
.../core/commands/tui/ClasspathTabRenderTest.java | 21 ++
.../jbang/core/commands/tui/DialogHelperTest.java | 13 +
.../core/commands/tui/DoctorPopupWrapTest.java | 6 +
.../jbang/core/commands/tui/DrawOverlayTest.java | 59 ++++
.../jbang/core/commands/tui/FormHelperTest.java | 97 ++++++
.../jbang/core/commands/tui/FuzzyFilterTest.java | 11 +
.../jbang/core/commands/tui/HelpOverlayTest.java | 32 ++
.../jbang/core/commands/tui/InputHistoryTest.java | 42 +++
.../jbang/core/commands/tui/LogTabRenderTest.java | 47 +++
.../core/commands/tui/McpFacadeLocateTextTest.java | 50 +++
.../dsl/jbang/core/commands/tui/NoopBackend.java | 14 +-
.../jbang/core/commands/tui/PopupManagerTest.java | 53 ++++
.../core/commands/tui/RecordingManagerTest.java | 73 +++++
.../core/commands/tui/SettingsPopupRenderTest.java | 44 +++
.../commands/tui/SourceEditorNavigationTest.java | 43 +++
.../core/commands/tui/SourceViewerEditTest.java | 30 ++
.../commands/tui/SourceViewerEditorOpsTest.java | 79 ++++-
.../jbang/core/commands/tui/ThemePopupTest.java | 56 ++++
.../dsl/jbang/core/commands/tui/TocPopupTest.java | 93 ++++++
.../dsl/jbang/core/commands/tui/TuiHelperTest.java | 128 ++++++++
.../core/commands/tui/YamlBlockEditorTest.java | 51 +++
.../core/commands/tui/diagram/DiagramTextTest.java | 88 ++++++
90 files changed, 2400 insertions(+), 800 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc
index 52e6d2002f72..782c7c2320fe 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc
@@ -375,6 +375,7 @@ The template of a Kamelet is a route of the project: *g* lists it under the name
| *Alt+Up/Down* | Move YAML block up/down
| *Ctrl+D* | Duplicate block
| *Ctrl+K* | Delete current line
+| *Ctrl+/* | Comment out the current block, or bring it back (`#` in YAML and properties, `//` in Java)
| *Ctrl+Left/Right* | Word navigation
| *Home* | Smart home (content indent / column 0)
| *Ctrl+R* | Refactoring menu: replace URI, extract to property, extract a step to a new file (YAML, XML)
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTab.java
index 9f8c5b126a99..d8eb4b61d6b9 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTab.java
@@ -188,7 +188,7 @@ abstract class AbstractTab implements MonitorTab {
if (!me.isClick()) {
return false;
}
- if (!TuiHelper.contains(tableArea, me.x(), me.y())) {
+ if (!tableArea.contains(me.x(), me.y())) {
return false;
}
int rowIndex = tableState.offset() + (me.y() - tableArea.y() - 2);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
index fb828063471f..4a2fdb261e6f 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
@@ -41,6 +41,7 @@ import dev.tamboui.widgets.list.ListItem;
import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
import dev.tamboui.widgets.list.ScrollMode;
+import dev.tamboui.widgets.scrollbar.ScrollbarState;
import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
import org.apache.camel.util.json.JsonObject;
@@ -112,6 +113,8 @@ class ActionsPopup {
private int savedMainSelection;
// Absolute bounds of the single-line list popups captured during render, used to hit-test clicks.
private Rect actionsMenuRect;
+ // the menus scroll to the selection when a short terminal cannot show every item
+ private final ScrollbarState menuScrollbarState = new ScrollbarState();
private final GotoTabPopup gotoTabPopup = new GotoTabPopup();
private final ThemePopup themePopup = new ThemePopup();
@@ -1041,13 +1044,14 @@ class ActionsPopup {
.items(items.toArray(ListItem[]::new))
.highlightStyle(Theme.selectionBg())
.highlightSymbol("")
- .scrollMode(ScrollMode.NONE)
+ .scrollMode(ScrollMode.AUTO_SCROLL)
.block(Block.builder()
.borderType(BorderType.ROUNDED).borders(Borders.ALL)
.title(" Actions ")
.build())
.build();
frame.renderStatefulWidget(list, popup, actionsMenuState);
+ TuiHelper.renderListScrollbar(frame, popup, items.size(), actionsMenuState.offset(), menuScrollbarState);
}
private void renderScreenSubmenu(Frame frame, Rect area) {
@@ -1084,13 +1088,14 @@ class ActionsPopup {
.items(items.toArray(ListItem[]::new))
.highlightStyle(Theme.selectionBg())
.highlightSymbol("")
- .scrollMode(ScrollMode.NONE)
+ .scrollMode(ScrollMode.AUTO_SCROLL)
.block(Block.builder()
.borderType(BorderType.ROUNDED).borders(Borders.ALL)
.title(" Screen ")
.build())
.build();
frame.renderStatefulWidget(list, popup, actionsMenuState);
+ TuiHelper.renderListScrollbar(frame, popup, items.size(), actionsMenuState.offset(), menuScrollbarState);
}
private void renderMcpSubmenu(Frame frame, Rect area) {
@@ -1121,13 +1126,14 @@ class ActionsPopup {
.items(items.toArray(ListItem[]::new))
.highlightStyle(Theme.selectionBg())
.highlightSymbol("")
- .scrollMode(ScrollMode.NONE)
+ .scrollMode(ScrollMode.AUTO_SCROLL)
.block(Block.builder()
.borderType(BorderType.ROUNDED).borders(Borders.ALL)
.title(title)
.build())
.build();
frame.renderStatefulWidget(list, popup, actionsMenuState);
+ TuiHelper.renderListScrollbar(frame, popup, items.size(), actionsMenuState.offset(), menuScrollbarState);
}
private boolean hasMultipleIntegrations() {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopup.java
index 2eb9bb99b8ff..88136634c9b4 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopup.java
@@ -21,6 +21,7 @@ import java.util.List;
import java.util.function.Supplier;
import dev.tamboui.layout.Rect;
+import dev.tamboui.markdown.MarkdownView;
import dev.tamboui.style.Style;
import dev.tamboui.terminal.Frame;
import dev.tamboui.text.Line;
@@ -88,6 +89,7 @@ class AiLogPopup {
} else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
detailScroll = Math.max(0, detailScroll - 5);
} else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
+ // kept within the detail when it is drawn
detailScroll += 5;
}
return true;
@@ -169,13 +171,14 @@ class AiLogPopup {
&& entry.level() != AiPanel.LogLevel.TOOL && entry.level() != AiPanel.LogLevel.RESULT) {
// an answer or a prompt is Markdown: shown as the AI panel shows it (bold, code, lists), wrapped
Block block = Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(" Detail ").build();
- frame.renderWidget(dev.tamboui.markdown.MarkdownView.builder()
+ MarkdownView.Builder view = MarkdownView.builder()
.source(content)
- .scroll(detailScroll)
- .block(block)
.styles(Theme.chatMarkdownStyles())
- .syntaxTheme(Theme.syntaxTheme())
- .build(), area);
+ .syntaxTheme(Theme.syntaxTheme());
+ Rect inner = block.inner(area);
+ detailScroll = Math.min(detailScroll,
+ Math.max(0, view.build().computeHeight(inner.width()) - inner.height()));
+ frame.renderWidget(view.scroll(detailScroll).block(block).build(), area);
return;
}
List<Line> lines = new ArrayList<>();
@@ -202,10 +205,11 @@ class AiLogPopup {
int visibleLines = inner.height();
int totalLines = lines.size();
- int clampedScroll = Math.min(detailScroll, Math.max(0, totalLines - visibleLines));
- int end = Math.min(clampedScroll + visibleLines, totalLines);
- if (clampedScroll < end) {
- List<Line> visible = lines.subList(clampedScroll, end);
+ // the scroll stays within the detail, so PgUp moves at once after PgDn went past its end
+ detailScroll = Math.min(detailScroll, Math.max(0, totalLines - visibleLines));
+ int end = Math.min(detailScroll + visibleLines, totalLines);
+ if (detailScroll < end) {
+ List<Line> visible = lines.subList(detailScroll, end);
frame.renderWidget(
Paragraph.builder().text(Text.from(visible.toArray(Line[]::new))).build(),
inner);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
index a19f67764bea..504137b65c51 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -192,6 +192,8 @@ class AiPanel {
// CLI-command-completion callback while the render thread iterates the list concurrently.
private final List<ConversationEntry> conversation = new CopyOnWriteArrayList<>();
private int scrollOffset;
+ // the rendered lines of the conversation in the last frame, to keep a scrolled-up view where it is
+ private int lastTotalLines;
// LLM state
private volatile LlmClient client;
@@ -846,7 +848,7 @@ class AiPanel {
}
return handled;
}
- if (!TuiHelper.contains(lastArea, me.x(), me.y())) {
+ if (!lastArea.contains(me.x(), me.y())) {
return false;
}
if (me.kind() == MouseEventKind.SCROLL_UP) {
@@ -928,7 +930,8 @@ class AiPanel {
}
if (ke.isKey(KeyCode.PAGE_UP)) {
if (statsView) {
- statsScrollOffset += 5;
+ // the usage view scrolls its model table from the top
+ statsScrollOffset = Math.max(0, statsScrollOffset - 5);
} else {
scrollOffset += 5;
}
@@ -936,7 +939,7 @@ class AiPanel {
}
if (ke.isKey(KeyCode.PAGE_DOWN)) {
if (statsView) {
- statsScrollOffset = Math.max(0, statsScrollOffset - 5);
+ statsScrollOffset += 5;
} else {
scrollOffset = Math.max(0, scrollOffset - 5);
}
@@ -980,13 +983,15 @@ class AiPanel {
}
return true;
}
+ // the cursor moves and deletes a code point at a time, so an emoji (two chars) is not cut in half
if (ke.isKey(KeyCode.BACKSPACE)) {
if (cursorPos > 0) {
if (promptHistory != null) {
promptHistory.resetNavigation();
}
- inputBuffer.deleteCharAt(cursorPos - 1);
- cursorPos--;
+ int previous = inputBuffer.offsetByCodePoints(cursorPos, -1);
+ inputBuffer.delete(previous, cursorPos);
+ cursorPos = previous;
}
return true;
}
@@ -995,19 +1000,19 @@ class AiPanel {
if (promptHistory != null) {
promptHistory.resetNavigation();
}
- inputBuffer.deleteCharAt(cursorPos);
+ inputBuffer.delete(cursorPos, inputBuffer.offsetByCodePoints(cursorPos, 1));
}
return true;
}
if (ke.isKey(KeyCode.LEFT)) {
if (cursorPos > 0) {
- cursorPos--;
+ cursorPos = inputBuffer.offsetByCodePoints(cursorPos, -1);
}
return true;
}
if (ke.isKey(KeyCode.RIGHT)) {
if (cursorPos < inputBuffer.length()) {
- cursorPos++;
+ cursorPos = inputBuffer.offsetByCodePoints(cursorPos, 1);
}
return true;
}
@@ -1023,12 +1028,13 @@ class AiPanel {
handleTabCompletion(ke.hasShift());
return true;
}
- if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
+ String typed = FormHelper.typedText(ke);
+ if (typed != null) {
if (promptHistory != null) {
promptHistory.resetNavigation();
}
- inputBuffer.insert(cursorPos, ke.character());
- cursorPos++;
+ inputBuffer.insert(cursorPos, typed);
+ cursorPos += typed.length();
return true;
}
if (isGlobalShortcut(ke)) {
@@ -1094,13 +1100,14 @@ class AiPanel {
if (searchTerm.isEmpty()) {
exitHistorySearch(false);
} else {
- searchTerm.deleteCharAt(searchTerm.length() - 1);
+ searchTerm.setLength(searchTerm.offsetByCodePoints(searchTerm.length(), -1));
performSearch(promptHistory.size() - 1);
}
return true;
}
- if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
- searchTerm.append(ke.character());
+ String typed = FormHelper.typedText(ke);
+ if (typed != null) {
+ searchTerm.append(typed);
int from = searchIndex >= 0 ? searchIndex : promptHistory.size() - 1;
performSearch(from);
return true;
@@ -1157,7 +1164,8 @@ class AiPanel {
/** Moves the cursor to the previous (-1) or next (1) line of the input; false when there is no such line. */
private boolean moveCursorLine(int direction) {
int start = lineStart(cursorPos);
- int column = cursorPos - start;
+ // the column in code points, so the cursor does not land inside an emoji on the other line
+ int column = inputBuffer.codePointCount(start, cursorPos);
int targetStart;
if (direction < 0) {
if (start == 0) {
@@ -1172,7 +1180,8 @@ class AiPanel {
targetStart = end + 1;
}
int targetEnd = lineEnd(targetStart);
- cursorPos = Math.min(targetStart + column, targetEnd);
+ cursorPos = inputBuffer.offsetByCodePoints(targetStart,
+ Math.min(column, inputBuffer.codePointCount(targetStart, targetEnd)));
return true;
}
@@ -1474,7 +1483,6 @@ class AiPanel {
String displayText = cliResult != null ? cliResult.displayText() : "command";
conversation.add(new ConversationEntry(
AiRole.ERROR, "Failed to run " + displayText + ": " + error.getMessage()));
- scrollOffset = 0;
return;
}
@@ -1487,7 +1495,6 @@ class AiPanel {
AiRole.ERROR,
cliResult.displayText() + " exit code " + cliResult.exitCode() + "\n\n" + cliResult.output()));
}
- scrollOffset = 0;
}
private void interruptBusyOperation() {
@@ -1806,7 +1813,6 @@ class AiPanel {
conversation.add(new ConversationEntry(AiRole.ERROR, err));
log(LogLevel.ERROR, "Error", err);
}
- scrollOffset = 0;
messages.add(LlmClient.Message.assistantWithToolCalls(text, List.of()));
compactHistoryAfterTurn();
}
@@ -2059,7 +2065,6 @@ class AiPanel {
acpPreambleSent = true;
}
listener.finish(stopReason);
- scrollOffset = 0;
}
/**
@@ -2226,7 +2231,6 @@ class AiPanel {
public synchronized void onTextChunk(String chunk) {
text.append(chunk);
liveIndex = replaceOrAppend(liveIndex, new ConversationEntry(AiRole.ASSISTANT, text.toString()));
- scrollOffset = 0;
}
@Override
@@ -2646,6 +2650,12 @@ class AiPanel {
// scrollOffset=0 means auto-scroll to bottom (most recent content visible)
// scrollOffset>0 means user scrolled up by that many lines
+ // A user who scrolled up keeps reading the same lines while an answer streams in below them: the offset from
+ // the bottom grows by the lines added since the last frame. At the bottom the view follows the new lines.
+ if (scrollOffset > 0 && lastTotalLines > 0 && totalLines > lastTotalLines) {
+ scrollOffset += totalLines - lastTotalLines;
+ }
+ lastTotalLines = totalLines;
// Clamp so PgDn always has immediate effect after scrolling past the top
int maxScrollOffset = Math.max(0, totalLines - contentArea.height());
scrollOffset = Math.min(scrollOffset, maxScrollOffset);
@@ -2731,12 +2741,24 @@ class AiPanel {
} else {
// ensure the cursor is visible by adjusting the text window of its line
int windowStart = cursorColumn > maxWidth - 1 ? cursorColumn - maxWidth + 1 : 0;
- String visible = text.substring(windowStart, Math.min(text.length(), windowStart + maxWidth));
+ int windowEnd = Math.min(text.length(), windowStart + maxWidth);
+ // the window does not cut an emoji (two chars) in half
+ if (windowStart > 0 && windowStart < text.length() && Character.isLowSurrogate(text.charAt(windowStart))) {
+ windowStart++;
+ }
+ if (windowEnd < text.length() && windowEnd > windowStart
+ && Character.isHighSurrogate(text.charAt(windowEnd - 1))) {
+ windowEnd--;
+ }
+ String visible = text.substring(windowStart, Math.max(windowStart, windowEnd));
int cursorInWindow = cursorColumn - windowStart;
if (cursorInWindow >= 0 && cursorInWindow < visible.length()) {
+ // the whole character under the cursor, both chars of an emoji
+ int cursorEnd = Math.min(visible.length(),
+ cursorInWindow + Character.charCount(visible.codePointAt(cursorInWindow)));
spans.add(Span.raw(visible.substring(0, cursorInWindow)));
- spans.add(Span.styled(String.valueOf(visible.charAt(cursorInWindow)), Style.EMPTY.reversed()));
- spans.add(Span.raw(visible.substring(cursorInWindow + 1)));
+ spans.add(Span.styled(visible.substring(cursorInWindow, cursorEnd), Style.EMPTY.reversed()));
+ spans.add(Span.raw(visible.substring(cursorEnd)));
} else {
spans.add(Span.raw(visible));
if (cursorInWindow == visible.length()) {
@@ -3046,6 +3068,10 @@ class AiPanel {
Constraint.length(8),
Constraint.length(8))
.build();
+ // PgUp/PgDn scroll the models that do not fit below the header
+ int visibleModels = Math.max(0, tableArea.height() - 1);
+ statsScrollOffset = Math.max(0, Math.min(statsScrollOffset, rows.size() - visibleModels));
+ statsTableState.setOffset(statsScrollOffset);
frame.renderStatefulWidget(table, tableArea, statsTableState);
// --- Charts per question: tokens on the left, AI vs tool time on the right ---
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
index ca317a5808ce..aff2ec41c625 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
@@ -40,7 +40,6 @@ import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
import dev.tamboui.widgets.list.ScrollMode;
import dev.tamboui.widgets.paragraph.Paragraph;
-import dev.tamboui.widgets.scrollbar.Scrollbar;
import dev.tamboui.widgets.scrollbar.ScrollbarState;
class AutocompletePopup {
@@ -96,6 +95,9 @@ class AutocompletePopup {
private List<CompletionItem> filteredItems;
private CompletionItem selectedItem;
private Rect popupRect;
+ // the list part of the popup and the rows it was scrolled by in the last render, for a click
+ private Rect listRect;
+ private int listOffset;
private String titlePrefix;
private boolean fullKeys;
// the row of the filtered list a divider is drawn above, -1 for none: the properties of a Kamelet above it, the
@@ -204,7 +206,7 @@ class AutocompletePopup {
return Result.CLOSED;
}
if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
- filter.appendChar(ke.string().charAt(0));
+ filter.append(ke.string());
List<CompletionItem> prev = filteredItems;
rebuildList();
if (filteredItems.isEmpty()) {
@@ -230,7 +232,7 @@ class AutocompletePopup {
}
if (me.isClick()) {
if (popupRect != null && popupRect.contains(me.x(), me.y())) {
- int idx = TuiHelper.listItemAt(popupRect, 0,
+ int idx = TuiHelper.listItemAt(listRect, listOffset,
(filteredItems != null ? listRows() : 0) + 2,
me.x(), me.y());
int row = idx - 2;
@@ -396,15 +398,9 @@ class AutocompletePopup {
.build())
.build();
frame.renderStatefulWidget(list, listRect, renderState);
-
- int visibleRows = Math.max(1, listRect.height() - 2);
- if (listRows() + 2 > visibleRows) {
- scrollbarState
- .contentLength(listRows())
- .viewportContentLength(visibleRows)
- .position(sel != null ? sel + (dividerAt >= 0 && sel >= dividerAt ? 1 : 0) : 0);
- frame.renderStatefulWidget(Scrollbar.builder().build(), listRect, scrollbarState);
- }
+ this.listRect = listRect;
+ listOffset = renderState.offset();
+ TuiHelper.renderListScrollbar(frame, listRect, listRows() + 2, listOffset, scrollbarState);
}
private void renderDetail(Frame frame, Rect detailRect) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BeansTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BeansTab.java
index 616f940e3b32..71a4c0a5f1bc 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BeansTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BeansTab.java
@@ -332,7 +332,8 @@ class BeansTab extends AbstractTableTab {
public void renderFooter(List<Span> spans) {
if (filterInputActive) {
spans.add(Span.styled(" /", Theme.label().bold()));
- spans.add(Span.raw(filterInputState.text() + "█ "));
+ FormHelper.addCaretField(spans, filterInputState);
+ spans.add(Span.raw(" "));
hint(spans, "Enter", "filter");
hintLast(spans, "Esc", "cancel");
return;
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 79c02ec1ffdc..e259a7a4ff8b 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
@@ -1784,7 +1784,7 @@ public class CamelMonitor extends CamelCommand {
// Tab bar clicks: detect which tab was clicked and switch to it
if (me.isClick() && lastTabsArea != null && lastTabLabels != null) {
int tabsY = lastTabsArea.height() >= 2 ? lastTabsArea.y() + 1 : lastTabsArea.y();
- if (me.y() == tabsY && TuiHelper.contains(lastTabsArea, me.x(), me.y())) {
+ if (me.y() == tabsY && lastTabsArea.contains(me.x(), me.y())) {
int clickedTab = findClickedTab(me.x() - lastTabsArea.x());
if (clickedTab >= 0) {
if (isInfraSelected()) {
@@ -1807,7 +1807,8 @@ public class CamelMonitor extends CamelCommand {
}
// The view bar of the tab: a click on a view goes there, a click on a view setting presses its key
- if (subViewBar.isOnRow(me.y()) && TuiHelper.contains(lastContentArea, me.x(), me.y())
+ if (subViewBar.isOnRow(me.y()) && lastContentArea != null
+ && lastContentArea.contains(me.x(), me.y())
&& !popupManager.isMorePopupVisible() && !popupManager.isSwitchPopupVisible()) {
if (me.isClick()) {
SubViewBar.View view = subViewBar.viewAt(me.x(), me.y());
@@ -1824,7 +1825,7 @@ public class CamelMonitor extends CamelCommand {
}
// Mouse events in the content area: delegate to the active tab
- if (TuiHelper.contains(lastContentArea, me.x(), me.y())) {
+ if (lastContentArea != null && lastContentArea.contains(me.x(), me.y())) {
if (popupManager.isMorePopupVisible() || popupManager.isSwitchPopupVisible()) {
return popupManager.handleMouseEvent(me, tabRegistry.selectedTabIndex(), TAB_LOG);
}
@@ -2270,7 +2271,7 @@ public class CamelMonitor extends CamelCommand {
}
int titleWidth = titleSpans.stream().mapToInt(s -> s.width()).sum();
int rightWidth = headerRight.stream().mapToInt(Span::width).sum();
- int waveWidth = activeNotification.length() + 2;
+ int waveWidth = CharWidth.of(activeNotification) + 2;
int leftWidth = Math.min(titleWidth + 2, area.width() - waveWidth - rightWidth);
if (leftWidth > 0 && waveWidth > 0 && leftWidth + waveWidth + rightWidth <= area.width()) {
Rect leftArea = new Rect(area.x(), area.y(), leftWidth, 1);
@@ -2457,10 +2458,6 @@ public class CamelMonitor extends CamelCommand {
}
private void renderContent(Frame frame, Rect area) {
- // Clear the content area before rendering the active tab. Without this, styled cells
- // from the previous tab (e.g. RED error text in the log detail) can bleed through when
- // switching tabs if TamboUI's buffer diff does not reset every cell in the region.
- frame.buffer().clear(area);
MonitorTab tab = tabRegistry.activeTab();
if (tab != null) {
SubViewBar.Spec spec = tab.subViewBar();
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
index 3ce026f22d79..cbf8cd83245a 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
@@ -22,10 +22,10 @@ import java.util.List;
import dev.tamboui.layout.Rect;
import dev.tamboui.style.Style;
import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.CharWidth;
import dev.tamboui.text.Line;
import dev.tamboui.text.Span;
import dev.tamboui.text.Text;
-import dev.tamboui.tui.event.KeyCode;
import dev.tamboui.tui.event.KeyEvent;
import dev.tamboui.widgets.Clear;
import dev.tamboui.widgets.paragraph.Paragraph;
@@ -61,6 +61,11 @@ class CaptionOverlay {
return inlineMode || captionText != null;
}
+ /** The caption shown or being typed (tests). */
+ String captionTextForTesting() {
+ return captionText;
+ }
+
void openInline() {
inlineMode = true;
inlineBuffer = new StringBuilder();
@@ -101,11 +106,12 @@ class CaptionOverlay {
finishInline();
} else if (ke.isDeleteBackward()) {
if (!inlineBuffer.isEmpty()) {
- inlineBuffer.deleteCharAt(inlineBuffer.length() - 1);
+ // the last character, both chars of an emoji
+ inlineBuffer.setLength(inlineBuffer.offsetByCodePoints(inlineBuffer.length(), -1));
captionText = inlineBuffer.toString();
inlineLastKeystroke = System.currentTimeMillis();
}
- } else if (ke.code() == KeyCode.CHAR) {
+ } else if (FormHelper.typedText(ke) != null) {
inlineBuffer.append(ke.string());
captionText = inlineBuffer.toString();
inlineLastKeystroke = System.currentTimeMillis();
@@ -140,7 +146,7 @@ class CaptionOverlay {
return;
}
- int totalChars = captionText.length();
+ int totalChars = captionText.codePointCount(0, captionText.length());
long elapsed = now - captionStartTime;
int charsToShow = (int) (elapsed / CHAR_DELAY_MS);
@@ -196,7 +202,7 @@ class CaptionOverlay {
int maxWidth = 0;
for (String part : parts) {
lines.add(Line.from(Span.styled(" " + part + " ", style)));
- maxWidth = Math.max(maxWidth, part.length() + 4);
+ maxWidth = Math.max(maxWidth, CharWidth.of(part) + 4);
}
int captionW = Math.min(Math.max(maxWidth, 6), area.width() - 2);
@@ -216,8 +222,9 @@ class CaptionOverlay {
private void renderCaption(Frame frame, Rect area) {
long now = System.currentTimeMillis();
long elapsed = now - captionStartTime;
- int charsToShow = Math.min((int) (elapsed / CHAR_DELAY_MS), captionText.length());
- String visible = captionText.substring(0, charsToShow);
+ // typed a character at a time, an emoji (two chars) at once
+ int charsToShow = Math.min((int) (elapsed / CHAR_DELAY_MS), captionText.codePointCount(0, captionText.length()));
+ String visible = captionText.substring(0, captionText.offsetByCodePoints(0, charsToShow));
Style style;
if (captionFullyTypedTime == 0 || now - captionFullyTypedTime < HOLD_DURATION_MS) {
@@ -231,7 +238,7 @@ class CaptionOverlay {
int maxWidth = 0;
for (String part : parts) {
lines.add(Line.from(Span.styled(" " + part + " ", style)));
- maxWidth = Math.max(maxWidth, part.length() + 4);
+ maxWidth = Math.max(maxWidth, CharWidth.of(part) + 4);
}
int captionW = Math.min(maxWidth, area.width() - 2);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CatalogTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CatalogTab.java
index 99c28005dc7b..b46f929c4615 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CatalogTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CatalogTab.java
@@ -615,7 +615,8 @@ class CatalogTab extends AbstractTableTab {
public void renderFooter(List<Span> spans) {
if (filterInputActive) {
spans.add(Span.styled(" /", Theme.label().bold()));
- spans.add(Span.raw(filterInputState.text() + "█ "));
+ FormHelper.addCaretField(spans, filterInputState);
+ spans.add(Span.raw(" "));
hint(spans, "Enter", "filter");
hintLast(spans, "Esc", "cancel");
return;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathTab.java
index f39604e02e61..3063da63414e 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathTab.java
@@ -40,7 +40,6 @@ import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
import dev.tamboui.widgets.list.ScrollMode;
import dev.tamboui.widgets.paragraph.Paragraph;
-import dev.tamboui.widgets.scrollbar.Scrollbar;
import dev.tamboui.widgets.scrollbar.ScrollbarState;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
@@ -174,7 +173,7 @@ class ClasspathTab extends AbstractTab {
listState.selectNext(filteredEntries.size());
return true;
}
- if (me.isClick() && contains(lastArea, me.x(), me.y())) {
+ if (me.isClick() && lastArea != null && lastArea.contains(me.x(), me.y())) {
int itemIndex = listState.offset() + (me.y() - lastArea.y() - 1);
if (itemIndex >= 0 && itemIndex < filteredEntries.size()) {
listState.select(itemIndex);
@@ -274,22 +273,15 @@ class ClasspathTab extends AbstractTab {
frame.renderStatefulWidget(list, area, listState);
lastArea = area;
- // Render vertical scrollbar on the right border when content overflows
- int visibleRows = area.height() - 2; // top border + bottom border
- if (visibleRows > 0 && filteredEntries.size() > visibleRows) {
- Rect scrollRect = new Rect(area.x() + area.width() - 1, area.y() + 1, 1, visibleRows);
- listScrollState.contentLength(filteredEntries.size());
- listScrollState.viewportContentLength(visibleRows);
- listScrollState.position(listState.offset());
- frame.renderStatefulWidget(Scrollbar.builder().build(), scrollRect, listScrollState);
- }
+ renderListScrollbar(frame, area, filteredEntries.size(), listState.offset(), listScrollState);
}
@Override
public void renderFooter(List<Span> spans) {
if (filterInputActive) {
spans.add(Span.styled(" /", Theme.label().bold()));
- spans.add(Span.raw(filterInputState.text() + "█ "));
+ FormHelper.addCaretField(spans, filterInputState);
+ spans.add(Span.raw(" "));
hint(spans, "Enter", "filter");
hintLast(spans, "Esc", "cancel");
return;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramDetailSupport.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramDetailSupport.java
index 789ad3342033..effb2df04065 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramDetailSupport.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramDetailSupport.java
@@ -291,11 +291,15 @@ final class DiagramDetailSupport {
static List<String> wrapWords(String text, int width) {
List<String> out = new ArrayList<>();
StringBuilder line = new StringBuilder();
+ int lineWidth = 0;
for (String word : text.split("\\s+")) {
- if (!line.isEmpty() && line.length() + 1 + word.length() > width) {
+ int wordWidth = CharWidth.of(word);
+ if (!line.isEmpty() && lineWidth + 1 + wordWidth > width) {
out.add(line.toString());
line.setLength(0);
+ lineWidth = 0;
}
+ lineWidth += (line.isEmpty() ? 0 : 1) + wordWidth;
line.append(line.isEmpty() ? "" : " ").append(word);
}
if (!line.isEmpty()) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelper.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelper.java
index 565d2f38ae88..9de34526dd7d 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelper.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelper.java
@@ -22,6 +22,7 @@ import java.util.List;
import dev.tamboui.layout.Rect;
import dev.tamboui.style.Style;
import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.CharWidth;
import dev.tamboui.text.Line;
import dev.tamboui.text.Span;
import dev.tamboui.text.Text;
@@ -106,7 +107,7 @@ final class DialogHelper {
Style accent = danger ? Theme.error() : Theme.warning();
String titleText = " " + title + " ";
String msg = message.trim();
- int popupW = clampWidth(area, CONFIRM_MIN_WIDTH, Math.max(msg.length() + 6, titleText.length() + 4));
+ int popupW = clampWidth(area, CONFIRM_MIN_WIDTH, Math.max(CharWidth.of(msg) + 6, CharWidth.of(titleText) + 4));
Rect popup = centered(area, popupW, CONFIRM_HEIGHT);
frame.renderWidget(Clear.INSTANCE, popup);
@@ -149,8 +150,8 @@ final class DialogHelper {
Line hints = extraKey != null
? TuiHelper.hintLine(acceptKey, acceptLabel, extraKey, extraLabel, "Esc", "cancel")
: TuiHelper.hintLine(acceptKey, acceptLabel, "Esc", "cancel");
- int contentW = Math.max(msg.length(), Math.max(detail != null ? detail.length() : 0, hints.width()));
- int popupW = clampWidth(area, CONFIRM_MIN_WIDTH, Math.max(contentW + 6, titleText.length() + 4));
+ int contentW = Math.max(CharWidth.of(msg), Math.max(detail != null ? CharWidth.of(detail) : 0, hints.width()));
+ int popupW = clampWidth(area, CONFIRM_MIN_WIDTH, Math.max(contentW + 6, CharWidth.of(titleText) + 4));
Rect popup = centered(area, popupW, CONFIRM_HEIGHT + (detail != null ? 1 : 0));
frame.renderWidget(Clear.INSTANCE, popup);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java
index 910a3beb4dab..32ada8e89731 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java
@@ -28,6 +28,7 @@ import java.util.function.Supplier;
import dev.tamboui.layout.Rect;
import dev.tamboui.style.Style;
import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.CharWidth;
import dev.tamboui.text.Line;
import dev.tamboui.text.Span;
import dev.tamboui.text.Text;
@@ -75,15 +76,20 @@ class DoctorPopup {
return out;
}
StringBuilder line = new StringBuilder();
+ int lineWidth = 0;
for (String word : text.trim().split("\\s+")) {
- if (line.length() > 0 && line.length() + 1 + word.length() > width) {
+ int wordWidth = CharWidth.of(word);
+ if (line.length() > 0 && lineWidth + 1 + wordWidth > width) {
out.add(line.toString());
line.setLength(0);
+ lineWidth = 0;
}
if (line.length() > 0) {
line.append(' ');
+ lineWidth++;
}
line.append(word);
+ lineWidth += wordWidth;
}
if (line.length() > 0) {
out.add(line.toString());
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
index 08f941f0cd91..6e94f254002d 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
@@ -18,15 +18,22 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
+import java.util.regex.Pattern;
import dev.tamboui.buffer.Buffer;
+import dev.tamboui.buffer.Cell;
import dev.tamboui.layout.Rect;
import dev.tamboui.style.Color;
+import dev.tamboui.style.ColorConverter;
import dev.tamboui.style.Style;
import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.CharWidth;
class DrawOverlay {
+ private static final Pattern SEPARATOR_LESS = Pattern.compile("^(light|dark|bright)(?=[a-z])");
+
record DrawCell(int x, int y, String symbol, Style style) {
}
@@ -77,7 +84,7 @@ class DrawOverlay {
if (cell.symbol == null) {
// highlight mode: keep existing symbol, apply style as background
var existing = buffer.get(cell.x, cell.y);
- if (existing != null && existing != dev.tamboui.buffer.Cell.CONTINUATION) {
+ if (existing != null && existing != Cell.CONTINUATION) {
Style merged = existing.style().bg(cell.style.bg().orElse(Color.YELLOW));
buffer.setString(cell.x, cell.y, existing.symbol(), merged);
}
@@ -175,34 +182,23 @@ class DrawOverlay {
}
String ch = new String(Character.toChars(cp));
cells.add(new DrawCell(col, row, ch, s));
- col += Math.max(1, dev.tamboui.text.CharWidth.of(cp));
+ col += Math.max(1, CharWidth.of(cp));
i += Character.charCount(cp);
}
return cells;
}
+ /**
+ * A color by name (red, light_blue, lightblue, bright-cyan, ...), as #rrggbb or #rgb, rgb(r, g, b) or indexed(n),
+ * or null when the name is not a color.
+ */
static Color parseColor(String name) {
if (name == null || name.isBlank()) {
return null;
}
- return switch (name.toLowerCase().trim()) {
- case "red" -> Color.RED;
- case "green" -> Color.GREEN;
- case "blue" -> Color.BLUE;
- case "yellow" -> Color.YELLOW;
- case "cyan" -> Color.CYAN;
- case "magenta" -> Color.MAGENTA;
- case "white" -> Color.WHITE;
- case "gray", "grey" -> Color.GRAY;
- case "dark_gray", "dark_grey", "darkgray", "darkgrey" -> Color.DARK_GRAY;
- case "light_red", "lightred" -> Color.LIGHT_RED;
- case "light_green", "lightgreen" -> Color.LIGHT_GREEN;
- case "light_blue", "lightblue" -> Color.LIGHT_BLUE;
- case "light_yellow", "lightyellow" -> Color.LIGHT_YELLOW;
- case "light_cyan", "lightcyan" -> Color.LIGHT_CYAN;
- case "light_magenta", "lightmagenta" -> Color.LIGHT_MAGENTA;
- case "black" -> Color.BLACK;
- default -> null;
- };
+ // TamboUI names them light-red and dark-gray; light_red and lightred are taken too
+ String normalized = SEPARATOR_LESS.matcher(name.trim().toLowerCase(Locale.ROOT).replace('_', '-'))
+ .replaceFirst("$1-");
+ return ColorConverter.INSTANCE.convert(normalized).orElse(null);
}
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ExampleBrowserPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ExampleBrowserPopup.java
index 5412bf82c3d6..33b1b6db9872 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ExampleBrowserPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ExampleBrowserPopup.java
@@ -199,20 +199,12 @@ class ExampleBrowserPopup {
if (popupRect == null || itemHeights == null) {
return -1;
}
- int firstRow = popupRect.top() + 1;
- int relY = mouseY - firstRow;
- if (relY < 0) {
+ // the rows inside the border; the list's offset counts rows, not items
+ int relY = mouseY - (popupRect.top() + 1);
+ if (relY >= popupRect.height() - 2) {
return -1;
}
- int offset = listState.offset();
- int rowAcc = 0;
- for (int i = offset; i < itemHeights.length; i++) {
- rowAcc += itemHeights[i];
- if (relY < rowAcc) {
- return i;
- }
- }
- return -1;
+ return TuiHelper.listItemAtRow(relY, listState.offset(), itemHeights);
}
void render(Frame frame, Rect area) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileActionsPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileActionsPopup.java
index 6161cb4538b4..540fbfa0e43b 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileActionsPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileActionsPopup.java
@@ -246,10 +246,10 @@ class FileActionsPopup {
inputState.moveCursorToStart();
} else if (ke.isEnd()) {
inputState.moveCursorToEnd();
- } else if (ke.code() == KeyCode.CHAR) {
- char ch = ke.string().charAt(0);
- if (ch >= 0x20 && ch != 0x7F) {
- inputState.insert(ch);
+ } else {
+ String typed = FormHelper.typedText(ke);
+ if (typed != null) {
+ inputState.insert(typed);
}
}
return true;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderInputPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderInputPopup.java
index 626386949b06..8ed832157782 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderInputPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderInputPopup.java
@@ -318,10 +318,10 @@ class FolderInputPopup {
inputState.moveCursorToStart();
} else if (ke.isEnd()) {
inputState.moveCursorToEnd();
- } else if (ke.code() == KeyCode.CHAR) {
- char ch = ke.string().charAt(0);
- if (ch >= 0x20 && ch != 0x7F) {
- inputState.insert(ch);
+ } else {
+ String typed = FormHelper.typedText(ke);
+ if (typed != null) {
+ inputState.insert(typed);
}
}
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelper.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelper.java
index e5ccd249d41e..c12887bce726 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelper.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelper.java
@@ -16,6 +16,8 @@
*/
package org.apache.camel.dsl.jbang.core.commands.tui;
+import java.util.List;
+
import dev.tamboui.layout.Rect;
import dev.tamboui.style.Overflow;
import dev.tamboui.style.Style;
@@ -37,6 +39,19 @@ final class FormHelper {
record HeaderEntry(TextInputState keyInput, TextInputState valueInput) {
}
+ /**
+ * The text a key types into a field: the whole character, also one outside the basic plane such as an emoji (where
+ * {@code KeyEvent.character()} gives U+FFFD and {@code string().charAt(0)} half of it). Null when the key types
+ * nothing: it is no character, a control character, or a character pressed with Ctrl or Alt (Ctrl+A is no "a").
+ */
+ static String typedText(KeyEvent ke) {
+ if (ke.code() != KeyCode.CHAR || ke.hasCtrl() || ke.hasAlt()) {
+ return null;
+ }
+ int cp = ke.codePoint();
+ return cp < 0x20 || cp == 0x7F ? null : ke.string();
+ }
+
static void handleTextInput(KeyEvent ke, TextInputState state) {
if (ke.isDeleteBackward()) {
state.deleteBackward();
@@ -50,8 +65,11 @@ final class FormHelper {
state.moveCursorToStart();
} else if (ke.isEnd()) {
state.moveCursorToEnd();
- } else if (ke.code() == KeyCode.CHAR) {
- state.insert(ke.string().charAt(0));
+ } else {
+ String typed = typedText(ke);
+ if (typed != null) {
+ state.insert(typed);
+ }
}
}
@@ -74,9 +92,29 @@ final class FormHelper {
editor.deleteBackward();
} else if (key.isDeleteForward()) {
editor.deleteForward();
- } else if (key.code() == KeyCode.CHAR && !key.hasCtrl()) {
- editor.insert(key.string());
+ } else {
+ String typed = typedText(key);
+ if (typed != null) {
+ editor.insert(typed);
+ }
+ }
+ }
+
+ /**
+ * Adds a text field to a status line: its text with a block caret at the cursor, so Left and Right show where the
+ * next character goes (a caret drawn after the text always looks as if it were at the end).
+ */
+ static void addCaretField(List<Span> spans, TextInputState state) {
+ String text = state.text();
+ int pos = Math.max(0, Math.min(state.cursorPosition(), text.length()));
+ if (pos == text.length()) {
+ spans.add(Span.raw(text + "█"));
+ return;
}
+ int end = pos + Character.charCount(text.codePointAt(pos));
+ spans.add(Span.raw(text.substring(0, pos)));
+ spans.add(Span.styled(text.substring(pos, end), Style.EMPTY.reversed()));
+ spans.add(Span.raw(text.substring(end)));
}
static void renderLabel(Frame frame, int x, int y, int w, String label, boolean selected) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilter.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilter.java
index e87524a6817c..c0f5dc7e431d 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilter.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilter.java
@@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
import dev.tamboui.style.Style;
import dev.tamboui.text.Line;
@@ -48,9 +49,15 @@ class FuzzyFilter {
filter += Character.toLowerCase(c);
}
+ /** Adds the typed text, a whole character also when it is outside the basic plane, such as an emoji. */
+ void append(String typed) {
+ filter += typed.toLowerCase(Locale.ROOT);
+ }
+
+ /** Removes the last character typed, both chars of one outside the basic plane. */
void deleteChar() {
if (!filter.isEmpty()) {
- filter = filter.substring(0, filter.length() - 1);
+ filter = filter.substring(0, filter.offsetByCodePoints(filter.length(), -1));
}
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoNodePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoNodePopup.java
index 368a2c7cedc1..a4f6b99a0d72 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoNodePopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoNodePopup.java
@@ -34,7 +34,6 @@ import dev.tamboui.widgets.list.ListItem;
import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
import dev.tamboui.widgets.list.ScrollMode;
-import dev.tamboui.widgets.scrollbar.Scrollbar;
import dev.tamboui.widgets.scrollbar.ScrollbarState;
import org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget;
@@ -136,7 +135,7 @@ class GotoNodePopup {
return true;
}
if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
- filter.appendChar(ke.string().charAt(0));
+ filter.append(ke.string());
rebuildList();
return true;
}
@@ -228,15 +227,7 @@ class GotoNodePopup {
.build())
.build();
frame.renderStatefulWidget(list, popup, renderState);
-
- int visibleRows = Math.max(1, popup.height() - 2);
- if (shown + 2 > visibleRows) {
- scrollbarState
- .contentLength(shown)
- .viewportContentLength(visibleRows)
- .position(sel != null ? sel : 0);
- frame.renderStatefulWidget(Scrollbar.builder().build(), popup, scrollbarState);
- }
+ TuiHelper.renderListScrollbar(frame, popup, shown + 2, renderState.offset(), scrollbarState);
}
private void rebuildList() {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
index 2dd9fce760a6..21cc43140994 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
@@ -34,7 +34,6 @@ import dev.tamboui.widgets.list.ListItem;
import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
import dev.tamboui.widgets.list.ScrollMode;
-import dev.tamboui.widgets.scrollbar.Scrollbar;
import dev.tamboui.widgets.scrollbar.ScrollbarState;
class GotoRoutePopup {
@@ -131,7 +130,7 @@ class GotoRoutePopup {
return true;
}
if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
- filter.appendChar(ke.string().charAt(0));
+ filter.append(ke.string());
rebuildList();
return true;
}
@@ -228,15 +227,7 @@ class GotoRoutePopup {
.build())
.build();
frame.renderStatefulWidget(list, popup, renderState);
-
- int visibleRows = Math.max(1, popup.height() - 2);
- if (shown + 2 > visibleRows) {
- scrollbarState
- .contentLength(shown)
- .viewportContentLength(visibleRows)
- .position(sel != null ? sel : 0);
- frame.renderStatefulWidget(Scrollbar.builder().build(), popup, scrollbarState);
- }
+ TuiHelper.renderListScrollbar(frame, popup, shown + 2, renderState.offset(), scrollbarState);
}
private void rebuildList() {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopup.java
index 6d1b1765da51..e5d13214e9d8 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopup.java
@@ -37,7 +37,6 @@ import dev.tamboui.widgets.list.ListItem;
import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
import dev.tamboui.widgets.list.ScrollMode;
-import dev.tamboui.widgets.scrollbar.Scrollbar;
import dev.tamboui.widgets.scrollbar.ScrollbarState;
import org.apache.camel.dsl.jbang.core.commands.tui.diagram.DiagramColors;
@@ -139,7 +138,7 @@ class GotoSourceNodePopup {
return true;
}
if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
- filter.appendChar(ke.string().charAt(0));
+ filter.append(ke.string());
rebuildList();
return true;
}
@@ -267,15 +266,7 @@ class GotoSourceNodePopup {
.build())
.build();
frame.renderStatefulWidget(list, popup, renderState);
-
- int visibleRows = Math.max(1, popup.height() - 2);
- if (shown + 2 > visibleRows) {
- scrollbarState
- .contentLength(shown)
- .viewportContentLength(visibleRows)
- .position(sel != null ? sel : 0);
- frame.renderStatefulWidget(Scrollbar.builder().build(), popup, scrollbarState);
- }
+ TuiHelper.renderListScrollbar(frame, popup, shown + 2, renderState.offset(), scrollbarState);
}
static String buildTreePrefix(
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoTabPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoTabPopup.java
index 21b7c19c54d9..093d02f71bdc 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoTabPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoTabPopup.java
@@ -36,7 +36,6 @@ import dev.tamboui.widgets.list.ListItem;
import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
import dev.tamboui.widgets.list.ScrollMode;
-import dev.tamboui.widgets.scrollbar.Scrollbar;
import dev.tamboui.widgets.scrollbar.ScrollbarState;
class GotoTabPopup {
@@ -49,6 +48,8 @@ class GotoTabPopup {
private List<TabRegistry.TabEntry> activeEntries;
private List<TabRegistry.TabEntry> filteredEntries;
private Rect popupRect;
+ // the rows the list was scrolled by in the last render, for a click
+ private int listOffset;
private Runnable callback;
private TabRegistry.TabEntry pendingEntry;
private java.util.function.Supplier<IntegrationInfo> integrationSupplier;
@@ -136,7 +137,7 @@ class GotoTabPopup {
return true;
}
if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
- filter.appendChar(ke.string().charAt(0));
+ filter.append(ke.string());
rebuildList();
return true;
}
@@ -155,7 +156,7 @@ class GotoTabPopup {
}
if (me.isClick()) {
if (popupRect != null && popupRect.contains(me.x(), me.y())) {
- int idx = TuiHelper.listItemAt(popupRect, 0,
+ int idx = TuiHelper.listItemAt(popupRect, listOffset,
(filteredEntries != null ? filteredEntries.size() : 0) + 2,
me.x(), me.y());
if (idx >= 2 && filteredEntries != null && idx - 2 < filteredEntries.size()) {
@@ -274,15 +275,8 @@ class GotoTabPopup {
.build())
.build();
frame.renderStatefulWidget(list, popup, renderState);
-
- int visibleRows = Math.max(1, popup.height() - 2);
- if (shown + 2 > visibleRows) {
- scrollbarState
- .contentLength(shown)
- .viewportContentLength(visibleRows)
- .position(sel != null ? sel : 0);
- frame.renderStatefulWidget(Scrollbar.builder().build(), popup, scrollbarState);
- }
+ listOffset = renderState.offset();
+ TuiHelper.renderListScrollbar(frame, popup, shown + 2, listOffset, scrollbarState);
}
SelectionContext getSelectionContext() {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HelpOverlay.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HelpOverlay.java
index a73b1d81ac14..6061c4d92729 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HelpOverlay.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HelpOverlay.java
@@ -36,6 +36,8 @@ class HelpOverlay {
private boolean visible;
private String markdown;
private int scroll;
+ // the furthest the help scrolls, from the last render (unknown before it)
+ private int maxScroll = Integer.MAX_VALUE;
boolean isVisible() {
return visible;
@@ -44,6 +46,7 @@ class HelpOverlay {
void open(String markdown) {
this.markdown = markdown;
this.scroll = 0;
+ this.maxScroll = Integer.MAX_VALUE;
this.visible = true;
}
@@ -66,7 +69,7 @@ class HelpOverlay {
return true;
}
if (ke.isDown() || ke.isChar('j')) {
- scroll++;
+ scroll = Math.min(maxScroll, scroll + 1);
return true;
}
if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
@@ -74,7 +77,7 @@ class HelpOverlay {
return true;
}
if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
- scroll += 10;
+ scroll = Math.min(maxScroll, scroll + 10);
return true;
}
return true;
@@ -94,14 +97,15 @@ class HelpOverlay {
.title(" Help ")
.build();
- MarkdownView view = MarkdownView.builder()
+ MarkdownView.Builder view = MarkdownView.builder()
.source(markdown)
- .scroll(scroll)
- .block(block)
.styles(Theme.markdownStyles())
- .syntaxTheme(Theme.syntaxTheme())
- .build();
- frame.renderWidget(view, popup);
+ .syntaxTheme(Theme.syntaxTheme());
+ // the scroll stays within the text, so Up moves at once after scrolling down past the end
+ Rect inner = block.inner(popup);
+ maxScroll = Math.max(0, view.build().computeHeight(inner.width()) - inner.height());
+ scroll = Math.min(scroll, maxScroll);
+ frame.renderWidget(view.scroll(scroll).block(block).build(), popup);
}
void renderFooter(List<Span> spans) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
index 3994e706f4a8..b22083df7e5b 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
@@ -36,7 +36,6 @@ import dev.tamboui.layout.Rect;
import dev.tamboui.style.Overflow;
import dev.tamboui.style.Style;
import dev.tamboui.terminal.Frame;
-import dev.tamboui.text.CharWidth;
import dev.tamboui.text.Line;
import dev.tamboui.text.Span;
import dev.tamboui.text.Text;
@@ -2433,41 +2432,11 @@ class HistoryTab extends AbstractTab {
private static List<Line> applyHSkip(List<Line> lines, int hSkip) {
List<Line> result = new ArrayList<>(lines.size());
for (Line line : lines) {
- result.add(hSkipLine(line, hSkip));
+ result.add(TuiHelper.skipColumns(line, hSkip));
}
return result;
}
- private static Line hSkipLine(Line line, int hSkip) {
- List<Span> result = new ArrayList<>();
- int skip = hSkip;
- for (Span span : line.spans()) {
- if (skip <= 0) {
- result.add(span);
- continue;
- }
- String text = span.content();
- int spanWidth = CharWidth.of(text);
- if (spanWidth <= skip) {
- skip -= spanWidth;
- } else {
- int i = 0;
- int consumed = 0;
- while (i < text.length() && consumed < skip) {
- int cp = text.codePointAt(i);
- consumed += CharWidth.of(cp);
- i += Character.charCount(cp);
- }
- skip = 0;
- String remaining = text.substring(i);
- if (!remaining.isEmpty()) {
- result.add(Span.styled(remaining, span.style()));
- }
- }
- }
- return Line.from(result);
- }
-
@Override
public SelectionContext getSelectionContext() {
boolean tracerActive = !traces.get().isEmpty();
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpProbe.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpProbe.java
index 3e7449cfb384..dea4b8ef1df2 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpProbe.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpProbe.java
@@ -478,8 +478,9 @@ class HttpProbe {
activeInput.moveCursorToEnd();
return true;
}
- if (ke.code() == KeyCode.CHAR) {
- activeInput.insert(ke.string().charAt(0));
+ String typed = FormHelper.typedText(ke);
+ if (typed != null) {
+ activeInput.insert(typed);
return true;
}
return true;
@@ -562,8 +563,9 @@ class HttpProbe {
probeBodyState.deleteForward();
return true;
}
- if (ke.code() == KeyCode.CHAR) {
- probeBodyState.insert(ke.character());
+ String typed = FormHelper.typedText(ke);
+ if (typed != null) {
+ probeBodyState.insert(typed);
return true;
}
return true;
@@ -669,8 +671,9 @@ class HttpProbe {
activeInput.moveCursorToEnd();
return true;
}
- if (ke.code() == KeyCode.CHAR) {
- activeInput.insert(ke.string().charAt(0));
+ String typed = FormHelper.typedText(ke);
+ if (typed != null) {
+ activeInput.insert(typed);
return true;
}
return true;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
index 667e5f03bbdf..d54b6e09fa70 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
@@ -163,20 +163,12 @@ class InfraBrowserPopup {
if (popupRect == null || itemHeights == null) {
return -1;
}
- int firstRow = popupRect.top() + 1;
- int relY = mouseY - firstRow;
- if (relY < 0) {
+ // the rows inside the border; the list's offset counts rows, not items
+ int relY = mouseY - (popupRect.top() + 1);
+ if (relY >= popupRect.height() - 2) {
return -1;
}
- int offset = browserState.offset();
- int rowAcc = 0;
- for (int i = offset; i < itemHeights.length; i++) {
- rowAcc += itemHeights[i];
- if (relY < rowAcc) {
- return i;
- }
- }
- return -1;
+ return TuiHelper.listItemAtRow(relY, browserState.offset(), itemHeights);
}
void render(Frame frame, Rect area) {
@@ -472,8 +464,8 @@ class InfraBrowserPopup {
portState.moveCursorToStart();
} else if (ke.isEnd()) {
portState.moveCursorToEnd();
- } else if (ke.code() == KeyCode.CHAR && Character.isDigit(ke.string().charAt(0))) {
- portState.insert(ke.string().charAt(0));
+ } else if (FormHelper.typedText(ke) != null && Character.isDigit(ke.codePoint())) {
+ portState.insert(ke.string());
}
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InputHistory.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InputHistory.java
index a341162e898e..66751ceacbde 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InputHistory.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InputHistory.java
@@ -27,6 +27,7 @@ import dev.tamboui.tui.event.KeyEvent;
import dev.tamboui.widgets.Clear;
import dev.tamboui.widgets.block.Block;
import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
import dev.tamboui.widgets.list.ListItem;
import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
@@ -141,7 +142,7 @@ class InputHistory {
.highlightStyle(Theme.selectionBg())
.highlightSymbol("")
.block(Block.builder()
- .borderType(BorderType.ROUNDED)
+ .borderType(BorderType.ROUNDED).borders(Borders.ALL)
.title(" " + title + " ")
.build())
.build();
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
index 451da88bc679..b34f0d2499ba 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
@@ -398,14 +398,15 @@ class LogTab extends AbstractTab {
line = compactLine(entry);
if (!wordWrap) {
maxW = Math.max(maxW, line.width());
- line = hSkip > 0 ? TuiHelper.ansiToLine(lineText(line), hSkip) : line;
+ line = TuiHelper.skipColumns(line, hSkip);
}
} else {
if (!wordWrap) {
maxW = Math.max(maxW, CharWidth.of(TuiHelper.stripAnsi(raw)));
}
line = raw.indexOf('\u001B') >= 0
- ? TuiHelper.ansiToLine(raw, hSkip) : colorizePlainLog(raw, entry);
+ ? TuiHelper.ansiToLine(raw, hSkip)
+ : TuiHelper.skipColumns(colorizePlainLog(raw, entry), hSkip);
}
if (entry.repeat > 1) {
line = line.append(Span.styled(" (x" + entry.repeat + ")", Theme.warning().bold()));
@@ -587,7 +588,7 @@ class LogTab extends AbstractTab {
.items(items.toArray(new ListItem[0]))
.highlightStyle(Theme.selectionBg())
.highlightSymbol("")
- .scrollMode(ScrollMode.NONE)
+ .scrollMode(ScrollMode.AUTO_SCROLL)
.block(Block.builder()
.borderType(BorderType.ROUNDED).borders(Borders.ALL)
.title(" Fix with AI: which ERROR? ")
@@ -615,7 +616,7 @@ class LogTab extends AbstractTab {
ListItem.from(" TRACE ").style(Style.EMPTY.dim()))
.highlightStyle(Theme.selectionBg())
.highlightSymbol("")
- .scrollMode(ScrollMode.NONE)
+ .scrollMode(ScrollMode.AUTO_SCROLL)
.block(Block.builder()
.borderType(BorderType.ROUNDED).borders(Borders.ALL)
.title(" Set Log Level ")
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MavenDependenciesTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MavenDependenciesTab.java
index fe6b7097846e..f9de538f8af4 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MavenDependenciesTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MavenDependenciesTab.java
@@ -315,7 +315,8 @@ class MavenDependenciesTab extends AbstractTableTab {
public void renderFooter(List<Span> spans) {
if (filterInputActive) {
spans.add(Span.styled(" /", Theme.label().bold()));
- spans.add(Span.raw(filterInputState.text() + "█ "));
+ FormHelper.addCaretField(spans, filterInputState);
+ spans.add(Span.raw(" "));
hint(spans, "Enter", "filter");
hintLast(spans, "Esc", "cancel");
return;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index b67263863ab4..941a18b7ba71 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -903,23 +903,14 @@ class McpFacade {
}
String screen = ExportRequest.export(buf).text().toString();
String[] lines = screen.split("\n", -1);
- int searchWidth = 0;
- for (int i = 0; i < search.length();) {
- int cp = search.codePointAt(i);
- searchWidth += Math.max(1, CharWidth.of(cp));
- i += Character.charCount(cp);
- }
+ // the width TamboUI gives the text: a combining mark or a variation selector takes no column of its own
+ int searchWidth = CharWidth.of(search);
JsonArray matches = new JsonArray();
for (int y = 0; y < lines.length; y++) {
String line = lines[y];
int idx = line.indexOf(search);
while (idx >= 0) {
- int visualCol = 0;
- for (int i = 0; i < idx;) {
- int cp = line.codePointAt(i);
- visualCol += Math.max(1, CharWidth.of(cp));
- i += Character.charCount(cp);
- }
+ int visualCol = CharWidth.of(line.substring(0, idx));
JsonObject match = new JsonObject();
match.put("x", visualCol);
match.put("y", y);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpLogPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpLogPopup.java
index 79d5b984e4b4..319283032b63 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpLogPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpLogPopup.java
@@ -94,6 +94,7 @@ class McpLogPopup {
} else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
detailScroll = Math.max(0, detailScroll - 5);
} else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
+ // kept within the detail when it is drawn
detailScroll += 5;
}
return true;
@@ -202,10 +203,11 @@ class McpLogPopup {
int visibleLines = inner.height();
int totalLines = lines.size();
- int clampedScroll = Math.min(detailScroll, Math.max(0, totalLines - visibleLines));
- int end = Math.min(clampedScroll + visibleLines, totalLines);
- if (clampedScroll < end) {
- List<Line> visible = lines.subList(clampedScroll, end);
+ // the scroll stays within the detail, so PgUp moves at once after PgDn went past its end
+ detailScroll = Math.min(detailScroll, Math.max(0, totalLines - visibleLines));
+ int end = Math.min(detailScroll + visibleLines, totalLines);
+ if (detailScroll < end) {
+ List<Line> visible = lines.subList(detailScroll, end);
frame.renderWidget(
Paragraph.builder().text(Text.from(visible.toArray(Line[]::new))).build(),
inner);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
index 544753f51a92..e298d86b8522 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
@@ -38,7 +38,6 @@ import dev.tamboui.widgets.list.ListItem;
import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
import dev.tamboui.widgets.list.ScrollMode;
-import dev.tamboui.widgets.scrollbar.Scrollbar;
import dev.tamboui.widgets.scrollbar.ScrollbarState;
/**
@@ -76,6 +75,7 @@ class PopupManager {
private boolean showMorePopup;
private final ListState morePopupState = new ListState();
private final ScrollbarState moreScrollbarState = new ScrollbarState();
+ private final ScrollbarState switchScrollbarState = new ScrollbarState();
private int lastMoreSelection;
private char lastShortcutLetter;
private int lastShortcutIndex = -1;
@@ -506,7 +506,7 @@ class PopupManager {
if (lastMorePopupRect == null) {
return false;
}
- boolean inside = TuiHelper.contains(lastMorePopupRect, me.x(), me.y());
+ boolean inside = lastMorePopupRect.contains(me.x(), me.y());
if (me.isClick() && !inside) {
showMorePopup = false;
@@ -516,8 +516,10 @@ class PopupManager {
return true;
}
int total = moreVisualCount();
- int itemIndex = me.y() - lastMorePopupRect.y() - 1;
- if (itemIndex < 0 || itemIndex >= total) {
+ // below the top border, from the first item the scrolled list shows
+ int row = me.y() - lastMorePopupRect.y() - 1;
+ int itemIndex = morePopupState.offset() + row;
+ if (row < 0 || row >= lastMorePopupRect.height() - 2 || itemIndex >= total) {
return true;
}
if (me.kind() == MouseEventKind.SCROLL_UP) {
@@ -551,7 +553,7 @@ class PopupManager {
return false;
}
List<IntegrationInfo> switchList = nonVanishingIntegrationsSupplier.get();
- boolean inside = TuiHelper.contains(lastSwitchPopupRect, me.x(), me.y());
+ boolean inside = lastSwitchPopupRect.contains(me.x(), me.y());
if (me.isClick() && !inside) {
showSwitchPopup = false;
@@ -560,8 +562,10 @@ class PopupManager {
if (!inside) {
return true;
}
- int itemIndex = me.y() - lastSwitchPopupRect.y() - 1; // -1 for top border
- if (itemIndex < 0 || itemIndex >= switchList.size()) {
+ // below the top border, from the first integration the scrolled list shows
+ int row = me.y() - lastSwitchPopupRect.y() - 1;
+ int itemIndex = switchPopupState.offset() + row;
+ if (row < 0 || row >= lastSwitchPopupRect.height() - 2 || itemIndex >= switchList.size()) {
return true;
}
if (me.kind() == MouseEventKind.SCROLL_UP) {
@@ -629,16 +633,7 @@ class PopupManager {
.build())
.build();
frame.renderStatefulWidget(list, popup, morePopupState);
-
- int visibleRows = Math.max(1, popup.height() - 2);
- if (itemCount > visibleRows) {
- Integer sel = morePopupState.selected();
- moreScrollbarState
- .contentLength(itemCount)
- .viewportContentLength(visibleRows)
- .position(sel != null ? sel : 0);
- frame.renderStatefulWidget(Scrollbar.builder().build(), popup, moreScrollbarState);
- }
+ TuiHelper.renderListScrollbar(frame, popup, itemCount, morePopupState.offset(), moreScrollbarState);
}
private ListItem[] morePopupItems(Style keyStyle) {
@@ -740,13 +735,15 @@ class PopupManager {
.items(items)
.highlightStyle(Theme.selectionBg())
.highlightSymbol("")
- .scrollMode(ScrollMode.NONE)
+ // scrolls to the selection when not every integration fits
+ .scrollMode(ScrollMode.AUTO_SCROLL)
.block(Block.builder()
.borderType(BorderType.ROUNDED).borders(Borders.ALL)
.title(" Switch Integration ")
.build())
.build();
frame.renderStatefulWidget(listWidget, popup, switchPopupState);
+ TuiHelper.renderListScrollbar(frame, popup, items.length, switchPopupState.offset(), switchScrollbarState);
}
void renderKillConfirm(Frame frame, Rect area) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessControlPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessControlPopup.java
index fd7e156e71df..e2963cfb68e2 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessControlPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessControlPopup.java
@@ -179,7 +179,7 @@ class ProcessControlPopup {
.items(items)
.highlightStyle(Theme.selectionBg())
.highlightSymbol("")
- .scrollMode(ScrollMode.NONE)
+ .scrollMode(ScrollMode.AUTO_SCROLL)
.block(Block.builder()
.borderType(BorderType.ROUNDED)
.borders(Borders.ALL)
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManager.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManager.java
index 86f62ebd3096..383a77bcea6c 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManager.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManager.java
@@ -128,8 +128,13 @@ class RecordingManager {
// ---- Buffer tracking ----
+ /**
+ * Keeps a copy of the frame just rendered for the MCP tools, the tape player and screenshots. TamboUI clears and
+ * draws into the same two buffers in turn, so another thread must not read the live one; the copy is one array of
+ * references to immutable cells.
+ */
void updateBuffer(Buffer buffer) {
- lastBuffer = buffer;
+ lastBuffer = buffer.copy();
renderGeneration++;
}
@@ -238,7 +243,31 @@ class RecordingManager {
// ---- Key label utility ----
+ /**
+ * The label of a key, as shown while recording and written to a tape: the key's name (Enter, F2, an arrow) or the
+ * character typed, with Ctrl+, Alt+ and Shift+ in front when they were held (Shift+ with a character only together
+ * with Ctrl or Alt, as the character shows Shift itself). Null for a key it has no name for.
+ */
static String keyLabel(KeyEvent ke) {
+ String name = keyName(ke);
+ if (name == null) {
+ return null;
+ }
+ boolean character = ke.code() == KeyCode.CHAR && !"Space".equals(name);
+ StringBuilder label = new StringBuilder();
+ if (ke.hasCtrl()) {
+ label.append("Ctrl+");
+ }
+ if (ke.hasAlt()) {
+ label.append("Alt+");
+ }
+ if (ke.hasShift() && (!character || ke.hasCtrl() || ke.hasAlt())) {
+ label.append("Shift+");
+ }
+ return label.append(name).toString();
+ }
+
+ private static String keyName(KeyEvent ke) {
if (ke.isKey(KeyCode.ENTER)) {
return "Enter";
}
@@ -246,7 +275,7 @@ class RecordingManager {
return "Esc";
}
if (ke.isKey(KeyCode.TAB)) {
- return ke.hasShift() ? "⇧Tab" : "Tab";
+ return "Tab";
}
if (ke.isKey(KeyCode.UP)) {
return TuiIcons.ARROW_UP;
@@ -275,6 +304,9 @@ class RecordingManager {
if (ke.isKey(KeyCode.BACKSPACE)) {
return "⌫";
}
+ if (ke.isKey(KeyCode.DELETE)) {
+ return "Delete";
+ }
for (int i = 1; i <= 12; i++) {
try {
KeyCode fKey = KeyCode.valueOf("F" + i);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RefactorPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RefactorPopup.java
index a24dc69cef76..a1062b3e9ff8 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RefactorPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RefactorPopup.java
@@ -24,7 +24,6 @@ import dev.tamboui.layout.Rect;
import dev.tamboui.terminal.Frame;
import dev.tamboui.text.Line;
import dev.tamboui.text.Span;
-import dev.tamboui.tui.event.KeyCode;
import dev.tamboui.tui.event.KeyEvent;
import dev.tamboui.widgets.Clear;
import dev.tamboui.widgets.block.Block;
@@ -184,10 +183,10 @@ class RefactorPopup {
inputState.moveCursorToStart();
} else if (ke.isEnd()) {
inputState.moveCursorToEnd();
- } else if (ke.code() == KeyCode.CHAR) {
- char ch = ke.string().charAt(0);
- if (ch >= 0x20 && ch != 0x7F) {
- inputState.insert(ch);
+ } else {
+ String typed = FormHelper.typedText(ke);
+ if (typed != null) {
+ inputState.insert(typed);
}
}
return true;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
index a4f2296136ba..4d70ba9fa0ff 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
@@ -26,7 +26,6 @@ import dev.tamboui.terminal.Frame;
import dev.tamboui.text.Line;
import dev.tamboui.text.Span;
import dev.tamboui.text.Text;
-import dev.tamboui.tui.event.KeyCode;
import dev.tamboui.tui.event.KeyEvent;
import dev.tamboui.widgets.Clear;
import dev.tamboui.widgets.block.Block;
@@ -801,7 +800,7 @@ class RunOptionsForm {
active.moveCursorToStart();
} else if (ke.isEnd()) {
active.moveCursorToEnd();
- } else if (ke.code() == KeyCode.CHAR) {
+ } else if (FormHelper.typedText(ke) != null) {
char c = ke.string().charAt(0);
String text = active.text();
if (Character.isDigit(c)) {
@@ -877,13 +876,10 @@ class RunOptionsForm {
active.moveCursorToStart();
} else if (ke.isEnd()) {
active.moveCursorToEnd();
- } else if (ke.code() == KeyCode.CHAR) {
- if (digitsOnly) {
- if (Character.isDigit(ke.string().charAt(0))) {
- active.insert(ke.string().charAt(0));
- }
- } else {
- active.insert(ke.string().charAt(0));
+ } else {
+ String typed = FormHelper.typedText(ke);
+ if (typed != null && (!digitsOnly || Character.isDigit(typed.codePointAt(0)))) {
+ active.insert(typed);
}
}
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java
index 8eace475c730..2d7e7fcbe994 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java
@@ -261,14 +261,16 @@ class SearchHighlighter {
void renderFooterHints(List<Span> spans) {
if (findInputActive) {
spans.add(Span.styled(" /", Theme.hintKey()));
- spans.add(Span.raw(searchInputState.text() + "█ "));
+ FormHelper.addCaretField(spans, searchInputState);
+ spans.add(Span.raw(" "));
hint(spans, "Enter", "search");
hintLast(spans, "Esc", "cancel");
return;
}
if (highlightInputActive) {
spans.add(Span.styled(" h:", Theme.hintKey()));
- spans.add(Span.raw(searchInputState.text() + "█ "));
+ FormHelper.addCaretField(spans, searchInputState);
+ spans.add(Span.raw(" "));
hint(spans, "Enter", "set");
hintLast(spans, "Esc", "cancel");
return;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticAuditView.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticAuditView.java
index 7eb2e1eec534..e690326b263f 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticAuditView.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticAuditView.java
@@ -910,7 +910,9 @@ final class SemanticAuditView {
hint(spans, "Enter", "apply");
hint(spans, "Esc", "cancel");
} else if (input != null) {
- spans.add(Span.raw(" /" + input.text() + "█ "));
+ spans.add(Span.raw(" /"));
+ FormHelper.addCaretField(spans, input);
+ spans.add(Span.raw(" "));
hint(spans, "Enter", "apply");
hint(spans, "Esc", "cancel");
} else {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticEvaluationPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticEvaluationPopup.java
index baaf09783d7e..8b59cf5ea1e8 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticEvaluationPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticEvaluationPopup.java
@@ -132,9 +132,9 @@ final class SemanticEvaluationPopup {
void handleMouseEvent(MouseEvent event) {
if (event.isClick()) {
- if (inputArea != null && contains(inputArea, event.x(), event.y())) {
+ if (inputArea != null && inputArea.contains(event.x(), event.y())) {
resultFocused = false;
- } else if (resultArea != null && contains(resultArea, event.x(), event.y())) {
+ } else if (resultArea != null && resultArea.contains(event.x(), event.y())) {
resultFocused = true;
}
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticParameterForm.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticParameterForm.java
index 632fba05ec98..340b9ed6b557 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticParameterForm.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticParameterForm.java
@@ -321,7 +321,7 @@ final class SemanticParameterForm {
boolean click(int x, int y) {
for (Hit hit : hits) {
- if (TuiHelper.contains(hit.area(), x, y)) {
+ if (hit.area().contains(x, y)) {
selected = hit.control();
if (selected.row() == ADD) {
handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER));
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticPlayground.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticPlayground.java
index c9520f1bd50d..3378b1752675 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticPlayground.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticPlayground.java
@@ -240,7 +240,7 @@ final class SemanticPlayground {
if (!event.isClick()) {
return false;
}
- if (runArea != null && contains(runArea, event.x(), event.y())) {
+ if (runArea != null && runArea.contains(event.x(), event.y())) {
run();
return true;
}
@@ -254,7 +254,7 @@ final class SemanticPlayground {
}
for (int i = 0; i < 3; i++) {
Rect area = i == 0 ? inputArea : i == 1 ? parameterArea : outputArea;
- if (area != null && contains(area, event.x(), event.y())) {
+ if (area != null && area.contains(event.x(), event.y())) {
field = i;
return true;
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticRelationships.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticRelationships.java
index 68fa3e1eec2e..e5ce6dae99cb 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticRelationships.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticRelationships.java
@@ -48,7 +48,7 @@ final class SemanticRelationships {
}
int clicked(int x, int y) {
- return hits.stream().filter(hit -> TuiHelper.contains(hit.area(), x, y)).mapToInt(Hit::index).findFirst().orElse(-1);
+ return hits.stream().filter(hit -> hit.area().contains(x, y)).mapToInt(Hit::index).findFirst().orElse(-1);
}
void render(
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTab.java
index 5e94dc322de8..bdb71c69ecbd 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTab.java
@@ -17,6 +17,7 @@
package org.apache.camel.dsl.jbang.core.commands.tui;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
@@ -618,9 +619,12 @@ class SemanticTab extends AbstractTableTab {
focus = Focus.PLAYGROUND;
return true;
}
- if (view == View.EXPERTS && lastTableArea != null && contains(lastTableArea, event.x(), event.y())) {
- int index = tableState.offset() + (event.y() - lastTableArea.y() - 1) / 2;
- if (event.y() >= lastTableArea.y() + 1 && index < rows().size()) {
+ if (view == View.EXPERTS && lastTableArea != null && lastTableArea.contains(event.x(), event.y())) {
+ // below the header; each expert takes two lines, and the offset counts lines
+ int[] heights = new int[rows().size()];
+ Arrays.fill(heights, 2);
+ int index = TuiHelper.tableRowAtLine(event.y() - lastTableArea.y() - 1, tableState.offset(), heights);
+ if (index >= 0) {
tableState.select(index);
focus = Focus.LIST;
selectionChanged();
@@ -645,7 +649,7 @@ class SemanticTab extends AbstractTableTab {
selectionChanged();
return true;
}
- if (detailArea != null && contains(detailArea, event.x(), event.y())) {
+ if (detailArea != null && detailArea.contains(event.x(), event.y())) {
focus = Focus.DETAIL;
return true;
}
@@ -885,7 +889,9 @@ class SemanticTab extends AbstractTableTab {
} else if (focus == Focus.PLAYGROUND && playground != null) {
playground.renderFooter(spans);
} else if (filterInput != null) {
- spans.add(Span.raw(" /" + filterInput.text() + "█ "));
+ spans.add(Span.raw(" /"));
+ FormHelper.addCaretField(spans, filterInput);
+ spans.add(Span.raw(" "));
hint(spans, "Enter", "filter");
hintLast(spans, "Esc", "cancel");
} else {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java
index af274382e9c3..6950a08d1992 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java
@@ -274,8 +274,9 @@ class SendMessagePopup {
bodyState.deleteForward();
return true;
}
- if (ke.code() == KeyCode.CHAR) {
- bodyState.insert(ke.character());
+ String typed = FormHelper.typedText(ke);
+ if (typed != null) {
+ bodyState.insert(typed);
return true;
}
return true;
@@ -441,8 +442,9 @@ class SendMessagePopup {
activeInput.moveCursorToEnd();
return true;
}
- if (ke.code() == KeyCode.CHAR) {
- activeInput.insert(ke.string().charAt(0));
+ String typed = FormHelper.typedText(ke);
+ if (typed != null) {
+ activeInput.insert(typed);
return true;
}
return true;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java
index d6d2498f5a16..064160032154 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java
@@ -24,7 +24,6 @@ import dev.tamboui.style.Style;
import dev.tamboui.terminal.Frame;
import dev.tamboui.text.Line;
import dev.tamboui.text.Span;
-import dev.tamboui.tui.event.KeyCode;
import dev.tamboui.tui.event.KeyEvent;
import dev.tamboui.widgets.Clear;
import dev.tamboui.widgets.block.Block;
@@ -649,10 +648,10 @@ class SettingsPopup {
active.moveCursorToStart();
} else if (ke.isEnd()) {
active.moveCursorToEnd();
- } else if (ke.code() == KeyCode.CHAR) {
- char ch = ke.string().charAt(0);
- if (ch >= 0x20 && ch != 0x7F) {
- active.insert(ch);
+ } else {
+ String typed = FormHelper.typedText(ke);
+ if (typed != null) {
+ active.insert(typed);
}
}
}
@@ -671,9 +670,13 @@ class SettingsPopup {
return sb.toString();
}
- /** The content line a row is drawn on, counting the dividers that follow rows 2, 6, 9 and 12. */
+ /**
+ * The content line a row is drawn on, counting the dividers that render draws after the Select Tab, Panel Space,
+ * Default Folder and Shell History rows.
+ */
static int lineOf(int row) {
- return row + (row > 2 ? 1 : 0) + (row > 6 ? 1 : 0) + (row > 9 ? 1 : 0) + (row > 12 ? 1 : 0);
+ return row + (row > ROW_SELECT_TAB ? 1 : 0) + (row > ROW_PANEL_SPACE ? 1 : 0) + (row > ROW_FOLDER ? 1 : 0)
+ + (row > ROW_SHELL_HISTORY ? 1 : 0);
}
/** True when a scrolled line falls outside the popup's inner area and must not be drawn. */
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
index 09535e3eeb13..ee73f7283cd4 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
@@ -223,7 +223,7 @@ class ShellPanel {
if (!visible || lastArea == null) {
return false;
}
- if (!TuiHelper.contains(lastArea, me.x(), me.y())) {
+ if (!lastArea.contains(me.x(), me.y())) {
return false;
}
if (me.kind() == MouseEventKind.SCROLL_UP) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigation.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigation.java
index 0e95d498130f..5a1604d49d10 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigation.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigation.java
@@ -125,11 +125,53 @@ final class SourceEditorNavigation {
state.moveCursorDown();
}
state.moveCursorToLineStart();
- String line = state.getLine(state.cursorRow());
- int target = Math.min(col, line.length());
- for (int i = 0; i < target; i++) {
+ moveToColumn(state, col);
+ }
+
+ /**
+ * Moves the cursor along its line to the column, a char index into the line as {@code cursorCol()} is. TamboUI
+ * moves the cursor a grapheme cluster at a time, and an emoji is two chars or more, so the moves are counted by the
+ * column reached and not by the chars. A column inside a cluster ends at the start of the cluster.
+ */
+ static void moveToColumn(TextAreaState state, int col) {
+ int row = state.cursorRow();
+ int target = Math.max(0, Math.min(col, state.getLine(row).length()));
+ while (state.cursorCol() < target && state.cursorRow() == row) {
state.moveCursorRight();
}
+ while (state.cursorCol() > target && state.cursorRow() == row) {
+ state.moveCursorLeft();
+ }
+ }
+
+ /** Deletes the text of the cursor's line, a grapheme cluster at a time, so the line below stays as it is. */
+ static void clearLine(TextAreaState state) {
+ int row = state.cursorRow();
+ state.moveCursorToLineStart();
+ while (!state.getLine(row).isEmpty()) {
+ state.deleteForward();
+ }
+ }
+
+ /** Deletes the text before the cursor back to the column of its line, a grapheme cluster at a time. */
+ static void deleteBackTo(TextAreaState state, int col) {
+ int row = state.cursorRow();
+ while (state.cursorCol() > Math.max(0, col) && state.cursorRow() == row) {
+ state.deleteBackward();
+ }
+ }
+
+ /**
+ * Deletes the text after the cursor up to the column of its line as it is now, a grapheme cluster at a time, and
+ * never past the end of the line.
+ */
+ static void deleteForwardTo(TextAreaState state, int col) {
+ int row = state.cursorRow();
+ int length = state.getLine(row).length();
+ int keep = length - Math.max(0, Math.min(col, length) - state.cursorCol());
+ while (state.getLine(row).length() > keep && state.cursorCol() < state.getLine(row).length()) {
+ state.deleteForward();
+ }
}
private static void replaceLine(TextAreaState state, String newLine, int cursorCol) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
index 923e1425a7e6..1b5f6b89a8db 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
@@ -57,7 +57,6 @@ import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
import dev.tamboui.widgets.list.ScrollMode;
import dev.tamboui.widgets.paragraph.Paragraph;
-import dev.tamboui.widgets.scrollbar.Scrollbar;
import dev.tamboui.widgets.scrollbar.ScrollbarState;
import org.apache.camel.dsl.jbang.core.commands.RouteDslConverter;
import org.apache.camel.dsl.jbang.core.commands.ai.KameletChecks;
@@ -313,7 +312,7 @@ class SourceTab extends AbstractTab {
return true;
}
- if (leftArea != null && TuiHelper.contains(leftArea, me.x(), me.y())) {
+ if (leftArea != null && leftArea.contains(me.x(), me.y())) {
focusOnViewer = false;
if (me.kind() == MouseEventKind.SCROLL_UP) {
listState.selectPrevious();
@@ -335,7 +334,7 @@ class SourceTab extends AbstractTab {
return true;
}
- if (rightArea != null && TuiHelper.contains(rightArea, me.x(), me.y())) {
+ if (rightArea != null && rightArea.contains(me.x(), me.y())) {
if (sourceViewer.isVisible()) {
focusOnViewer = true;
return sourceViewer.handleMouseEvent(me);
@@ -1054,19 +1053,7 @@ class SourceTab extends AbstractTab {
.build())
.build();
frame.renderStatefulWidget(list, area, listState);
-
- int visibleRows = area.height() - 2;
- if (entries.size() > visibleRows) {
- Rect scrollRect = new Rect(
- area.x() + area.width() - 1,
- area.y() + 1,
- 1,
- visibleRows);
- listScrollState.contentLength(entries.size());
- listScrollState.viewportContentLength(visibleRows);
- listScrollState.position(listState.offset());
- frame.renderStatefulWidget(Scrollbar.builder().build(), scrollRect, listScrollState);
- }
+ TuiHelper.renderListScrollbar(frame, area, entries.size(), listState.offset(), listScrollState);
}
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
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 b67c740124d7..64956d5f4689 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
@@ -26,6 +26,7 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
@@ -185,6 +186,8 @@ class SourceViewer {
private boolean markdownMode;
private String rawMarkdownContent;
private int markdownScroll;
+ // the furthest the markdown view scrolls, from the last render (unknown before it)
+ private int markdownMaxScroll = Integer.MAX_VALUE;
private QuickDocProvider quickDocProvider;
private boolean quickDocEnabled;
private Map<Integer, List<DocEntry>> quickDocEntries = Collections.emptyMap();
@@ -632,6 +635,7 @@ class SourceViewer {
markdownMode = false;
rawMarkdownContent = null;
markdownScroll = 0;
+ markdownMaxScroll = Integer.MAX_VALUE;
quickDocProvider = null;
quickDocEnabled = false;
quickDocEntries = Collections.emptyMap();
@@ -800,20 +804,6 @@ class SourceViewer {
return loadedFilePath;
}
- void toggleQuickDoc() {
- if (quickDocProvider != null) {
- quickDocEnabled = !quickDocEnabled;
- if (quickDocEnabled) {
- quickDocEntries = quickDocProvider.provideAll(codeData);
- if (quickDocEntries == null) {
- quickDocEntries = Collections.emptyMap();
- }
- } else {
- quickDocEntries = Collections.emptyMap();
- }
- }
- }
-
private void refreshQuickDoc() {
if (quickDocEnabled && quickDocProvider != null && !codeData.isEmpty()) {
quickDocEntries = quickDocProvider.provideAll(codeData);
@@ -884,17 +874,17 @@ class SourceViewer {
}
if (markdownMode) {
if (ke.isUp() || ke.isChar('k')) {
- markdownScroll = Math.max(0, markdownScroll - 1);
+ scrollMarkdown(-1);
} else if (ke.isDown() || ke.isChar('j')) {
- markdownScroll++;
+ scrollMarkdown(1);
} else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
- markdownScroll = Math.max(0, markdownScroll - 10);
+ scrollMarkdown(-10);
} else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
- markdownScroll += 10;
+ scrollMarkdown(10);
} else if (ke.isHome() || ke.isKey(KeyCode.HOME)) {
markdownScroll = 0;
} else if (ke.isEnd() || ke.isKey(KeyCode.END)) {
- markdownScroll = Integer.MAX_VALUE;
+ markdownScroll = markdownMaxScroll;
}
return true;
}
@@ -1002,6 +992,43 @@ class SourceViewer {
editState.scrollDown(Math.min(prevScroll, maxScroll), lastVisibleLines);
}
+ /** Scrolls the markdown view by lines, within the document as far as the last render measured it. */
+ private void scrollMarkdown(int delta) {
+ long next = (long) Math.min(markdownScroll, markdownMaxScroll) + delta;
+ markdownScroll = (int) Math.max(0, Math.min(markdownMaxScroll, next));
+ }
+
+ /** Ctrl+/: comments out the block at the cursor, or brings it back when it is commented out. */
+ private void toggleComment(boolean yamlListBlocks) {
+ String marker = lineCommentMarker();
+ if (marker == null) {
+ return;
+ }
+ List<String> lines = editLines();
+ int row = editState.cursorRow();
+ YamlBlockEditor.BlockRange block = YamlBlockEditor.findCommentBlock(lines, row, yamlListBlocks, marker);
+ if (block.isEmpty()) {
+ return;
+ }
+ List<String> toggled = YamlBlockEditor.toggleComment(lines, block, marker);
+ applyBlockEdit(new YamlBlockEditor.EditResult(toggled, row, YamlBlockEditor.leadingSpaces(toggled.get(row))));
+ }
+
+ /** The line comment marker of the file in the editor, or null when it has none the editor can toggle (XML). */
+ private String lineCommentMarker() {
+ if (editableFile == null) {
+ return null;
+ }
+ String name = editableFile.getFileName().toString().toLowerCase(Locale.ROOT);
+ if (name.endsWith(".yaml") || name.endsWith(".yml") || name.endsWith(".properties")) {
+ return "#";
+ }
+ if (name.endsWith(".java") || name.endsWith(".groovy") || name.endsWith(".kt") || name.endsWith(".js")) {
+ return "//";
+ }
+ return null;
+ }
+
private void refreshEditFindMatches() {
search.buildFindMatches(editLines());
}
@@ -1136,6 +1163,11 @@ class SourceViewer {
applyBlockEdit(YamlBlockEditor.deleteLine(editLines(), editState.cursorRow()));
return true;
}
+ if (ke.hasCtrl() && ke.isChar('/')) {
+ // terminals send Ctrl+/ as 0x1F, which TamboUI reads as Ctrl+/ since 0.5.0
+ toggleComment(yamlListBlocks);
+ return true;
+ }
if (ke.hasCtrl() && ke.isCharIgnoreCase('r') && (isCamelYamlFile() || uriCompletion != null)) {
openRefactorPopup();
return true;
@@ -1261,9 +1293,10 @@ class SourceViewer {
openAutocomplete();
return true;
}
- if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
+ String typed = FormHelper.typedText(ke);
+ if (typed != null) {
recordEditChange();
- editState.insert(ke.character());
+ editState.insert(typed);
return true;
}
return true;
@@ -1333,12 +1366,8 @@ class SourceViewer {
@Override
public void deleteCurrentLine() {
recordEditChange();
- editState.moveCursorToLineStart();
int current = editState.cursorRow();
- String line = editState.getLine(current);
- for (int i = 0; i < line.length(); i++) {
- editState.deleteForward();
- }
+ SourceEditorNavigation.clearLine(editState);
if (current < editState.lineCount() - 1) {
editState.deleteForward();
} else if (current > 0) {
@@ -1554,16 +1583,8 @@ class SourceViewer {
return;
}
SimpleCompletionContext c = sc.context();
- int col = editState.cursorCol();
- for (; col < sc.endCol(); col++) {
- editState.moveCursorRight();
- }
- for (; col > sc.endCol(); col--) {
- editState.moveCursorLeft();
- }
- for (int i = 0; i < c.prefix().length(); i++) {
- editState.deleteBackward();
- }
+ SourceEditorNavigation.moveToColumn(editState, sc.endCol());
+ SourceEditorNavigation.deleteBackTo(editState, editState.cursorCol() - c.prefix().length());
// Tab in the middle of a word replaces all of it
String line = editState.getLine(sc.row());
int from = editState.cursorCol();
@@ -1571,9 +1592,7 @@ class SourceViewer {
while (end < line.length() && isSimpleWordChar(line.charAt(end), c.kind())) {
end++;
}
- for (int i = from; i < end; i++) {
- editState.deleteForward();
- }
+ SourceEditorNavigation.deleteForwardTo(editState, end);
String text = item.insertText();
String closing = c.closing();
if (c.kind() == SimpleCompletionContext.Kind.FUNCTION && text.endsWith("}")) {
@@ -1647,16 +1666,8 @@ class SourceViewer {
return;
}
XmlCompletionContext c = xc.context();
- int col = editState.cursorCol();
- for (; col < xc.endCol(); col++) {
- editState.moveCursorRight();
- }
- for (; col > xc.endCol(); col--) {
- editState.moveCursorLeft();
- }
- for (int i = 0; i < c.prefix().length(); i++) {
- editState.deleteBackward();
- }
+ SourceEditorNavigation.moveToColumn(editState, xc.endCol());
+ SourceEditorNavigation.deleteBackTo(editState, editState.cursorCol() - c.prefix().length());
// Tab in the middle of a name replaces all of it
String line = editState.getLine(xc.row());
int from = editState.cursorCol();
@@ -1667,17 +1678,15 @@ class SourceViewer {
: Character.isLetterOrDigit(line.charAt(end)) || line.charAt(end) == '-')) {
end++;
}
- for (int i = from; i < end; i++) {
- editState.deleteForward();
- }
+ SourceEditorNavigation.deleteForwardTo(editState, end);
String text = item.insertText();
int caret = text.indexOf(XmlCompletions.CARET);
if (caret >= 0) {
text = text.substring(0, caret) + text.substring(caret + 1);
}
editState.insert(text);
- for (int i = caret >= 0 ? text.length() - caret : 0; i > 0; i--) {
- editState.moveCursorLeft();
+ if (caret >= 0) {
+ SourceEditorNavigation.moveToColumn(editState, editState.cursorCol() - (text.length() - caret));
}
if (c.kind() == XmlCompletionContext.Kind.ATTRIBUTE) {
openAutocomplete();
@@ -1718,25 +1727,15 @@ class SourceViewer {
if (editState.cursorRow() != jc.row()) {
return;
}
- int col = editState.cursorCol();
- for (; col < jc.endCol(); col++) {
- editState.moveCursorRight();
- }
- for (; col > jc.endCol(); col--) {
- editState.moveCursorLeft();
- }
- for (int i = 0; i < jc.context().prefix().length(); i++) {
- editState.deleteBackward();
- }
+ SourceEditorNavigation.moveToColumn(editState, jc.endCol());
+ SourceEditorNavigation.deleteBackTo(editState, editState.cursorCol() - jc.context().prefix().length());
String line = editState.getLine(jc.row());
int from = editState.cursorCol();
int end = from;
while (end < line.length() && Character.isJavaIdentifierPart(line.charAt(end))) {
end++;
}
- for (int i = from; i < end; i++) {
- editState.deleteForward();
- }
+ SourceEditorNavigation.deleteForwardTo(editState, end);
String text = item.insertText();
int caret = text.indexOf(XmlCompletions.CARET);
if (caret >= 0) {
@@ -1748,8 +1747,8 @@ class SourceViewer {
caret = -1;
}
editState.insert(text);
- for (int i = caret >= 0 ? text.length() - caret : 0; i > 0; i--) {
- editState.moveCursorLeft();
+ if (caret >= 0) {
+ SourceEditorNavigation.moveToColumn(editState, editState.cursorCol() - (text.length() - caret));
}
}
@@ -1761,16 +1760,8 @@ class SourceViewer {
return;
}
// the arrows move the cursor within the prefix while the popup is open: back to its end
- int col = editState.cursorCol();
- for (; col < jc.endCol(); col++) {
- editState.moveCursorRight();
- }
- for (; col > jc.endCol(); col--) {
- editState.moveCursorLeft();
- }
- for (int i = 0; i < jc.prefix().length(); i++) {
- editState.deleteBackward();
- }
+ SourceEditorNavigation.moveToColumn(editState, jc.endCol());
+ SourceEditorNavigation.deleteBackTo(editState, editState.cursorCol() - jc.prefix().length());
// Tab in the middle of a word replaces all of it: the rest of the name after the cursor goes too
String line = editState.getLine(jc.row());
int from = editState.cursorCol();
@@ -1778,9 +1769,7 @@ class SourceViewer {
while (end < line.length() && isUriWordChar(line.charAt(end), jc.suffix())) {
end++;
}
- for (int i = from; i < end; i++) {
- editState.deleteForward();
- }
+ SourceEditorNavigation.deleteForwardTo(editState, end);
// the : or = the name is followed by is not doubled when it is there already
boolean hasSuffix = !jc.suffix().isEmpty() && line.startsWith(jc.suffix(), end);
editState.insert(item.key() + (hasSuffix ? "" : jc.suffix()));
@@ -1997,17 +1986,11 @@ class SourceViewer {
int eq = currentLine.indexOf('=');
if (eq >= 0) {
String keyPart = currentLine.substring(0, eq + 1);
- editState.moveCursorToLineStart();
- for (int i = 0; i < currentLine.length(); i++) {
- editState.deleteForward();
- }
+ SourceEditorNavigation.clearLine(editState);
editState.insert(keyPart + item.key());
}
} else {
- editState.moveCursorToLineStart();
- for (int i = 0; i < currentLine.length(); i++) {
- editState.deleteForward();
- }
+ SourceEditorNavigation.clearLine(editState);
boolean isGroup = item.key().endsWith(".");
String insertText = isGroup ? item.key() : item.key() + "=";
editState.insert(insertText);
@@ -2046,10 +2029,7 @@ class SourceViewer {
}
String indentStr = " ".repeat(indent);
- editState.moveCursorToLineStart();
- for (int i = 0; i < currentLine.length(); i++) {
- editState.deleteForward();
- }
+ SourceEditorNavigation.clearLine(editState);
if (valueMode) {
String trimmed = currentLine.trim();
@@ -2346,15 +2326,10 @@ class SourceViewer {
return;
}
recordEditChange();
- editState.moveCursorToLineStart();
- for (int i = 0; i < line.length(); i++) {
- editState.deleteForward();
- }
+ SourceEditorNavigation.clearLine(editState);
editState.insert(fixed);
editState.moveCursorToLineStart();
- for (int i = 0; i < Math.min(col, fixed.length()); i++) {
- editState.moveCursorRight();
- }
+ SourceEditorNavigation.moveToColumn(editState, col);
dirty = true;
lineStatuses = null;
// the problem is gone: check again now, not at the next interval
@@ -2597,11 +2572,11 @@ class SourceViewer {
}
if (markdownMode) {
if (me.kind() == MouseEventKind.SCROLL_UP) {
- markdownScroll = Math.max(0, markdownScroll - 3);
+ scrollMarkdown(-3);
return true;
}
if (me.kind() == MouseEventKind.SCROLL_DOWN) {
- markdownScroll += 3;
+ scrollMarkdown(3);
return true;
}
return true;
@@ -2645,11 +2620,7 @@ class SourceViewer {
if (current != null && current.isBlank() && !current.isEmpty()) {
// strip the blank line's leading whitespace (e.g. from ENTER auto-indent) so the
// reindented block's own indent is not stacked on top of it
- editState.moveCursorToLineStart();
- int n = current.length();
- for (int i = 0; i < n; i++) {
- editState.deleteForward();
- }
+ SourceEditorNavigation.clearLine(editState);
}
editState.insert(adjusted);
}
@@ -2689,14 +2660,15 @@ class SourceViewer {
bb.borderStyle(borderStyle);
}
Block block = bb.build();
- MarkdownView view = MarkdownView.builder()
+ MarkdownView.Builder viewBuilder = MarkdownView.builder()
.source(rawMarkdownContent)
- .scroll(markdownScroll)
- .block(block)
.styles(Theme.markdownStyles())
- .syntaxTheme(Theme.syntaxTheme())
- .build();
- frame.renderWidget(view, area);
+ .syntaxTheme(Theme.syntaxTheme());
+ // the scroll stays within the document, so End, then Up or PgUp, move from the end at once
+ Rect inner = block.inner(area);
+ markdownMaxScroll = Math.max(0, viewBuilder.build().computeHeight(inner.width()) - inner.height());
+ markdownScroll = Math.max(0, Math.min(markdownScroll, markdownMaxScroll));
+ frame.renderWidget(viewBuilder.scroll(markdownScroll).block(block).build(), area);
return;
}
@@ -3317,6 +3289,9 @@ class SourceViewer {
TuiHelper.hint(spans, "Alt+↑/↓", "move block");
TuiHelper.hint(spans, "Ctrl+D", "duplicate");
TuiHelper.hint(spans, "Ctrl+K", "delete line");
+ if (lineCommentMarker() != null) {
+ TuiHelper.hint(spans, "Ctrl+/", "comment");
+ }
if (autocompleteProvider != null) {
TuiHelper.hint(spans, "Tab", "complete");
}
@@ -3695,6 +3670,7 @@ class SourceViewer {
rawMarkdownContent = String.join("\n", rawLines);
markdownMode = true;
markdownScroll = 0;
+ markdownMaxScroll = Integer.MAX_VALUE;
} else {
rawMarkdownContent = null;
markdownMode = false;
@@ -4245,92 +4221,6 @@ class SourceViewer {
return lastCommentLine >= 0 ? lastCommentLine + 1 : 0;
}
- private static Line applyHorizontalSkip(Line line, int hSkip) {
- List<Span> scrolled = new ArrayList<>();
- int skipped = 0;
- for (Span span : line.spans()) {
- String content = span.content();
- if (skipped >= hSkip) {
- scrolled.add(span);
- } else if (skipped + content.length() > hSkip) {
- int offset = hSkip - skipped;
- scrolled.add(Span.styled(content.substring(offset), span.style()));
- skipped = hSkip;
- } else {
- skipped += content.length();
- }
- }
- return scrolled.isEmpty() ? Line.from(List.of(Span.raw(""))) : Line.from(scrolled);
- }
-
- private int computeGutterWidth() {
- for (String line : lines) {
- int w = 0;
- for (int i = 0; i < line.length(); i++) {
- char c = line.charAt(i);
- if (c != ' ' && !Character.isDigit(c)) {
- break;
- }
- w++;
- }
- if (w > 0) {
- return w;
- }
- }
- return 0;
- }
-
- private List<Line> renderQuickDocLines(DocEntry entry, int sourceIndent, int gutterWidth, int viewportWidth) {
- String text = entry.text();
- String prefix = " ".repeat(gutterWidth + 5 + sourceIndent);
- String marker = "ℹ ";
- Style docStyle = Style.EMPTY.dim().italic();
- int prefixWidth = prefix.length() + marker.length();
-
- if (!wordWrap || viewportWidth <= 0 || prefixWidth + text.length() <= viewportWidth) {
- return List.of(Line.from(List.of(
- Span.raw(prefix),
- Span.styled(marker, docStyle),
- Span.styled(text, docStyle))));
- }
-
- int textWidth = viewportWidth - prefixWidth;
- if (textWidth <= 10) {
- return List.of(Line.from(List.of(
- Span.raw(prefix),
- Span.styled(marker, docStyle),
- Span.styled(text, docStyle))));
- }
-
- String contPrefix = " ".repeat(prefixWidth);
- List<Line> result = new ArrayList<>();
- int pos = 0;
- boolean first = true;
- while (pos < text.length()) {
- int end = Math.min(pos + textWidth, text.length());
- if (end < text.length()) {
- int space = text.lastIndexOf(' ', end);
- if (space > pos) {
- end = space + 1;
- }
- }
- String chunk = text.substring(pos, end).stripTrailing();
- if (first) {
- result.add(Line.from(List.of(
- Span.raw(prefix),
- Span.styled(marker, docStyle),
- Span.styled(chunk, docStyle))));
- first = false;
- } else {
- result.add(Line.from(List.of(
- Span.raw(contPrefix),
- Span.styled(chunk, docStyle))));
- }
- pos = end;
- }
- return result;
- }
-
private int countVisualRows(int fromLine, int toLine, int contentWidth) {
int count = 0;
for (int i = fromLine; i < toLine && i < lines.size(); i++) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpansTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpansTab.java
index 717637ba7be4..2e7bd298dec8 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpansTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpansTab.java
@@ -649,7 +649,8 @@ class SpansTab extends AbstractTab {
hintLast(spans, "PgUp/Dn", "page");
} else if (filterInputActive) {
spans.add(Span.styled(" /", Theme.label().bold()));
- spans.add(Span.raw(filterInputState.text() + "█ "));
+ FormHelper.addCaretField(spans, filterInputState);
+ spans.add(Span.raw(" "));
hint(spans, "Enter", "filter");
hintLast(spans, "Esc", "cancel");
} else {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
index ebf2055265dc..635244da526a 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
@@ -242,8 +242,9 @@ class SqlQueryTab extends AbstractTab {
}
// typed character
- if (ke.code() == KeyCode.CHAR) {
- sqlInput.insert(ke.character());
+ String typed = FormHelper.typedText(ke);
+ if (typed != null) {
+ sqlInput.insert(typed);
return true;
}
@@ -744,7 +745,7 @@ class SqlQueryTab extends AbstractTab {
}
Block block = Block.builder()
- .borderType(BorderType.ROUNDED)
+ .borderType(BorderType.ROUNDED).borders(Borders.ALL)
.title(title)
.build();
Rect inner = block.inner(popup);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapeRecorder.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapeRecorder.java
index 503965519fd9..08a72553ef33 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapeRecorder.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapeRecorder.java
@@ -66,7 +66,7 @@ class TapeRecorder {
String tapeCmd = toTapeCommand(key);
if (tapeCmd != null) {
lines.add(tapeCmd);
- } else if (key.length() == 1) {
+ } else if (key.codePointCount(0, key.length()) == 1) {
lines.add("Type \"" + escapeTapeString(key) + "\"");
}
keyCount++;
@@ -105,7 +105,7 @@ class TapeRecorder {
if (tapeCmd != null) {
flushCharBatch(charBatch);
lines.add(tapeCmd);
- } else if (key.length() == 1) {
+ } else if (key.codePointCount(0, key.length()) == 1) {
charBatch.append(key);
}
keyCount++;
@@ -172,7 +172,46 @@ class TapeRecorder {
}
}
+ /**
+ * The tape command of a key label (see {@link RecordingManager#keyLabel}) or a key given to tui_send_keys: Ctrl+,
+ * Alt+ and Shift+ in that order, then the key's tape name (Up, PageDown, F8, Backspace) or the character. Null for
+ * a single character typed without Ctrl or Alt, which is written as Type, and for a label it does not know.
+ */
static String toTapeCommand(String key) {
+ if ("⇧Tab".equals(key)) {
+ return "Shift+Tab";
+ }
+ boolean ctrl = false;
+ boolean alt = false;
+ boolean shift = false;
+ String rest = key;
+ int plus;
+ // a lone "+" is a key of its own, not a modifier separator
+ while ((plus = rest.indexOf('+')) > 0 && plus < rest.length() - 1) {
+ String modifier = rest.substring(0, plus).toLowerCase(Locale.ROOT);
+ if ("ctrl".equals(modifier) || "control".equals(modifier)) {
+ ctrl = true;
+ } else if ("alt".equals(modifier) || "meta".equals(modifier)) {
+ alt = true;
+ } else if ("shift".equals(modifier)) {
+ shift = true;
+ } else {
+ return null;
+ }
+ rest = rest.substring(plus + 1);
+ }
+ String name = tapeKeyName(rest);
+ if (name == null) {
+ if (rest.codePointCount(0, rest.length()) != 1 || !(ctrl || alt || shift)) {
+ return null;
+ }
+ name = rest;
+ }
+ return (ctrl ? "Ctrl+" : "") + (alt ? "Alt+" : "") + (shift ? "Shift+" : "") + name;
+ }
+
+ /** The tape name of a key such as Enter, an arrow or F8, or null when it is a character or no key. */
+ private static String tapeKeyName(String key) {
if (key.length() == 1) {
return switch (key) {
case TuiIcons.ARROW_UP -> "Up";
@@ -184,14 +223,6 @@ class TapeRecorder {
};
}
String lower = key.toLowerCase(Locale.ROOT);
-
- if (lower.startsWith("ctrl+")) {
- return "Ctrl+" + key.substring(5);
- }
- if (lower.startsWith("shift+")) {
- return "Shift+" + key.substring(6);
- }
-
if (SPECIAL_KEYS.contains(lower)) {
return switch (lower) {
case "esc" -> "Escape";
@@ -200,11 +231,9 @@ class TapeRecorder {
default -> capitalize(lower);
};
}
-
if (lower.matches("f\\d{1,2}")) {
return key.toUpperCase(Locale.ROOT);
}
-
return null;
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemePopup.java
index b6ad559aae01..cc4490b4eab2 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemePopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemePopup.java
@@ -35,6 +35,7 @@ import dev.tamboui.widgets.list.ListItem;
import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
import dev.tamboui.widgets.list.ScrollMode;
+import dev.tamboui.widgets.scrollbar.ScrollbarState;
import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hint;
import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hintLast;
@@ -49,6 +50,7 @@ class ThemePopup {
private final ListState listState = new ListState();
private Runnable themeRefreshAction;
private Rect popupRect;
+ private final ScrollbarState scrollbarState = new ScrollbarState();
void setThemeRefreshAction(Runnable action) {
this.themeRefreshAction = action;
@@ -168,13 +170,15 @@ class ThemePopup {
.items(items.toArray(ListItem[]::new))
.highlightStyle(Theme.selectionBg())
.highlightSymbol("")
- .scrollMode(ScrollMode.NONE)
+ // scrolls to the selection when a short terminal cannot show every theme
+ .scrollMode(ScrollMode.AUTO_SCROLL)
.block(Block.builder()
.borderType(BorderType.ROUNDED).borders(Borders.ALL)
.title(" Themes ")
.build())
.build();
frame.renderStatefulWidget(list, popup, listState);
+ TuiHelper.renderListScrollbar(frame, popup, items.size(), listState.offset(), scrollbarState);
}
void renderFooter(List<Span> spans) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopup.java
index 593092ef638f..f51d0f7ec2bc 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopup.java
@@ -36,7 +36,6 @@ import dev.tamboui.widgets.list.ListItem;
import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
import dev.tamboui.widgets.list.ScrollMode;
-import dev.tamboui.widgets.scrollbar.Scrollbar;
import dev.tamboui.widgets.scrollbar.ScrollbarState;
class TocPopup {
@@ -51,6 +50,8 @@ class TocPopup {
private List<TocEntry> allEntries;
private List<TocEntry> filteredEntries;
private Rect popupRect;
+ // the rows the list was scrolled by in the last render, for a click
+ private int listOffset;
private TocEntry pendingEntry;
boolean isVisible() {
@@ -124,7 +125,7 @@ class TocPopup {
return true;
}
if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
- filter.appendChar(ke.string().charAt(0));
+ filter.append(ke.string());
rebuildList();
return true;
}
@@ -143,7 +144,7 @@ class TocPopup {
}
if (me.isClick()) {
if (popupRect != null && popupRect.contains(me.x(), me.y())) {
- int idx = TuiHelper.listItemAt(popupRect, 0,
+ int idx = TuiHelper.listItemAt(popupRect, listOffset,
(filteredEntries != null ? filteredEntries.size() : 0) + 2,
me.x(), me.y());
if (idx >= 2 && filteredEntries != null && idx - 2 < filteredEntries.size()) {
@@ -233,15 +234,8 @@ class TocPopup {
.build())
.build();
frame.renderStatefulWidget(list, popup, renderState);
-
- int visibleRows = Math.max(1, popup.height() - 2);
- if (shown + 2 > visibleRows) {
- scrollbarState
- .contentLength(shown)
- .viewportContentLength(visibleRows)
- .position(sel != null ? sel : 0);
- frame.renderStatefulWidget(Scrollbar.builder().build(), popup, scrollbarState);
- }
+ listOffset = renderState.offset();
+ TuiHelper.renderListScrollbar(frame, popup, shown + 2, listOffset, scrollbarState);
}
private void rebuildList() {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
index 5b3940a11447..0b89c26f32f2 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
@@ -30,10 +30,14 @@ import java.util.function.Function;
import dev.tamboui.layout.Rect;
import dev.tamboui.style.AnsiColor;
import dev.tamboui.style.Color;
+import dev.tamboui.style.Modifier;
import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
import dev.tamboui.text.CharWidth;
import dev.tamboui.text.Line;
import dev.tamboui.text.Span;
+import dev.tamboui.widgets.scrollbar.Scrollbar;
+import dev.tamboui.widgets.scrollbar.ScrollbarState;
import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.apache.camel.dsl.jbang.core.common.ProcessHelper;
import org.apache.camel.support.PatternHelper;
@@ -253,8 +257,8 @@ final class TuiHelper {
}
/**
- * Strip ANSI escape sequences and carriage returns from a log line. Handles CSI sequences (\e[...finalChar where
- * finalChar is any char in @-~), 2-char Fe/Fp escape sequences, and \r.
+ * Strip ANSI escape sequences and carriage returns from a log line, and expand tabs. All of an escape sequence is
+ * dropped (see {@link #escapeEnd}), so the text of a window title or a hyperlink target does not show.
*/
static String stripAnsi(String line) {
if (line == null || line.isEmpty()) {
@@ -267,21 +271,8 @@ final class TuiHelper {
int i = 0;
while (i < line.length()) {
char ch = line.charAt(i);
- if (ch == '\u001B' && i + 1 < line.length()) {
- char next = line.charAt(i + 1);
- if (next == '[') {
- // CSI sequence: \e[ + params/intermediates + final byte (any char @-~)
- i += 2;
- while (i < line.length() && (line.charAt(i) < '@' || line.charAt(i) > '~')) {
- i++;
- }
- i++; // skip the final byte
- } else if (next >= '@' && next <= '_') {
- // 2-char Fe/Fp escape sequence (e.g. \eM, \e7, \e8)
- i += 2;
- } else {
- i++; // unrecognised, skip just the ESC
- }
+ if (ch == '\u001B') {
+ i = escapeEnd(line, i);
} else if (ch == '\r') {
i++;
} else if (ch == '\t') {
@@ -295,10 +286,69 @@ final class TuiHelper {
return sb.toString();
}
+ /**
+ * The index just past the escape sequence that starts with the ESC at {@code start}:
+ * <ul>
+ * <li>a CSI sequence, ESC [ up to its final byte (colors are ESC [ ... m);</li>
+ * <li>an OSC string such as a window title or a hyperlink, ESC ] up to BEL or ESC \;</li>
+ * <li>a DCS, SOS, PM or APC string, ESC P, X, ^ or _ up to ESC \;</li>
+ * <li>an escape with intermediate bytes, such as ESC ( B, up to its final byte;</li>
+ * <li>any other escape, ESC and one character.</li>
+ * </ul>
+ * A string that is not terminated runs to the end of the text, a sequence cut off by the next ESC ends there, and
+ * an ESC that starts no sequence is dropped on its own.
+ */
+ static int escapeEnd(String s, int start) {
+ int n = s.length();
+ if (start + 1 >= n) {
+ return n;
+ }
+ char next = s.charAt(start + 1);
+ int i = start + 2;
+ switch (next) {
+ case '[' -> {
+ while (i < n) {
+ char c = s.charAt(i);
+ if (c >= '@' && c <= '~') {
+ return i + 1;
+ }
+ if (c == '\u001B') {
+ return i;
+ }
+ i++;
+ }
+ return n;
+ }
+ case ']', 'P', 'X', '^', '_' -> {
+ while (i < n) {
+ char c = s.charAt(i);
+ if (c == '\u0007' && next == ']') {
+ return i + 1;
+ }
+ if (c == '\u001B' && i + 1 < n && s.charAt(i + 1) == '\\') {
+ return i + 2;
+ }
+ i++;
+ }
+ return n;
+ }
+ default -> {
+ if (next >= ' ' && next <= '/') {
+ while (i < n && s.charAt(i) >= ' ' && s.charAt(i) <= '/') {
+ i++;
+ }
+ return Math.min(n, i + 1);
+ }
+ return next >= '0' && next <= '~' ? start + 2 : start + 1;
+ }
+ }
+ }
+
/**
* Parse a raw log line (which may contain ANSI SGR color codes) into a TamboUI {@link Line} of styled
* {@link Span}s, skipping the first {@code hSkip} display-width columns (for horizontal scrolling). Color state is
- * tracked through the skipped region so colors that start before the visible area still apply correctly.
+ * tracked through the skipped region so colors that start before the visible area still apply correctly. Other
+ * escape sequences are dropped (see {@link #escapeEnd}).
*/
static Line ansiToLine(String raw, int hSkip) {
if (raw == null || raw.isEmpty()) {
@@ -311,28 +361,16 @@ final class TuiHelper {
int i = 0;
while (i < raw.length()) {
char ch = raw.charAt(i);
- if (ch == '\u001B' && i + 1 < raw.length()) {
- char next = raw.charAt(i + 1);
- if (next == '[') {
- i += 2;
- int seqStart = i;
- while (i < raw.length() && (raw.charAt(i) < '@' || raw.charAt(i) > '~')) {
- i++;
- }
- char finalByte = i < raw.length() ? raw.charAt(i) : 0;
- i++;
- if (finalByte == 'm') {
- if (text.length() > 0) {
- spans.add(Span.styled(text.toString(), style));
- text.setLength(0);
- }
- style = applySgr(style, raw.substring(seqStart, i - 1));
+ if (ch == '\u001B') {
+ int end = escapeEnd(raw, i);
+ if (isSgr(raw, i, end)) {
+ if (text.length() > 0) {
+ spans.add(Span.styled(text.toString(), style));
+ text.setLength(0);
}
- } else if (next >= '@' && next <= '_') {
- i += 2;
- } else {
- i++;
+ style = applySgr(style, raw.substring(i + 2, end - 1));
}
+ i = end;
} else if (ch == '\r') {
i++;
} else {
@@ -359,6 +397,49 @@ final class TuiHelper {
return Line.from(spans);
}
+ /**
+ * The line without its first {@code columns} display columns, for horizontal scrolling, keeping the style of each
+ * span. A wide character cut by the edge is left out.
+ */
+ static Line skipColumns(Line line, int columns) {
+ if (columns <= 0) {
+ return line;
+ }
+ List<Span> result = new ArrayList<>();
+ int skip = columns;
+ for (Span span : line.spans()) {
+ if (skip <= 0) {
+ result.add(span);
+ continue;
+ }
+ String text = span.content();
+ int spanWidth = CharWidth.of(text);
+ if (spanWidth <= skip) {
+ skip -= spanWidth;
+ } else {
+ int i = 0;
+ int consumed = 0;
+ while (i < text.length() && consumed < skip) {
+ int cp = text.codePointAt(i);
+ consumed += CharWidth.of(cp);
+ i += Character.charCount(cp);
+ }
+ skip = 0;
+ String remaining = text.substring(i);
+ if (!remaining.isEmpty()) {
+ result.add(Span.styled(remaining, span.style()));
+ }
+ }
+ }
+ return Line.from(result);
+ }
+
+ /** Whether the escape sequence from start to end is an SGR one, ESC [ params m, and not a private CSI ... m. */
+ private static boolean isSgr(String s, int start, int end) {
+ return end - start >= 3 && s.charAt(start + 1) == '[' && s.charAt(end - 1) == 'm'
+ && (end - start == 3 || "<=>?".indexOf(s.charAt(start + 2)) < 0);
+ }
+
private static Style applySgr(Style style, String params) {
if (params.isEmpty()) {
return Style.EMPTY;
@@ -366,6 +447,12 @@ final class TuiHelper {
String[] parts = params.split(";", -1);
int j = 0;
while (j < parts.length) {
+ if (parts[j].indexOf(':') >= 0) {
+ // the ITU T.416 form, with the color in sub-parameters: 38:2::r:g:b, 38:5:n, 4:3 (curly underline)
+ style = applySgrSubParameters(style, parts[j].split(":", -1));
+ j++;
+ continue;
+ }
int code;
try {
code = parts[j].isEmpty() ? 0 : Integer.parseInt(parts[j]);
@@ -379,9 +466,18 @@ final class TuiHelper {
case 2 -> style = style.dim();
case 3 -> style = style.italic();
case 4 -> style = style.underlined();
+ case 5 -> style = style.slowBlink();
+ case 6 -> style = style.rapidBlink();
+ case 7 -> style = style.reversed();
+ case 8 -> style = style.hidden();
+ case 9 -> style = style.crossedOut();
case 22 -> style = style.notBold().notDim();
case 23 -> style = style.notItalic();
case 24 -> style = style.notUnderlined();
+ case 25 -> style = style.removeModifier(Modifier.SLOW_BLINK).removeModifier(Modifier.RAPID_BLINK);
+ case 27 -> style = style.notReversed();
+ case 28 -> style = style.notHidden();
+ case 29 -> style = style.notCrossedOut();
case 30 -> style = style.fg(Color.ansi(AnsiColor.BLACK));
case 31 -> style = style.fg(Color.ansi(AnsiColor.RED));
case 32 -> style = style.fg(Color.ansi(AnsiColor.GREEN));
@@ -390,22 +486,17 @@ final class TuiHelper {
case 35 -> style = style.fg(Color.ansi(AnsiColor.MAGENTA));
case 36 -> style = style.fg(Color.ansi(AnsiColor.CYAN));
case 37 -> style = style.fg(Color.ansi(AnsiColor.WHITE));
- case 38 -> {
+ case 38, 48 -> {
+ Color color = null;
if (j + 2 < parts.length && "5".equals(parts[j + 1])) {
- try {
- style = style.fg(Color.indexed(Integer.parseInt(parts[j + 2])));
- j += 2;
- } catch (NumberFormatException e) {
- /* skip */ }
+ color = indexedColor(parts[j + 2]);
+ j += 2;
} else if (j + 4 < parts.length && "2".equals(parts[j + 1])) {
- try {
- style = style.fg(Color.rgb(
- Integer.parseInt(parts[j + 2]),
- Integer.parseInt(parts[j + 3]),
- Integer.parseInt(parts[j + 4])));
- j += 4;
- } catch (NumberFormatException e) {
- /* skip */ }
+ color = rgbColor(parts[j + 2], parts[j + 3], parts[j + 4]);
+ j += 4;
+ }
+ if (color != null) {
+ style = code == 38 ? style.fg(color) : style.bg(color);
}
}
case 39 -> style = style.fg(Color.RESET);
@@ -417,24 +508,6 @@ final class TuiHelper {
case 45 -> style = style.bg(Color.ansi(AnsiColor.MAGENTA));
case 46 -> style = style.bg(Color.ansi(AnsiColor.CYAN));
case 47 -> style = style.bg(Color.ansi(AnsiColor.WHITE));
- case 48 -> {
- if (j + 2 < parts.length && "5".equals(parts[j + 1])) {
- try {
- style = style.bg(Color.indexed(Integer.parseInt(parts[j + 2])));
- j += 2;
- } catch (NumberFormatException e) {
- /* skip */ }
- } else if (j + 4 < parts.length && "2".equals(parts[j + 1])) {
- try {
- style = style.bg(Color.rgb(
- Integer.parseInt(parts[j + 2]),
- Integer.parseInt(parts[j + 3]),
- Integer.parseInt(parts[j + 4])));
- j += 4;
- } catch (NumberFormatException e) {
- /* skip */ }
- }
- }
case 49 -> style = style.bg(Color.RESET);
case 90 -> style = style.fg(Color.ansi(AnsiColor.BRIGHT_BLACK));
case 91 -> style = style.fg(Color.ansi(AnsiColor.BRIGHT_RED));
@@ -460,6 +533,50 @@ final class TuiHelper {
return style;
}
+ private static Style applySgrSubParameters(Style style, String[] sub) {
+ switch (sub[0]) {
+ case "38", "48" -> {
+ Color color = null;
+ if (sub.length >= 3 && "5".equals(sub[1])) {
+ color = indexedColor(sub[2]);
+ } else if (sub.length >= 6 && "2".equals(sub[1])) {
+ // 38:2:<color space id>:r:g:b
+ color = rgbColor(sub[3], sub[4], sub[5]);
+ } else if (sub.length == 5 && "2".equals(sub[1])) {
+ // 38:2:r:g:b, as some terminals write it
+ color = rgbColor(sub[2], sub[3], sub[4]);
+ }
+ if (color != null) {
+ style = "38".equals(sub[0]) ? style.fg(color) : style.bg(color);
+ }
+ }
+ case "4" -> style = "0".equals(sub[1]) ? style.notUnderlined() : style.underlined();
+ default -> {
+ if (!sub[0].isEmpty()) {
+ style = applySgr(style, sub[0]);
+ }
+ }
+ }
+ return style;
+ }
+
+ private static Color indexedColor(String index) {
+ try {
+ int n = Integer.parseInt(index);
+ return n >= 0 && n <= 255 ? Color.indexed(n) : null;
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ private static Color rgbColor(String r, String g, String b) {
+ try {
+ return Color.rgb(Integer.parseInt(r), Integer.parseInt(g), Integer.parseInt(b));
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
static final Style[] DURATION_BAND_STYLES = {
Style.EMPTY.fg(Color.GREEN),
Style.EMPTY.fg(Color.LIGHT_GREEN),
@@ -515,12 +632,6 @@ final class TuiHelper {
return Line.from(spans);
}
- static boolean contains(Rect rect, int x, int y) {
- return rect != null
- && x >= rect.x() && x < rect.x() + rect.width()
- && y >= rect.y() && y < rect.y() + rect.height();
- }
-
static int compareStr(String a, String b) {
if (a == null && b == null) {
return 0;
@@ -1007,6 +1118,76 @@ final class TuiHelper {
return false;
}
+ /**
+ * Draws the scrollbar of a bordered list on its right border, between the corners, when its rows do not fit: at the
+ * list's scroll position, its offset after the render, and not at its selection.
+ *
+ * @param area the list with its border
+ * @param rows the rows of all items
+ * @param offset the rows the list is scrolled by
+ */
+ static void renderListScrollbar(Frame frame, Rect area, int rows, int offset, ScrollbarState state) {
+ int visibleRows = area.height() - 2;
+ if (visibleRows <= 0 || rows <= visibleRows) {
+ return;
+ }
+ Rect track = new Rect(area.x() + area.width() - 1, area.y() + 1, 1, visibleRows);
+ state.contentLength(rows).viewportContentLength(visibleRows).position(offset);
+ frame.renderStatefulWidget(Scrollbar.builder().build(), track, state);
+ }
+
+ /**
+ * The item of a TamboUI list at a row of its area, or -1. The offset of a {@code ListState} counts rows, not items,
+ * and an item that starts above the area shows its lower rows at the top, so the items are walked by their heights.
+ *
+ * @param row the row in the list area, 0 for its first row
+ * @param offset the list's offset after the last render
+ * @param heights the rows each item takes
+ */
+ static int listItemAtRow(int row, int offset, int[] heights) {
+ if (row < 0) {
+ return -1;
+ }
+ int target = offset + row;
+ int top = 0;
+ for (int i = 0; i < heights.length; i++) {
+ top += heights[i];
+ if (target < top) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * The row of a TamboUI table at a line of its data area (below the header), or -1. The offset of a
+ * {@code TableState} counts lines, not rows, and the table draws the first row that ends below the offset in full
+ * at the top of the area.
+ *
+ * @param line the line in the data area, 0 for its first line
+ * @param offset the table's offset after the last render
+ * @param heights the lines each row takes
+ */
+ static int tableRowAtLine(int line, int offset, int[] heights) {
+ if (line < 0) {
+ return -1;
+ }
+ int first = 0;
+ int end = 0;
+ while (first < heights.length && end + heights[first] <= offset) {
+ end += heights[first];
+ first++;
+ }
+ int top = 0;
+ for (int i = first; i < heights.length; i++) {
+ top += heights[i];
+ if (line < top) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
static int listItemAt(Rect popup, int offset, int itemCount, int mouseX, int mouseY) {
if (popup == null) {
return -1;
@@ -1025,18 +1206,24 @@ final class TuiHelper {
return idx >= 0 && idx < itemCount ? idx : -1;
}
+ /** Words onto lines of at most the display width; a longer word gets a line of its own. */
static List<String> wrapWords(String text, int maxWidth) {
List<String> lines = new ArrayList<>();
StringBuilder line = new StringBuilder();
+ int lineWidth = 0;
for (String word : text.split(" ")) {
+ int wordWidth = CharWidth.of(word);
if (line.isEmpty()) {
line.append(word);
- } else if (line.length() + 1 + word.length() <= maxWidth) {
+ lineWidth = wordWidth;
+ } else if (lineWidth + 1 + wordWidth <= maxWidth) {
line.append(' ').append(word);
+ lineWidth += 1 + wordWidth;
} else {
lines.add(line.toString());
line.setLength(0);
line.append(word);
+ lineWidth = wordWidth;
}
}
if (!line.isEmpty()) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
index 35143dcfee00..4aa0a4c83677 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
@@ -211,8 +211,8 @@ final class TuiToolDefinitions {
"length", propDef("integer", "Length of arrows"),
"text", propDef("string", "Text content to draw (for text shape)"),
"color", propDef("string",
- "red, green, blue, yellow, cyan, magenta, white, gray or black (default red, "
- + "yellow for highlight)"),
+ "red, green, blue, yellow, cyan, magenta, white, gray or black, light_red and so on, "
+ + "or #rrggbb (default red, yellow for highlight)"),
"duration", propDef("integer", "Auto-dismiss after this many seconds; otherwise stays until cleared"),
"append", propDef("boolean", "Add to the existing drawing instead of replacing it (default false)")),
List.of("shape", "x", "y"))));
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TypeConvertersTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TypeConvertersTab.java
index f16100637995..40aa34be9e42 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TypeConvertersTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TypeConvertersTab.java
@@ -194,7 +194,8 @@ class TypeConvertersTab extends AbstractTableTab {
public void renderFooter(List<Span> spans) {
if (filterInputActive) {
spans.add(Span.styled(" /", Theme.label().bold()));
- spans.add(Span.raw(filterInputState.text() + "█ "));
+ FormHelper.addCaretField(spans, filterInputState);
+ spans.add(Span.raw(" "));
hint(spans, "Enter", "filter");
hintLast(spans, "Esc", "cancel");
return;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditor.java
index 12ef619cb94e..f725475d9cdc 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditor.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditor.java
@@ -132,6 +132,29 @@ final class YamlBlockEditor {
}
static List<String> toggleComment(List<String> lines, BlockRange block) {
+ return toggleComment(lines, block, "#");
+ }
+
+ /**
+ * The lines a comment toggle at the row applies to: the block at the row, found as if no line were commented out.
+ * So a block that was commented out is found again as the block it was, without the commented-out sibling next to a
+ * block being taken in.
+ */
+ static BlockRange findCommentBlock(List<String> lines, int row, boolean yamlListBlocks, String marker) {
+ List<String> uncommented = new ArrayList<>(lines.size());
+ for (String line : lines) {
+ uncommented.add(uncommentLine(line, marker));
+ }
+ return findBlock(uncommented, row, yamlListBlocks);
+ }
+
+ /**
+ * Comments out the lines of the block with the given line comment marker ({@code #} in YAML and properties,
+ * {@code //} in Java), or removes one marker from each line when every non-blank line of the block already starts
+ * with it. A line that is a comment already gets a second marker, so it is still a comment when the block is
+ * brought back.
+ */
+ static List<String> toggleComment(List<String> lines, BlockRange block, String marker) {
List<String> answer = new ArrayList<>(lines);
boolean uncomment = true;
for (int i = block.startRow(); i <= block.endRow() && i < answer.size(); i++) {
@@ -139,13 +162,14 @@ final class YamlBlockEditor {
if (line.isBlank()) {
continue;
}
- if (!isCommented(line)) {
+ if (!isCommented(line, marker)) {
uncomment = false;
break;
}
}
for (int i = block.startRow(); i <= block.endRow() && i < answer.size(); i++) {
- answer.set(i, uncomment ? uncommentLine(answer.get(i)) : commentLine(answer.get(i)));
+ answer.set(i,
+ uncomment ? uncommentLine(answer.get(i), marker) : commentLine(answer.get(i), marker));
}
return answer;
}
@@ -256,30 +280,30 @@ final class YamlBlockEditor {
return count;
}
- private static boolean isCommented(String line) {
+ private static boolean isCommented(String line, String marker) {
String trimmed = line.stripLeading();
- return trimmed.startsWith("#");
+ return trimmed.startsWith(marker);
}
- private static String commentLine(String line) {
- if (line.isBlank() || isCommented(line)) {
+ private static String commentLine(String line, String marker) {
+ if (line.isBlank()) {
return line;
}
int indent = leadingSpaces(line);
- return line.substring(0, indent) + "# " + line.substring(indent);
+ return line.substring(0, indent) + marker + " " + line.substring(indent);
}
- private static String uncommentLine(String line) {
+ private static String uncommentLine(String line, String marker) {
if (line.isBlank()) {
return line;
}
int indent = leadingSpaces(line);
String rest = line.substring(indent);
- if (rest.startsWith("# ")) {
- return line.substring(0, indent) + rest.substring(2);
+ if (rest.startsWith(marker + " ")) {
+ return line.substring(0, indent) + rest.substring(marker.length() + 1);
}
- if (rest.startsWith("#")) {
- return line.substring(0, indent) + rest.substring(1);
+ if (rest.startsWith(marker)) {
+ return line.substring(0, indent) + rest.substring(marker.length());
}
return line;
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramText.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramText.java
new file mode 100644
index 000000000000..6bb413deb1ec
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramText.java
@@ -0,0 +1,112 @@
+/*
+ * 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.diagram;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.text.CharWidth;
+
+/**
+ * Text in the diagrams, measured by display width as the terminal shows it: a CJK character or an emoji takes two
+ * columns, a combining mark none.
+ */
+final class DiagramText {
+
+ // a label is broken after these where it can
+ private static final String BREAK_AFTER = " :/.,&?";
+
+ private DiagramText() {
+ }
+
+ /**
+ * The text on lines of at most the width, broken after a space or one of {@code : / . , & ?} where it can, and cut
+ * with ... on the last line when it needs more than {@code maxLines} lines.
+ */
+ static List<String> wrap(String text, int maxWidth, int maxLines) {
+ if (maxWidth <= 0 || CharWidth.of(text) <= maxWidth) {
+ return new ArrayList<>(List.of(text));
+ }
+ List<String> lines = new ArrayList<>();
+ String remaining = text;
+ // the text from where the last line starts, for cutting it with ...
+ String lastLineText = text;
+ while (!remaining.isEmpty() && lines.size() < maxLines) {
+ if (CharWidth.of(remaining) <= maxWidth) {
+ lines.add(remaining);
+ remaining = "";
+ break;
+ }
+ int fit = CharWidth.substringByWidth(remaining, maxWidth).length();
+ int breakAt = -1;
+ for (int i = 0; i < fit; i++) {
+ if (BREAK_AFTER.indexOf(remaining.charAt(i)) >= 0) {
+ breakAt = i + 1;
+ }
+ }
+ if (breakAt <= 0) {
+ // no place to break within the width: the word is cut, by one character at least
+ breakAt = Math.max(fit, Character.charCount(remaining.codePointAt(0)));
+ }
+ lastLineText = remaining;
+ lines.add(remaining.substring(0, breakAt).stripTrailing());
+ remaining = remaining.substring(breakAt).stripLeading();
+ }
+ if (!remaining.isEmpty()) {
+ // the last line goes on as the text does, with the space or the : it was broken at
+ lines.set(lines.size() - 1, CharWidth.substringByWidth(lastLineText, Math.max(1, maxWidth - 3)) + "...");
+ }
+ return lines;
+ }
+
+ /**
+ * Writes the text from column {@code x} of the screen, cut by display width to the columns of the area on both
+ * sides (the text may start left of the area when the diagram is scrolled).
+ */
+ static void write(Buffer buffer, Rect area, int x, int y, String text, Style style) {
+ if (y < area.top() || y >= area.bottom() || x >= area.right()) {
+ return;
+ }
+ String visible = text;
+ if (x < area.left()) {
+ // the characters left of the area are skipped; a wide one cut by the edge goes too and leaves its column in
+ // the area blank, so the rest of the text keeps its columns
+ int i = 0;
+ while (i < text.length() && x < area.left()) {
+ int cp = text.codePointAt(i);
+ x += CharWidth.of(cp);
+ i += Character.charCount(cp);
+ }
+ if (x >= area.right()) {
+ return;
+ }
+ visible = text.substring(i);
+ }
+ visible = CharWidth.substringByWidth(visible, area.right() - x);
+ if (!visible.isEmpty()) {
+ buffer.setString(x, y, visible, style);
+ }
+ }
+
+ /** The text cut to the width with an ellipsis (…) when it is wider. */
+ static String fit(String text, int width) {
+ return CharWidth.truncateWithEllipsis(text, Math.max(1, width), "…", CharWidth.TruncatePosition.END);
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
index 1c2e9b1ecdd5..a060c28e5a19 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
@@ -26,6 +26,7 @@ import dev.tamboui.buffer.Buffer;
import dev.tamboui.layout.Rect;
import dev.tamboui.style.Color;
import dev.tamboui.style.Style;
+import dev.tamboui.text.CharWidth;
import dev.tamboui.widget.Widget;
import org.apache.camel.diagram.RouteDiagramLayoutEngine;
import org.apache.camel.diagram.RouteDiagramLayoutEngine.Bounds;
@@ -201,8 +202,8 @@ public class RouteDiagramWidget implements Widget {
*/
private String borderText(String name, boolean in) {
String arrow = "──▶";
- int room = boxWidth - 6 - arrow.length() - 3;
- String n = name.length() > room ? name.substring(0, Math.max(1, room - 1)) + "…" : name;
+ int room = boxWidth - 6 - CharWidth.of(arrow) - 3;
+ String n = DiagramText.fit(name, room);
return in ? " " + n + " " + arrow + " " : " " + arrow + " " + n + " ";
}
@@ -226,19 +227,30 @@ public class RouteDiagramWidget implements Widget {
if (y < area.top() || y >= area.bottom()) {
return;
}
- for (int i = 0; i < m.text().length(); i++) {
- int x = area.x() + m.col() - scrollX + i;
- if (x >= area.right()) {
+ // a character at a time, by the columns it takes: an emoji or a CJK character two
+ String text = m.text();
+ int offset = 0;
+ for (int i = 0; i < text.length();) {
+ int cp = text.codePointAt(i);
+ int end = i + Character.charCount(cp);
+ // a combining mark goes with the character before it
+ while (end < text.length() && CharWidth.of(text.codePointAt(end)) == 0) {
+ end += Character.charCount(text.codePointAt(end));
+ }
+ int width = Math.max(1, CharWidth.of(cp));
+ int x = area.x() + m.col() - scrollX + offset;
+ if (x + width > area.right()) {
return;
}
- if (x < area.left()) {
- continue;
- }
- if (inBox(m.row(), m.col() + i)) {
- // lines may be crossed, another box not
- return;
+ if (x >= area.left()) {
+ if (inBox(m.row(), m.col() + offset) || width > 1 && inBox(m.row(), m.col() + offset + 1)) {
+ // lines may be crossed, another box not
+ return;
+ }
+ buffer.setString(x, y, text.substring(i, end), m.style());
}
- buffer.setString(x, y, String.valueOf(m.text().charAt(i)), m.style());
+ offset += width;
+ i = end;
}
}
@@ -304,7 +316,7 @@ public class RouteDiagramWidget implements Widget {
int cols = toCol(layoutRoute.maxX + PADDING) + boxWidth + 4;
if (errorFrame != null) {
for (String line : errorFrame.lines()) {
- cols = Math.max(cols, line.length() + 6);
+ cols = Math.max(cols, CharWidth.of(line) + 6);
}
}
return cols;
@@ -364,10 +376,10 @@ public class RouteDiagramWidget implements Widget {
}
String text = lines.get(i);
- if (text.length() > innerWidth) {
- text = text.substring(0, Math.max(1, innerWidth - 3)) + "...";
+ if (CharWidth.of(text) > innerWidth) {
+ text = CharWidth.substringByWidth(text, Math.max(1, innerWidth - 3)) + "...";
}
- int textCol = col + 2 + Math.max(0, (innerWidth - text.length()) / 2);
+ int textCol = col + 2 + Math.max(0, (innerWidth - CharWidth.of(text)) / 2);
// First line: [type] tag with EIP color, rest: label text
if (i == 0) {
@@ -406,7 +418,7 @@ public class RouteDiagramWidget implements Widget {
} else if (system != null && linkedRouteId == null) {
// no route of the integration on the other side: the message leaves to a system
String out = borderText(system, false);
- writeText(buffer, area, bottom, col + boxWidth - 1 - out.length(), out, edgeStyle);
+ writeText(buffer, area, bottom, col + boxWidth - 1 - CharWidth.of(out), out, edgeStyle);
}
}
@@ -619,8 +631,8 @@ public class RouteDiagramWidget implements Widget {
}
}
String header = " " + tableHeader(table) + " ";
- if (header.length() > width - 4) {
- header = header.substring(0, Math.max(1, width - 5)) + "… ";
+ if (CharWidth.of(header) > width - 4) {
+ header = DiagramText.fit(header, Math.max(1, width - 5)) + " ";
}
writeText(buffer, area, row, col + 2, header, style(Style.EMPTY.fg(eipColor).bold(), selected));
// the table itself: its top border, so a click on a row selects the row
@@ -629,7 +641,7 @@ public class RouteDiagramWidget implements Widget {
int inner = width - 4;
int valueWidth = 4;
for (TreeNode r : rows) {
- valueWidth = Math.max(valueWidth, caseValue(r).length());
+ valueWidth = Math.max(valueWidth, CharWidth.of(caseValue(r)));
}
valueWidth = Math.min(valueWidth, Math.max(4, inner / 3));
for (int i = 0; i < rows.size(); i++) {
@@ -652,14 +664,10 @@ public class RouteDiagramWidget implements Widget {
String link = findLinkedRouteId(rowNode) != null ? "↵ " : "";
String target = withoutOptions(caseTarget(rows.get(i)));
String value = caseValue(rows.get(i));
- if (value.length() > valueWidth) {
- value = value.substring(0, Math.max(1, valueWidth - 1)) + "…";
- }
+ value = DiagramText.fit(value, valueWidth);
String tail = link + count;
- int targetRoom = Math.max(1, inner - valueWidth - 3 - tail.length() - 1);
- if (target.length() > targetRoom) {
- target = target.substring(0, Math.max(1, targetRoom - 1)) + "…";
- }
+ int targetRoom = Math.max(1, inner - valueWidth - 3 - CharWidth.of(tail) - 1);
+ target = DiagramText.fit(target, targetRoom);
Style base = rowSelected ? selectionStyle() : Style.EMPTY;
for (int c = col + 1; c < right; c++) {
@@ -671,12 +679,12 @@ public class RouteDiagramWidget implements Widget {
// idle when its counter says so; a row without one (no statistics) is not dimmed
boolean idle = stat != null && total == 0 && !highlighted;
Style targetStyle = idle ? Theme.muted() : fromLabelStyle();
- writeText(buffer, area, r, col + 2, String.format("%-" + valueWidth + "s", value),
+ writeText(buffer, area, r, col + 2, value + " ".repeat(Math.max(0, valueWidth - CharWidth.of(value))),
style(valueStyle, rowSelected));
writeText(buffer, area, r, col + 2 + valueWidth, " → ", style(Theme.muted(), rowSelected));
writeText(buffer, area, r, col + 5 + valueWidth, target, style(targetStyle, rowSelected));
if (!tail.isEmpty()) {
- writeText(buffer, area, r, right - 1 - tail.length(), tail,
+ writeText(buffer, area, r, right - 1 - CharWidth.of(tail), tail,
style(link.isEmpty() ? metricsOkStyle() : Theme.label().bold(), rowSelected));
}
nodeBoxes.add(new EipNodeBox(rowNode.id, rowNode.type, r, r, col, right, rowNode));
@@ -757,43 +765,7 @@ public class RouteDiagramWidget implements Widget {
}
static List<String> wrapText(String text, int maxWidth) {
- if (maxWidth <= 0 || text.length() <= maxWidth) {
- return new ArrayList<>(List.of(text));
- }
-
- List<String> lines = new ArrayList<>();
- String remaining = text;
-
- while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
- if (remaining.length() <= maxWidth) {
- lines.add(remaining);
- remaining = "";
- break;
- }
-
- int breakAt = -1;
- for (int i = 0; i < maxWidth && i < remaining.length(); i++) {
- char c = remaining.charAt(i);
- if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ',' || c == '&' || c == '?') {
- breakAt = i + 1;
- }
- }
- if (breakAt <= 0) {
- breakAt = maxWidth;
- }
-
- lines.add(remaining.substring(0, breakAt).stripTrailing());
- remaining = remaining.substring(breakAt).stripLeading();
- }
-
- if (!remaining.isEmpty()) {
- int lastIdx = lines.size() - 1;
- String lastLine = lines.get(lastIdx);
- String combined = lastLine + remaining;
- lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth - 3)) + "...");
- }
-
- return lines;
+ return DiagramText.wrap(text, maxWidth, MAX_WRAP_LINES);
}
private void setChar(Buffer buffer, Rect area, int gridRow, int gridCol, char ch, Style style) {
@@ -805,20 +777,7 @@ public class RouteDiagramWidget implements Widget {
}
private void writeText(Buffer buffer, Rect area, int gridRow, int gridCol, String text, Style style) {
- int x = area.x() + gridCol - scrollX;
- int y = area.y() + gridRow - scrollY;
- if (y >= area.top() && y < area.bottom() && x < area.right()) {
- int startIdx = 0;
- if (x < area.left()) {
- startIdx = area.left() - x;
- x = area.left();
- }
- if (startIdx < text.length()) {
- int maxLen = area.right() - x;
- String visible = text.substring(startIdx, Math.min(text.length(), startIdx + maxLen));
- buffer.setString(x, y, visible, style);
- }
- }
+ DiagramText.write(buffer, area, area.x() + gridCol - scrollX, area.y() + gridRow - scrollY, text, style);
}
private Style style(Style base, boolean selected) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
index f4b4265ec5b8..50e1a4034ffc 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
@@ -26,6 +26,7 @@ import dev.tamboui.buffer.Buffer;
import dev.tamboui.layout.Rect;
import dev.tamboui.style.Color;
import dev.tamboui.style.Style;
+import dev.tamboui.text.CharWidth;
import dev.tamboui.widget.Widget;
import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutEdge;
import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode;
@@ -142,7 +143,7 @@ public class TopologyDiagramWidget implements Widget {
}
String second = wrapped.get(1);
int max = Math.max(1, boxWidth - 4 - 3);
- return List.of(wrapped.get(0), (second.length() > max ? second.substring(0, max) : second) + "...");
+ return List.of(wrapped.get(0), CharWidth.substringByWidth(second, max) + "...");
}
/** A box's lines and which of them are the AI label, the route id and the group tag (-1 when none). */
@@ -179,7 +180,7 @@ public class TopologyDiagramWidget implements Widget {
if (tag != null && label.size() > 1) {
int max = Math.max(1, boxWidth - 4 - 3);
String first = label.get(0);
- label = List.of((first.length() > max ? first.substring(0, max) : first) + "...");
+ label = List.of(CharWidth.substringByWidth(first, max) + "...");
}
lines = new ArrayList<>(label);
aiLines = ai != null ? lines.size() : 0;
@@ -376,7 +377,7 @@ public class TopologyDiagramWidget implements Widget {
int right = 0;
for (Map.Entry<TopologyLayoutNode, List<String>> e : errorLabels().entrySet()) {
for (String line : e.getValue()) {
- right = Math.max(right, toCol(e.getKey().x) + line.length() + 2);
+ right = Math.max(right, toCol(e.getKey().x) + CharWidth.of(line) + 2);
}
}
return right;
@@ -459,10 +460,10 @@ public class TopologyDiagramWidget implements Widget {
}
String lineText = lines.get(i);
- if (lineText.length() > innerWidth) {
- lineText = lineText.substring(0, Math.max(1, innerWidth - 3)) + "...";
+ if (CharWidth.of(lineText) > innerWidth) {
+ lineText = CharWidth.substringByWidth(lineText, Math.max(1, innerWidth - 3)) + "...";
}
- int textCol = col + 2 + Math.max(0, (innerWidth - lineText.length()) / 2);
+ int textCol = col + 2 + Math.max(0, (innerWidth - CharWidth.of(lineText)) / 2);
// Choose style based on content type
if (own != null) {
@@ -602,20 +603,7 @@ public class TopologyDiagramWidget implements Widget {
}
private void writeText(Buffer buffer, Rect area, int gridRow, int gridCol, String text, Style style) {
- int x = area.x() + gridCol - scrollX;
- int y = area.y() + gridRow - scrollY;
- if (y >= area.top() && y < area.bottom() && x < area.right()) {
- int startIdx = 0;
- if (x < area.left()) {
- startIdx = area.left() - x;
- x = area.left();
- }
- if (startIdx < text.length()) {
- int maxLen = area.right() - x;
- String visible = text.substring(startIdx, Math.min(text.length(), startIdx + maxLen));
- buffer.setString(x, y, visible, style);
- }
- }
+ DiagramText.write(buffer, area, area.x() + gridCol - scrollX, area.y() + gridRow - scrollY, text, style);
}
private Style style(Style base, boolean selected) {
@@ -639,42 +627,6 @@ public class TopologyDiagramWidget implements Widget {
}
public static List<String> wrapText(String text, int maxWidth) {
- if (maxWidth <= 0 || text.length() <= maxWidth) {
- return new ArrayList<>(List.of(text));
- }
-
- List<String> lines = new ArrayList<>();
- String remaining = text;
-
- while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
- if (remaining.length() <= maxWidth) {
- lines.add(remaining);
- remaining = "";
- break;
- }
-
- int breakAt = -1;
- for (int i = 0; i < maxWidth && i < remaining.length(); i++) {
- char c = remaining.charAt(i);
- if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ',' || c == '&' || c == '?') {
- breakAt = i + 1;
- }
- }
- if (breakAt <= 0) {
- breakAt = maxWidth;
- }
-
- lines.add(remaining.substring(0, breakAt).stripTrailing());
- remaining = remaining.substring(breakAt).stripLeading();
- }
-
- if (!remaining.isEmpty()) {
- int lastIdx = lines.size() - 1;
- String lastLine = lines.get(lastIdx);
- String combined = lastLine + " " + remaining;
- lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth - 3)) + "...");
- }
-
- return lines;
+ return DiagramText.wrap(text, maxWidth, MAX_WRAP_LINES);
}
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
index cbf7c62a4542..31323cc96049 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
@@ -53,6 +53,8 @@ a heat map of the route: where messages go, where they fail.
- **Alt+Up / Alt+Down** — move YAML list block up/down
- **Ctrl+D** — duplicate current block
- **Ctrl+K** — delete current line
+- **Ctrl+/** — comment out the current block, or bring it back (`#` in YAML and
+ properties, `//` in Java)
- **Ctrl+Left / Ctrl+Right** — word navigation
- **Home** — smart home (content indent, then column 0)
- Quick documentation panel is shown at the bottom (shows doc for current line;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopupTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopupTest.java
new file mode 100644
index 000000000000..3fb8b54d0c83
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopupTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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.List;
+
+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 org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AiLogPopupTest {
+
+ @Test
+ void pageUpMovesAtOnceAfterPageDownWentPastTheEnd() {
+ StringBuilder json = new StringBuilder("{");
+ for (int i = 1; i <= 30; i++) {
+ json.append(i > 1 ? "," : "").append("\"key").append(i).append("\":").append(i);
+ }
+ json.append('}');
+ AiLogPopup popup = new AiLogPopup();
+ popup.setActivityLog(() -> List.of(
+ new AiPanel.LogEntry("10:00:00", AiPanel.LogLevel.RESULT, "tui_get_state", json.toString())));
+ popup.open();
+ Rect area = new Rect(0, 0, 80, 30);
+
+ for (int i = 0; i < 20; i++) {
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.PAGE_DOWN));
+ }
+ assertThat(render(popup, area)).contains("\"key30\"");
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.PAGE_UP));
+
+ assertThat(render(popup, area)).doesNotContain("\"key30\"");
+ }
+
+ private static String render(AiLogPopup popup, Rect area) {
+ Buffer buffer = Buffer.empty(area);
+ popup.render(Frame.forTesting(buffer), area);
+ return TuiTestHelper.bufferToString(buffer);
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelGenAiUsageTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelGenAiUsageTest.java
index 5c146ccc7a03..f6703801c68f 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelGenAiUsageTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelGenAiUsageTest.java
@@ -24,6 +24,8 @@ import java.util.concurrent.atomic.AtomicReference;
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 org.apache.camel.component.ai.observability.GenAiAttributes;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -77,6 +79,35 @@ class AiPanelGenAiUsageTest {
assertThat(rendered).contains("[route:orders]");
}
+ @Test
+ void pageDownScrollsTheModelsOfTheStatsView() {
+ AiPanel panel = new AiPanel();
+ panel.setOtelSpans(spans);
+ panel.open();
+ for (int i = 0; i < 12; i++) {
+ panel.recordUsageForTesting(new AiPanel.AiUsageEntry(
+ "model-" + (char) ('a' + i), "openai", 10, 5, 15, 1000, "stop", Instant.now()));
+ }
+ panel.toggleStatsViewForTesting();
+ Rect area = new Rect(0, 0, 120, 14);
+
+ assertThat(render(panel, area)).contains("model-a").doesNotContain("model-l");
+
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.PAGE_DOWN));
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.PAGE_DOWN));
+ assertThat(render(panel, area)).contains("model-l").doesNotContain("model-a ");
+
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.PAGE_UP));
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.PAGE_UP));
+ assertThat(render(panel, area)).contains("model-a");
+ }
+
+ private static String render(AiPanel panel, Rect area) {
+ Buffer buffer = Buffer.empty(area);
+ panel.render(Frame.forTesting(buffer), area);
+ return TuiTestHelper.bufferToString(buffer);
+ }
+
@Test
void togglingStatsViewRequestsSpanRefresh() {
AiPanel panel = new AiPanel();
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
index e4b70a3d481a..9aba88c5c5ee 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
@@ -80,6 +80,71 @@ class AiPanelTest {
assertFalse(panel.idleFor(AiPanel.OVERVIEW_IDLE_MS, System.currentTimeMillis() + 60_000));
}
+ @Test
+ void anEmojiIsTypedMovedOverAndDeletedAsOneCharacter() {
+ AiPanel panel = new AiPanel();
+ type(panel, "ab");
+ panel.handleKeyEvent(KeyEvent.ofChar(0x1F600));
+ type(panel, "c");
+ assertEquals("ab\uD83D\uDE00c", panel.inputBufferForTesting());
+
+ // Left over the c and the whole emoji: x goes before the emoji
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT, KeyModifiers.NONE));
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT, KeyModifiers.NONE));
+ type(panel, "x");
+ assertEquals("abx\uD83D\uDE00c", panel.inputBufferForTesting());
+
+ // Delete takes both chars of the emoji
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.DELETE, KeyModifiers.NONE));
+ assertEquals("abxc", panel.inputBufferForTesting());
+
+ // Ctrl with a letter types nothing
+ panel.handleKeyEvent(KeyEvent.ofChar('b', KeyModifiers.of(true, false, false)));
+ assertEquals("abxc", panel.inputBufferForTesting());
+
+ // Backspace after an emoji takes both chars
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+ panel.handleKeyEvent(KeyEvent.ofChar(0x1F600));
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.BACKSPACE, KeyModifiers.NONE));
+ assertEquals("abxc", panel.inputBufferForTesting());
+ }
+
+ @Test
+ void anAnswerArrivingWhileScrolledUpLeavesTheViewWhereItIs() throws Exception {
+ AiPanel panel = new AiPanel();
+ SlowSecondAnswerLlmClient client = new SlowSecondAnswerLlmClient();
+ panel.setClientForTesting(client);
+ panel.open();
+ Rect area = new Rect(0, 0, 100, 24);
+
+ type(panel, "first");
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertFalse(panel.isThinkingForTesting()));
+ renderPanel(panel, area);
+
+ type(panel, "second");
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ assertTrue(client.secondAsked.await(5, TimeUnit.SECONDS));
+ // the user scrolls up to read the first answer while the second one is on its way
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.PAGE_UP, KeyModifiers.NONE));
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.PAGE_UP, KeyModifiers.NONE));
+ String before = renderPanel(panel, area);
+ assertTrue(before.contains("answer 1 line 2"), before);
+
+ client.releaseSecond.countDown();
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertFalse(panel.isThinkingForTesting()));
+ String after = renderPanel(panel, area);
+
+ assertTrue(after.contains("answer 1 line 2"), after);
+ assertFalse(after.contains("answer 2 line 30"), after);
+ }
+
+ private static String renderPanel(AiPanel panel, Rect area) {
+ Buffer buffer = Buffer.empty(area);
+ panel.render(Frame.forTesting(buffer), area);
+ return TuiTestHelper.bufferToString(buffer);
+ }
+
@Test
void normalTextStillGoesToLlm() throws Exception {
AiPanel panel = new AiPanel();
@@ -1654,6 +1719,42 @@ class AiPanelTest {
return count;
}
+ /** Answers each question with numbered lines; the second answer waits until the test releases it. */
+ private static final class SlowSecondAnswerLlmClient extends LlmClient {
+
+ private final CountDownLatch secondAsked = new CountDownLatch(1);
+ private final CountDownLatch releaseSecond = new CountDownLatch(1);
+ private int calls;
+
+ SlowSecondAnswerLlmClient() {
+ withModel("test-model");
+ withApiType(ApiType.openai);
+ }
+
+ @Override
+ public boolean detectEndpoint() {
+ return true;
+ }
+
+ @Override
+ public ChatResponse chatWithTools(String systemPrompt, List<Message> messages, List<ToolDef> tools) {
+ int call = ++calls;
+ if (call == 2) {
+ secondAsked.countDown();
+ try {
+ releaseSecond.await(10, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ StringBuilder text = new StringBuilder();
+ for (int i = 1; i <= 30; i++) {
+ text.append("answer ").append(call).append(" line ").append(i).append("\n\n");
+ }
+ return new ChatResponse(text.toString(), null, "end_turn", false, TokenUsage.EMPTY);
+ }
+ }
+
private static final class RecordingLlmClient extends LlmClient {
private final String response;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlayTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlayTest.java
new file mode 100644
index 000000000000..7183dc1e5b14
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlayTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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.List;
+
+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 org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CaptionOverlayTest {
+
+ @Test
+ void anInlineCaptionInWideCharactersIsNotCut() {
+ CaptionOverlay caption = new CaptionOverlay();
+ caption.openInline();
+ for (String ch : List.of("日", "本", "語")) {
+ caption.handleKeyEvent(KeyEvent.ofChar(ch.codePointAt(0)));
+ }
+ Rect area = new Rect(0, 0, 60, 10);
+ Buffer buffer = Buffer.empty(area);
+
+ caption.render(Frame.forTesting(buffer), area);
+
+ // the caret after the text is still in the caption box
+ assertThat(TuiTestHelper.bufferToString(buffer)).contains("█");
+ }
+
+ @Test
+ void backspaceInAnInlineCaptionDeletesAWholeEmoji() {
+ CaptionOverlay caption = new CaptionOverlay();
+ caption.openInline();
+ caption.handleKeyEvent(KeyEvent.ofChar('a'));
+ caption.handleKeyEvent(KeyEvent.ofChar(0x1F600));
+ caption.handleKeyEvent(KeyEvent.ofKey(KeyCode.BACKSPACE));
+ caption.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER));
+
+ assertThat(caption.captionTextForTesting()).isEqualTo("a");
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathTabRenderTest.java
index 946933d4d1c3..fb80ffd681a0 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathTabRenderTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathTabRenderTest.java
@@ -20,7 +20,10 @@ import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
+import dev.tamboui.style.Modifier;
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;
@@ -71,6 +74,24 @@ class ClasspathTabRenderTest {
"Should show loading or empty state");
}
+ @Test
+ void theFilterCaretFollowsTheCursor() {
+ ClasspathTab tab = new ClasspathTab(ctx);
+ tab.handleKeyEvent(KeyEvent.ofChar('/'));
+ for (char ch : "jar".toCharArray()) {
+ tab.handleKeyEvent(KeyEvent.ofChar(ch));
+ }
+ tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT));
+
+ List<Span> spans = new ArrayList<>();
+ tab.renderFooter(spans);
+
+ String footer = spans.stream().map(Span::content).reduce("", String::concat);
+ assertTrue(footer.startsWith(" /jar "), footer);
+ assertTrue(spans.stream().anyMatch(span -> "r".equals(span.content())
+ && span.style().effectiveModifiers().contains(Modifier.REVERSED)), footer);
+ }
+
@Test
void renderFooterHints() {
ClasspathTab tab = new ClasspathTab(ctx);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelperTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelperTest.java
index 19c1750ca8e6..2dd3eaca158d 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelperTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelperTest.java
@@ -19,6 +19,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import dev.tamboui.buffer.Buffer;
import dev.tamboui.layout.Rect;
import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.CharWidth;
import dev.tamboui.text.Line;
import dev.tamboui.text.Span;
import dev.tamboui.widgets.input.TextInputState;
@@ -132,4 +133,16 @@ class DialogHelperTest {
assertThatThrownBy(() -> TuiHelper.hintLine("Enter"))
.isInstanceOf(IllegalArgumentException.class);
}
+
+ @Test
+ void confirmIsWideEnoughForAWideMessage() {
+ // 30 CJK characters take 60 columns
+ String message = "日本語のメッセージ".repeat(3) + "確認しますか";
+ Buffer buffer = Buffer.empty(AREA);
+
+ Rect popup = DialogHelper.renderConfirm(Frame.forTesting(buffer), AREA, "Confirm", message, false);
+
+ assertThat(popup.width()).isGreaterThanOrEqualTo(CharWidth.of(message) + 6);
+ assertThat(TuiTestHelper.bufferToString(buffer).replace(" ", "")).contains(message);
+ }
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopupWrapTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopupWrapTest.java
index 3477ec42db75..11e3d9c2a569 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopupWrapTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopupWrapTest.java
@@ -54,4 +54,10 @@ class DoctorPopupWrapTest {
assertThat(DoctorPopup.wrapWords(" ", 41)).isEmpty();
assertThat(DoctorPopup.wrapWords(null, 41)).isEmpty();
}
+
+ @Test
+ void wrapsByDisplayWidth() {
+ // each CJK character takes two columns
+ assertThat(DoctorPopup.wrapWords("日本語 日本語 abc", 8)).containsExactly("日本語", "日本語", "abc");
+ }
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlayTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlayTest.java
new file mode 100644
index 000000000000..fd4a45cf5a8d
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlayTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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 dev.tamboui.style.Color;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DrawOverlayTest {
+
+ @Test
+ void parseColorTakesTheNamesTheToolsDocument() {
+ assertThat(DrawOverlay.parseColor("red")).isEqualTo(Color.RED);
+ assertThat(DrawOverlay.parseColor(" Yellow ")).isEqualTo(Color.YELLOW);
+ assertThat(DrawOverlay.parseColor("grey")).isEqualTo(Color.GRAY);
+ assertThat(DrawOverlay.parseColor("black")).isEqualTo(Color.BLACK);
+ }
+
+ @Test
+ void parseColorTakesLightAndDarkWithOrWithoutSeparator() {
+ assertThat(DrawOverlay.parseColor("light_red")).isEqualTo(Color.LIGHT_RED);
+ assertThat(DrawOverlay.parseColor("lightblue")).isEqualTo(Color.LIGHT_BLUE);
+ assertThat(DrawOverlay.parseColor("light-cyan")).isEqualTo(Color.LIGHT_CYAN);
+ assertThat(DrawOverlay.parseColor("dark_gray")).isEqualTo(Color.DARK_GRAY);
+ assertThat(DrawOverlay.parseColor("darkgrey")).isEqualTo(Color.DARK_GRAY);
+ assertThat(DrawOverlay.parseColor("bright-green")).isEqualTo(Color.LIGHT_GREEN);
+ }
+
+ @Test
+ void parseColorTakesHexRgbAndIndexed() {
+ assertThat(DrawOverlay.parseColor("#ff8000")).isEqualTo(Color.rgb(255, 128, 0));
+ assertThat(DrawOverlay.parseColor("#F80")).isEqualTo(Color.rgb(255, 136, 0));
+ assertThat(DrawOverlay.parseColor("rgb(1, 2, 3)")).isEqualTo(Color.rgb(1, 2, 3));
+ assertThat(DrawOverlay.parseColor("indexed(208)")).isEqualTo(Color.indexed(208));
+ }
+
+ @Test
+ void parseColorAnswersNullForWhatIsNotAColor() {
+ assertThat(DrawOverlay.parseColor(null)).isNull();
+ assertThat(DrawOverlay.parseColor(" ")).isNull();
+ assertThat(DrawOverlay.parseColor("chartreuse-ish")).isNull();
+ assertThat(DrawOverlay.parseColor("#12")).isNull();
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelperTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelperTest.java
new file mode 100644
index 000000000000..a88ecb857bf9
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelperTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.style.Modifier;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import dev.tamboui.widgets.input.TextAreaState;
+import dev.tamboui.widgets.input.TextInputState;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class FormHelperTest {
+
+ private static final String SMILE = "😀";
+ private static final KeyModifiers CTRL = KeyModifiers.of(true, false, false);
+ private static final KeyModifiers ALT = KeyModifiers.of(false, true, false);
+
+ @Test
+ void typedTextIsTheWholeCharacter() {
+ assertThat(FormHelper.typedText(KeyEvent.ofChar('a'))).isEqualTo("a");
+ assertThat(FormHelper.typedText(KeyEvent.ofChar('A', KeyModifiers.of(false, false, true)))).isEqualTo("A");
+ assertThat(FormHelper.typedText(KeyEvent.ofChar(0x1F600))).isEqualTo(SMILE);
+ assertThat(FormHelper.typedText(KeyEvent.ofChar('é'))).isEqualTo("é");
+ }
+
+ @Test
+ void typedTextIsNothingForCtrlAltAndOtherKeys() {
+ assertThat(FormHelper.typedText(KeyEvent.ofChar('a', CTRL))).isNull();
+ assertThat(FormHelper.typedText(KeyEvent.ofChar('x', ALT))).isNull();
+ assertThat(FormHelper.typedText(KeyEvent.ofChar('\u0007'))).isNull();
+ assertThat(FormHelper.typedText(KeyEvent.ofKey(KeyCode.ENTER))).isNull();
+ }
+
+ @Test
+ void textInputTakesAnEmojiAndSkipsCtrlLetters() {
+ TextInputState state = new TextInputState("ab");
+ state.moveCursorToEnd();
+
+ FormHelper.handleTextInput(KeyEvent.ofChar(0x1F600), state);
+ FormHelper.handleTextInput(KeyEvent.ofChar('u', CTRL), state);
+ FormHelper.handleTextInput(KeyEvent.ofChar('c'), state);
+ assertThat(state.text()).isEqualTo("ab" + SMILE + "c");
+
+ // Left goes over the c and the whole emoji
+ FormHelper.handleTextInput(KeyEvent.ofKey(KeyCode.LEFT), state);
+ FormHelper.handleTextInput(KeyEvent.ofKey(KeyCode.LEFT), state);
+ assertThat(state.cursorPosition()).isEqualTo(2);
+ }
+
+ @Test
+ void textAreaTakesAnEmojiAndSkipsCtrlLetters() {
+ TextAreaState editor = new TextAreaState("");
+
+ FormHelper.handleTextArea(KeyEvent.ofChar(0x1F600), editor);
+ FormHelper.handleTextArea(KeyEvent.ofChar('k', CTRL), editor);
+ FormHelper.handleTextArea(KeyEvent.ofChar('!'), editor);
+
+ assertThat(editor.text()).isEqualTo(SMILE + "!");
+ }
+
+ @Test
+ void caretFieldMarksTheCharacterAtTheCursor() {
+ TextInputState state = new TextInputState("camel");
+ state.moveCursorToEnd();
+ List<Span> atEnd = new ArrayList<>();
+ FormHelper.addCaretField(atEnd, state);
+ assertThat(atEnd).extracting(Span::content).containsExactly("camel█");
+
+ state.moveCursorLeft();
+ state.moveCursorLeft();
+ List<Span> inside = new ArrayList<>();
+ FormHelper.addCaretField(inside, state);
+ assertThat(inside).extracting(Span::content).containsExactly("cam", "e", "l");
+ assertThat(inside.get(1).style().effectiveModifiers()).contains(Modifier.REVERSED);
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilterTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilterTest.java
index 0b46671a3c04..6cf2b6ab64dd 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilterTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilterTest.java
@@ -231,4 +231,15 @@ class FuzzyFilterTest {
assertTrue(FuzzyFilter.camelCaseMatch("brokers", "b"));
assertFalse(FuzzyFilter.camelCaseMatch("brokers", "x"));
}
+
+ @Test
+ void appendAndDeleteAWholeEmoji() {
+ FuzzyFilter filter = new FuzzyFilter();
+ filter.append("A");
+ filter.append("\uD83D\uDE00");
+ assertEquals("a\uD83D\uDE00", filter.filter());
+
+ filter.deleteChar();
+ assertEquals("a", filter.filter());
+ }
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HelpOverlayTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HelpOverlayTest.java
index cc8c5be006d5..df40cdcd8c43 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HelpOverlayTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HelpOverlayTest.java
@@ -16,6 +16,9 @@
*/
package org.apache.camel.dsl.jbang.core.commands.tui;
+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 org.junit.jupiter.api.Test;
@@ -69,4 +72,33 @@ class HelpOverlayTest {
assertTrue(overlay.isVisible(), "an unrelated key must not close the help overlay");
}
+
+ @Test
+ void scrollingStopsAtTheEndOfTheHelp() {
+ StringBuilder md = new StringBuilder("# Help\n\n");
+ for (int i = 1; i <= 40; i++) {
+ md.append("- line ").append(i).append("\n");
+ }
+ HelpOverlay overlay = new HelpOverlay();
+ overlay.open(md.toString());
+ Rect area = new Rect(0, 0, 60, 20);
+ render(overlay, area);
+
+ // far past the end, then one line up: the view moves at once
+ for (int i = 0; i < 20; i++) {
+ overlay.handleKeyEvent(KeyEvent.ofKey(KeyCode.PAGE_DOWN));
+ }
+ String atEnd = render(overlay, area);
+ assertTrue(atEnd.contains("line 40"), atEnd);
+ overlay.handleKeyEvent(KeyEvent.ofKey(KeyCode.UP));
+ String up = render(overlay, area);
+
+ assertFalse(up.contains("line 40"), up);
+ }
+
+ private static String render(HelpOverlay overlay, Rect area) {
+ Buffer buffer = Buffer.empty(area);
+ overlay.render(Frame.forTesting(buffer), area);
+ return TuiTestHelper.bufferToString(buffer);
+ }
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/InputHistoryTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/InputHistoryTest.java
new file mode 100644
index 000000000000..227e39d0ffc3
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/InputHistoryTest.java
@@ -0,0 +1,42 @@
+/*
+ * 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 dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.terminal.Frame;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class InputHistoryTest {
+
+ @Test
+ void thePopupHasAFrame() {
+ InputHistory history = new InputHistory();
+ history.add("select * from orders");
+ history.add("select count(*) from orders");
+ history.showPopup();
+ Rect area = new Rect(0, 0, 60, 12);
+ Buffer buffer = Buffer.empty(area);
+
+ history.renderPopup(Frame.forTesting(buffer), area, "SQL History");
+
+ String rendered = TuiTestHelper.bufferToString(buffer);
+ assertThat(rendered).contains("╭", "╮", "╰", "╯", "SQL History", "select count(*) from orders");
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java
index 4a3c67e96d6a..1ee718050b8a 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java
@@ -20,6 +20,9 @@ import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.terminal.Frame;
import dev.tamboui.text.Span;
import dev.tamboui.tui.event.KeyCode;
import dev.tamboui.tui.event.KeyEvent;
@@ -136,6 +139,50 @@ class LogTabRenderTest {
assertTrue(rendered.contains("The newest line"), rendered);
}
+ @Test
+ void scrollingSidewaysShiftsPlainLines() {
+ LogTab tab = new LogTab(ctx);
+ tab.setEntriesForTesting(List.of(LogTab.parseLogLine("0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdef")));
+ tab.handleKeyEvent(KeyEvent.ofChar('w'));
+ tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.RIGHT));
+
+ Buffer buffer = render(tab, 30, 6);
+
+ assertTrue(rowText(buffer, 1).startsWith("456789abcdef"), rowText(buffer, 1));
+ }
+
+ @Test
+ void scrollingSidewaysKeepsTheColorsOfCompactLines() {
+ LogTab tab = new LogTab(ctx);
+ tab.setEntriesForTesting(List.of(LogTab.parseLogLine("2026-10-01 17:24:30.257 ERROR 85013 --- [main]"
+ + " org.foo.MyLogger : Something failed with a message long enough to scroll")));
+ tab.handleKeyEvent(KeyEvent.ofChar('w'));
+ tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.RIGHT));
+
+ Buffer buffer = render(tab, 40, 6);
+
+ // "17:24:30.257 ERROR MyLogger ..." without its first 4 columns
+ String row = rowText(buffer, 1);
+ assertTrue(row.startsWith("4:30.257 ERROR"), row);
+ assertEquals(Theme.error().fg(), buffer.get(1 + row.indexOf("ERROR"), 1).style().fg());
+ }
+
+ private static Buffer render(LogTab tab, int width, int height) {
+ Rect area = new Rect(0, 0, width, height);
+ Buffer buffer = Buffer.empty(area);
+ tab.render(Frame.forTesting(buffer), area);
+ return buffer;
+ }
+
+ /** The text inside the border of the given row. */
+ private static String rowText(Buffer buffer, int y) {
+ StringBuilder sb = new StringBuilder();
+ for (int x = 1; x < buffer.width() - 1; x++) {
+ sb.append(buffer.get(x, y).symbol());
+ }
+ return sb.toString();
+ }
+
@Test
void shiftF8WithDifferentErrorsOnTheScreenShowsThePickList() {
ctx.askAiCallback = (file, line, problem, text) -> {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeLocateTextTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeLocateTextTest.java
new file mode 100644
index 000000000000..58355698927d
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeLocateTextTest.java
@@ -0,0 +1,50 @@
+/*
+ * 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.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class McpFacadeLocateTextTest {
+
+ @Test
+ void locateTextCountsColumnsAsTheScreenShowsThem() {
+ RecordingManager recording = new RecordingManager(new CaptionOverlay());
+ Buffer buffer = Buffer.empty(new Rect(0, 0, 20, 1));
+ // an accented e written as e and a combining accent takes one column
+ buffer.setString(0, 0, "e\u0301 target", Style.EMPTY);
+ recording.updateBuffer(buffer);
+ McpFacade facade = new McpFacade(
+ null, new AtomicReference<>(List.of()), null, recording, null, null, null, null, null, null, null,
+ null, null);
+
+ JsonArray matches = facade.locateText("target");
+
+ assertThat(matches).hasSize(1);
+ assertThat(((JsonObject) matches.get(0)).getInteger("x")).isEqualTo(2);
+ assertThat(((JsonObject) matches.get(0)).getInteger("width")).isEqualTo(6);
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/NoopBackend.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/NoopBackend.java
index de98fa454b6a..4090a78aef9b 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/NoopBackend.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/NoopBackend.java
@@ -29,6 +29,16 @@ import dev.tamboui.terminal.Backend;
*/
final class NoopBackend implements Backend {
+ private final int[] input;
+ private int inputPos;
+
+ /**
+ * @param input the bytes the terminal sends, read through TamboUI's event parser by tests of key handling
+ */
+ NoopBackend(int... input) {
+ this.input = input;
+ }
+
@Override
public void draw(DiffResult diff) throws IOException {
}
@@ -85,12 +95,12 @@ final class NoopBackend implements Backend {
@Override
public int read(int timeoutMs) throws IOException {
- return -2;
+ return inputPos < input.length ? input[inputPos++] : -2;
}
@Override
public int peek(int timeoutMs) throws IOException {
- return -2;
+ return inputPos < input.length ? input[inputPos] : -2;
}
@Override
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManagerTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManagerTest.java
index 216ce7911d1c..489e8f61f667 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManagerTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManagerTest.java
@@ -20,9 +20,14 @@ import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
+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 dev.tamboui.tui.event.MouseButton;
+import dev.tamboui.tui.event.MouseEvent;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -130,6 +135,54 @@ class PopupManagerTest {
assertTrue(popupManager.isAnyPopupVisible(), "Any popup should be visible");
}
+ @Test
+ void aClickInTheScrolledSwitchPopupPicksTheIntegrationUnderTheMouse() {
+ List<IntegrationInfo> many = new ArrayList<>();
+ for (int i = 0; i < 30; i++) {
+ IntegrationInfo info = new IntegrationInfo();
+ info.pid = String.valueOf(1000 + i);
+ info.name = "app-" + i;
+ many.add(info);
+ }
+ PopupManager manager = new PopupManager(ctx, () -> many, List::of, new FilesBrowser(), new NoCallbacks());
+ // the last integration is selected, so the list is scrolled to the end
+ manager.openSwitchPopup("1029", many);
+ Rect area = new Rect(0, 0, 80, 16);
+ Buffer buffer = Buffer.empty(area);
+ manager.renderSwitchPopup(Frame.forTesting(buffer), area);
+
+ String[] rows = TuiTestHelper.bufferToString(buffer).split("\n");
+ int y = 0;
+ while (!rows[y].contains("app-25 ")) {
+ y++;
+ }
+ manager.handleMouseEvent(MouseEvent.press(MouseButton.LEFT, rows[y].indexOf("app-25"), y), 0, 2);
+
+ assertEquals("1025", ctx.selectedPid);
+ }
+
+ private static final class NoCallbacks implements PopupManager.PopupCallbacks {
+ @Override
+ public void selectMoreTab(int index) {
+ }
+
+ @Override
+ public void resetIntegrationTabState() {
+ }
+
+ @Override
+ public void refreshLogData() {
+ }
+
+ @Override
+ public void stopSelectedProcess(boolean forceKill) {
+ }
+
+ @Override
+ public void fallbackToOverviewIfTabInactive() {
+ }
+ }
+
@Test
void morePopupShortcutMatchesEitherCase() {
// Both Beans and Browse use 'B' — pressing 'b'/'B' cycles between them (matchCount=2)
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManagerTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManagerTest.java
index 89ebcbe1276e..c14680b49259 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManagerTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManagerTest.java
@@ -16,6 +16,11 @@
*/
package org.apache.camel.dsl.jbang.core.commands.tui;
+import java.util.List;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
import dev.tamboui.tui.event.KeyCode;
import dev.tamboui.tui.event.KeyEvent;
import dev.tamboui.tui.event.KeyModifiers;
@@ -41,6 +46,19 @@ class RecordingManagerTest {
manager.init(false);
}
+ @Test
+ void lastBufferIsNotClearedWithTheFrameBuffer() {
+ Buffer frame = Buffer.empty(new Rect(0, 0, 10, 2));
+ frame.setString(0, 0, "Routes", Style.EMPTY);
+
+ manager.updateBuffer(frame);
+ // TamboUI clears the frame buffer before it draws the next frame into it
+ frame.clear();
+
+ assertEquals("R", manager.getLastBuffer().get(0, 0).symbol());
+ assertEquals(1, manager.getRenderGeneration());
+ }
+
@Test
void initialStateIsNotRecording() {
assertFalse(manager.isRecording(), "Should not be recording initially");
@@ -167,6 +185,61 @@ class RecordingManagerTest {
assertEquals("F5", RecordingManager.keyLabel(ke));
}
+ @Test
+ void keyLabelKeepsModifiers() {
+ KeyModifiers ctrl = KeyModifiers.of(true, false, false);
+ KeyModifiers alt = KeyModifiers.of(false, true, false);
+ KeyModifiers shift = KeyModifiers.of(false, false, true);
+ assertEquals("Shift+F2", RecordingManager.keyLabel(KeyEvent.ofKey(KeyCode.F2, shift)));
+ assertEquals("Ctrl+f", RecordingManager.keyLabel(KeyEvent.ofChar('f', ctrl)));
+ assertEquals("Shift+Tab", RecordingManager.keyLabel(KeyEvent.ofKey(KeyCode.TAB, shift)));
+ assertEquals("Alt+" + TuiIcons.ARROW_UP, RecordingManager.keyLabel(KeyEvent.ofKey(KeyCode.UP, alt)));
+ assertEquals("Ctrl+Shift+Z", RecordingManager.keyLabel(KeyEvent.ofChar('Z', KeyModifiers.of(true, false, true))));
+ // a character shows Shift itself
+ assertEquals("A", RecordingManager.keyLabel(KeyEvent.ofChar('A', shift)));
+ assertEquals("Ctrl+Space", RecordingManager.keyLabel(KeyEvent.ofChar(' ', ctrl)));
+ }
+
+ @Test
+ void aRecordedKeyReplaysAsTheSameKey() {
+ List<KeyEvent> keys = List.of(
+ KeyEvent.ofKey(KeyCode.F8, KeyModifiers.of(false, false, true)),
+ KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.of(false, false, true)),
+ KeyEvent.ofKey(KeyCode.UP, KeyModifiers.of(false, true, false)),
+ KeyEvent.ofKey(KeyCode.RIGHT, KeyModifiers.of(true, false, false)),
+ KeyEvent.ofKey(KeyCode.PAGE_DOWN, KeyModifiers.NONE),
+ KeyEvent.ofKey(KeyCode.BACKSPACE, KeyModifiers.NONE),
+ KeyEvent.ofKey(KeyCode.DELETE, KeyModifiers.NONE),
+ KeyEvent.ofChar('f', KeyModifiers.of(true, false, false)),
+ KeyEvent.ofChar('x', KeyModifiers.of(false, true, false)),
+ KeyEvent.ofChar('Z', KeyModifiers.of(true, false, true)),
+ KeyEvent.ofChar('/', KeyModifiers.of(true, false, false)),
+ KeyEvent.ofChar('+', KeyModifiers.of(true, false, false)));
+ for (KeyEvent key : keys) {
+ String command = TapeRecorder.toTapeCommand(RecordingManager.keyLabel(key));
+ KeyEvent replayed = TapePlayer.parseKey(command);
+ assertNotNull(replayed, command);
+ assertEquals(key.code(), replayed.code(), command);
+ assertEquals(key.modifiers(), replayed.modifiers(), command);
+ assertEquals(key.codePoint(), replayed.codePoint(), command);
+ }
+ }
+
+ @Test
+ void aTapeTypesCharactersAndPressesModifiedKeys() {
+ TapeRecorder recorder = new TapeRecorder();
+ recorder.start(null);
+ recorder.recordKey(RecordingManager.keyLabel(KeyEvent.ofChar('a', KeyModifiers.NONE)));
+ recorder.recordKey(RecordingManager.keyLabel(KeyEvent.ofChar(0x1F600, KeyModifiers.NONE)));
+ recorder.recordKey(RecordingManager.keyLabel(KeyEvent.ofChar('s', KeyModifiers.of(true, false, false))));
+ recorder.recordKey(RecordingManager.keyLabel(KeyEvent.ofKey(KeyCode.F2, KeyModifiers.of(false, false, true))));
+ recorder.recordKeys(List.of("Alt+x", "b"), 0);
+
+ String tape = recorder.stop();
+
+ assertTrue(tape.contains("Type \"a\"\nType \"\uD83D\uDE00\"\nCtrl+s\nShift+F2\nAlt+x\nType \"b\""), tape);
+ }
+
@Test
void recordKeyAddsToRecentKeysWhenRecording() {
manager.toggleRecording(); // enable recording
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupRenderTest.java
index 756a0bb5e867..e592511dd819 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupRenderTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupRenderTest.java
@@ -98,6 +98,50 @@ class SettingsPopupRenderTest {
SettingsPopup.lineOf(SettingsPopup.ROW_COUNT - 1) + 1);
}
+ @Test
+ void lineOfIsTheLineEachRowIsDrawnOn(@TempDir Path tempDir) {
+ useHome(tempDir);
+ SettingsPopup popup = new SettingsPopup();
+ popup.setTabEntries(List.of(new TabRegistry.TabEntry("🐪", "Overview", "overview", "1", 0, -1)));
+ popup.open();
+ Rect area = new Rect(0, 0, 80, 40);
+ Buffer buffer = Buffer.empty(area);
+ popup.render(Frame.forTesting(buffer), area);
+ String[] lines = TuiTestHelper.bufferToString(buffer).split("\n");
+
+ // the popup starts two rows down, and its rows below its top border
+ int first = 3;
+ assertTrue(lines[first + SettingsPopup.lineOf(0)].contains("Theme:"));
+ assertTrue(lines[first + SettingsPopup.lineOf(9)].contains("Route Tree:"));
+ assertTrue(lines[first + SettingsPopup.lineOf(10)].contains("Default Folder:"));
+ assertTrue(lines[first + SettingsPopup.lineOf(13)].contains("Shell History:"));
+ assertTrue(lines[first + SettingsPopup.lineOf(14)].contains("AI Provider:"));
+ assertTrue(lines[first + SettingsPopup.lineOf(SettingsPopup.ROW_COUNT - 1)].contains("ACP Command:"));
+ }
+
+ @Test
+ void scrollingUpShowsTheDefaultFolderRow(@TempDir Path tempDir) {
+ useHome(tempDir);
+ SettingsPopup popup = new SettingsPopup();
+ popup.setTabEntries(List.of(new TabRegistry.TabEntry("🐪", "Overview", "overview", "1", 0, -1)));
+ popup.open();
+ Rect area = new Rect(0, 0, 80, 16);
+ for (int i = 0; i < 20; i++) {
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ }
+ popup.render(Frame.forTesting(Buffer.empty(area)), area);
+ // from ACP Command back up to Default Folder, the row the view scrolls up to
+ for (int i = 0; i < 10; i++) {
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.UP, KeyModifiers.NONE));
+ popup.render(Frame.forTesting(Buffer.empty(area)), area);
+ }
+ Buffer buffer = Buffer.empty(area);
+ popup.render(Frame.forTesting(buffer), area);
+
+ assertTrue(TuiTestHelper.bufferToString(buffer).contains("Default Folder:"),
+ TuiTestHelper.bufferToString(buffer));
+ }
+
@Test
void scrollsTheSelectedRowIntoViewOnAShortTerminal(@TempDir Path tempDir) {
useHome(tempDir);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigationTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigationTest.java
index f3e12ec34032..fc9f32e709b2 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigationTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigationTest.java
@@ -92,4 +92,47 @@ class SourceEditorNavigationTest {
SourceEditorNavigation.smartHome(state, true);
assertThat(state.cursorCol()).isZero();
}
+
+ @Test
+ void positionCursorCountsAnEmojiAsTheCharsItTakes() {
+ // the emoji is two chars, and TamboUI moves over it in one step
+ TextAreaState emoji = new TextAreaState("a\uD83D\uDE00bc\nnext");
+
+ SourceEditorNavigation.positionCursor(emoji, 0, 4);
+ assertThat(emoji.cursorRow()).isZero();
+ assertThat(emoji.cursorCol()).isEqualTo(4);
+
+ // the end of the line, not the start of the next one
+ SourceEditorNavigation.positionCursor(emoji, 0, 5);
+ assertThat(emoji.cursorRow()).isZero();
+ assertThat(emoji.cursorCol()).isEqualTo(5);
+
+ // inside the emoji: at its start
+ SourceEditorNavigation.positionCursor(emoji, 0, 2);
+ assertThat(emoji.cursorCol()).isEqualTo(1);
+ }
+
+ @Test
+ void clearLineLeavesTheNextLine() {
+ TextAreaState emoji = new TextAreaState("say \uD83D\uDE00 e\u0301\nnext");
+ SourceEditorNavigation.positionCursor(emoji, 0, 3);
+
+ SourceEditorNavigation.clearLine(emoji);
+
+ assertThat(emoji.text()).isEqualTo("\nnext");
+ assertThat(emoji.cursorRow()).isZero();
+ }
+
+ @Test
+ void deleteBackAndForwardStopAtTheColumn() {
+ TextAreaState emoji = new TextAreaState("ab\uD83D\uDE00cd\uD83D\uDE00ef\nnext");
+ SourceEditorNavigation.positionCursor(emoji, 0, 6);
+
+ SourceEditorNavigation.deleteBackTo(emoji, 2);
+ assertThat(emoji.getLine(0)).isEqualTo("ab\uD83D\uDE00ef");
+ assertThat(emoji.cursorCol()).isEqualTo(2);
+
+ SourceEditorNavigation.deleteForwardTo(emoji, 20);
+ assertThat(emoji.text()).isEqualTo("ab\nnext");
+ }
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
index 0d42c293e02d..eb942905c2b5 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
@@ -311,6 +311,36 @@ class SourceViewerEditTest {
assertThat(viewer.isMarkdownMode()).isTrue();
}
+ @Test
+ void markdownScrollStaysWithinTheDocument() throws Exception {
+ StringBuilder text = new StringBuilder("# Notes\n\n");
+ for (int i = 1; i <= 60; i++) {
+ text.append("Paragraph ").append(i).append("\n\n");
+ }
+ Path md = tempDir.resolve("long.md");
+ Files.writeString(md, text, StandardCharsets.UTF_8);
+ viewer.loadFile(md);
+ Rect area = new Rect(0, 0, 80, 20);
+
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+ assertThat(renderViewer(area)).contains("Paragraph 60");
+
+ // Down at the end stays at the end, it does not wrap to the top
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ String atEnd = renderViewer(area);
+ assertThat(atEnd).contains("Paragraph 60").doesNotContain("# Notes");
+
+ // PgUp after End moves up at once
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.PAGE_UP, KeyModifiers.NONE));
+ assertThat(renderViewer(area)).doesNotContain("Paragraph 60");
+ }
+
+ private String renderViewer(Rect area) {
+ Buffer buffer = Buffer.empty(area);
+ viewer.render(Frame.forTesting(buffer), area);
+ return TuiTestHelper.bufferToString(buffer);
+ }
+
@Test
void cancelEditViaPublicApi() {
viewer.loadFile(sourceFile);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditorOpsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditorOpsTest.java
index 027c81702304..045be98dd8a5 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditorOpsTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditorOpsTest.java
@@ -22,6 +22,9 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.Event;
+import dev.tamboui.tui.event.EventParser;
import dev.tamboui.tui.event.KeyCode;
import dev.tamboui.tui.event.KeyEvent;
import dev.tamboui.tui.event.KeyModifiers;
@@ -83,6 +86,15 @@ class SourceViewerEditorOpsTest {
assertThat(viewer.editText()).contains("X");
}
+ @Test
+ void typingAnEmojiInsertsIt() {
+ moveCursorToLineContaining("log:info");
+
+ viewer.handleKeyEvent(KeyEvent.ofChar(0x1F600, KeyModifiers.NONE));
+
+ assertThat(viewer.editText()).contains("- to: \uD83D\uDE00log:info");
+ }
+
@Test
void ctrlDDuplicatesYamlBlock() {
moveCursorToLineContaining("log:info");
@@ -103,6 +115,66 @@ class SourceViewerEditorOpsTest {
assertThat(viewer.editText()).contains("log:info");
}
+ @Test
+ void ctrlSlashCommentsOutYamlBlockAndBringsItBack() {
+ String original = viewer.editText();
+ moveCursorToLineContaining("log:warn");
+
+ viewer.handleKeyEvent(KeyEvent.ofChar('/', CTRL));
+ assertThat(viewer.editText()).contains(" # - to: log:warn").contains(" - to: log:info");
+
+ viewer.handleKeyEvent(KeyEvent.ofChar('/', CTRL));
+ assertThat(viewer.editText()).isEqualTo(original);
+ }
+
+ @Test
+ void ctrlSlashTogglesOnlyTheBlockNextToACommentedOutSibling() {
+ moveCursorToLineContaining("log:warn");
+ viewer.handleKeyEvent(KeyEvent.ofChar('/', CTRL));
+ moveCursorToLineContaining("log:info");
+
+ viewer.handleKeyEvent(KeyEvent.ofChar('/', CTRL));
+ assertThat(viewer.editText()).contains(" # - to: log:info\n # - to: log:warn");
+
+ // the commented-out sibling below is not part of the block
+ viewer.handleKeyEvent(KeyEvent.ofChar('/', CTRL));
+ assertThat(viewer.editText()).contains(" - to: log:info\n # - to: log:warn");
+ }
+
+ @Test
+ void ctrlSlashFromTheTerminalArrivesAsCommentToggle() throws Exception {
+ moveCursorToLineContaining("log:warn");
+
+ // terminals send Ctrl+/ as the control character 0x1F (US)
+ Event event = EventParser.readEvent(new NoopBackend(0x1F), 10);
+ assertThat(event).isInstanceOf(KeyEvent.class);
+ viewer.handleKeyEvent((KeyEvent) event);
+
+ assertThat(viewer.editText()).contains("# - to: log:warn");
+ }
+
+ @Test
+ void ctrlSlashUsesLineCommentsInJava() throws Exception {
+ Path java = tempDir.resolve("MyRoute.java");
+ Files.writeString(java, """
+ public class MyRoute extends RouteBuilder {
+ public void configure() {
+ from("timer:tick").to("log:info");
+ }
+ }
+ """, StandardCharsets.UTF_8);
+ viewer.loadFile(java);
+ viewer.enterEditMode();
+ moveCursorToLineContaining("from(");
+
+ viewer.handleKeyEvent(KeyEvent.ofChar('/', CTRL));
+
+ assertThat(viewer.editText()).contains(" // from(\"timer:tick\")").doesNotContain("#");
+ List<Span> spans = new ArrayList<>();
+ viewer.renderFooter(spans);
+ assertThat(spansToString(spans)).contains("Ctrl+/");
+ }
+
@Test
void smartHomeInEditModeUsesContentThenLineStart() {
moveCursorToLineContaining("uri:");
@@ -140,7 +212,7 @@ class SourceViewerEditorOpsTest {
@Test
void footerShowsNewEditorHints() {
- List<dev.tamboui.text.Span> spans = new ArrayList<>();
+ List<Span> spans = new ArrayList<>();
viewer.renderFooter(spans);
String footer = spansToString(spans);
@@ -149,6 +221,7 @@ class SourceViewerEditorOpsTest {
assertThat(footer).contains("Alt+↑/↓");
assertThat(footer).contains("Ctrl+D");
assertThat(footer).contains("Ctrl+K");
+ assertThat(footer).contains("Ctrl+/");
}
@Test
@@ -264,9 +337,9 @@ class SourceViewerEditorOpsTest {
return count;
}
- private static String spansToString(List<dev.tamboui.text.Span> spans) {
+ private static String spansToString(List<Span> spans) {
StringBuilder sb = new StringBuilder();
- for (dev.tamboui.text.Span span : spans) {
+ for (Span span : spans) {
sb.append(span.content());
}
return sb.toString();
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemePopupTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemePopupTest.java
new file mode 100644
index 000000000000..7f032be8d121
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemePopupTest.java
@@ -0,0 +1,56 @@
+/*
+ * 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.List;
+
+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 org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Isolated("Previews themes, which is global state")
+class ThemePopupTest {
+
+ @BeforeEach
+ @AfterEach
+ void resetTheme() {
+ Theme.resetForTesting();
+ }
+
+ @Test
+ void theSelectedThemeIsShownOnAShortTerminal() {
+ ThemePopup popup = new ThemePopup();
+ popup.open();
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.END));
+ Rect area = new Rect(0, 0, 60, 14);
+ Buffer buffer = Buffer.empty(area);
+
+ popup.render(Frame.forTesting(buffer), area);
+
+ List<ThemeMode> light = ThemeMode.lightThemes();
+ assertThat(TuiTestHelper.bufferToString(buffer)).contains(light.get(light.size() - 1).label());
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE));
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopupTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopupTest.java
new file mode 100644
index 000000000000..498eeae7dea5
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopupTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.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.MouseButton;
+import dev.tamboui.tui.event.MouseEvent;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class TocPopupTest {
+
+ @Test
+ void aClickInAScrolledListPicksTheHeadingUnderTheMouse() {
+ TocPopup popup = new TocPopup();
+ popup.open(headings(40));
+ // the last heading is selected, so the list is scrolled down
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.END));
+ Rect area = new Rect(0, 0, 80, 20);
+ Buffer buffer = Buffer.empty(area);
+ popup.render(Frame.forTesting(buffer), area);
+
+ int[] cell = find(buffer, "Heading 35");
+ popup.handleMouseEvent(MouseEvent.press(MouseButton.LEFT, cell[0], cell[1]));
+
+ assertThat(popup.consumePendingEntry().title()).isEqualTo("Heading 35");
+ }
+
+ @Test
+ void theScrollbarIsOnTheBorderAtTheScrollPosition() {
+ TocPopup popup = new TocPopup();
+ popup.open(headings(40));
+ Rect area = new Rect(0, 0, 80, 20);
+
+ Buffer top = Buffer.empty(area);
+ popup.render(Frame.forTesting(top), area);
+ int[] corner = find(top, "╮");
+ int bottom = find(top, "╯")[1];
+ // the corners stay, and the thumb starts below the top corner while the list is at its top
+ assertThat(top.get(corner[0], corner[1]).symbol()).isEqualTo("╮");
+ assertThat(top.get(corner[0], bottom).symbol()).isEqualTo("╯");
+ assertThat(top.get(corner[0], corner[1] + 1).symbol()).isEqualTo("█");
+
+ // at the end of the list the thumb is at the bottom of the track
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.END));
+ Buffer end = Buffer.empty(area);
+ popup.render(Frame.forTesting(end), area);
+ assertThat(end.get(corner[0], bottom).symbol()).isEqualTo("╯");
+ assertThat(end.get(corner[0], bottom - 1).symbol()).isEqualTo("█");
+ assertThat(end.get(corner[0], corner[1] + 1).symbol()).isNotEqualTo("█");
+ }
+
+ private static List<TocPopup.TocEntry> headings(int count) {
+ List<TocPopup.TocEntry> entries = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ entries.add(new TocPopup.TocEntry(2, "Heading " + i, i * 100));
+ }
+ return entries;
+ }
+
+ private static int[] find(Buffer buffer, String text) {
+ String[] rows = TuiTestHelper.bufferToString(buffer).split("\n");
+ for (int y = 0; y < rows.length; y++) {
+ int x = rows[y].indexOf(text);
+ if (x >= 0) {
+ return new int[] { x, y };
+ }
+ }
+ throw new AssertionError(text + " is not on the screen");
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelperTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelperTest.java
index f389fd6395dd..e767da7448b9 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelperTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelperTest.java
@@ -73,8 +73,81 @@ class TuiHelperTest {
assertEquals("", TuiHelper.stripAnsi(""));
}
+ @Test
+ void stripAnsiDropsWindowTitle() {
+ // OSC 0 sets the window title, ended by BEL
+ assertEquals("text", TuiHelper.stripAnsi("\u001B]0;my title\u0007text"));
+ }
+
+ @Test
+ void stripAnsiDropsHyperlinkTarget() {
+ // OSC 8 hyperlinks, ended by ST (ESC \)
+ assertEquals("see docs here",
+ TuiHelper.stripAnsi("see \u001B]8;;https://camel.apache.org\u001B\\docs\u001B]8;;\u001B\\ here"));
+ }
+
+ @Test
+ void stripAnsiDropsCharsetAndStringSequences() {
+ // tput sgr0 writes ESC ( B before the reset; DCS strings end with ST
+ assertEquals("plain", TuiHelper.stripAnsi("\u001B(B\u001B[mplain"));
+ assertEquals("after", TuiHelper.stripAnsi("\u001BPq#0;2;0;0;0\u001B\\after"));
+ assertEquals("cut", TuiHelper.stripAnsi("cut\u001B]0;title without end"));
+ }
+
+ @Test
+ void stripAnsiCsiCutOffByNextEscape() {
+ assertEquals("AB", TuiHelper.stripAnsi("A\u001B[31\u001B[0mB"));
+ }
+
// ---- ansiToLine tests ----
+ @Test
+ void ansiToLineDropsOscPayload() {
+ Line line = TuiHelper.ansiToLine("\u001B]2;title\u0007\u001B[31mError\u001B[0m", 0);
+ assertEquals("Error", rawContent(line));
+ assertEquals(Color.ansi(AnsiColor.RED), line.spans().get(0).style().fg().orElse(null));
+ }
+
+ @Test
+ void ansiToLineOscIsNotCountedWhenSkippingColumns() {
+ Line line = TuiHelper.ansiToLine("\u001B]0;title\u0007ABCDEF", 2);
+ assertEquals("CDEF", rawContent(line));
+ }
+
+ @Test
+ void ansiToLineReverseAndStrikethrough() {
+ Line line = TuiHelper.ansiToLine("\u001B[7mrev\u001B[27;9mstrike\u001B[29mplain", 0);
+ List<Span> spans = line.spans();
+ assertEquals(3, spans.size());
+ assertTrue(spans.get(0).style().effectiveModifiers().contains(Modifier.REVERSED));
+ assertFalse(spans.get(1).style().effectiveModifiers().contains(Modifier.REVERSED));
+ assertTrue(spans.get(1).style().effectiveModifiers().contains(Modifier.CROSSED_OUT));
+ assertFalse(spans.get(2).style().effectiveModifiers().contains(Modifier.CROSSED_OUT));
+ }
+
+ @Test
+ void ansiToLineColonColors() {
+ // the ITU T.416 form: 38:2:<color space>:r:g:b, 38:2:r:g:b and 48:5:n
+ Line line = TuiHelper.ansiToLine(
+ "\u001B[38:2::255:128:0mA\u001B[38:2:1:2:3mB\u001B[0;48:5:208mC\u001B[4:3mD\u001B[4:0mE", 0);
+ List<Span> spans = line.spans();
+ assertEquals("ABCDE", rawContent(line));
+ assertEquals(Color.rgb(255, 128, 0), spans.get(0).style().fg().orElse(null));
+ assertEquals(Color.rgb(1, 2, 3), spans.get(1).style().fg().orElse(null));
+ assertFalse(spans.get(2).style().fg().isPresent());
+ assertEquals(Color.indexed(208), spans.get(2).style().bg().orElse(null));
+ assertTrue(spans.get(3).style().effectiveModifiers().contains(Modifier.UNDERLINED));
+ assertFalse(spans.get(4).style().effectiveModifiers().contains(Modifier.UNDERLINED));
+ }
+
+ @Test
+ void ansiToLineIgnoresPrivateCsiEndingInM() {
+ // xterm's modifyOtherKeys setting, ESC [ > 4 ; 2 m, is not a color
+ Line line = TuiHelper.ansiToLine("\u001B[>4;2mtext", 0);
+ assertEquals("text", rawContent(line));
+ assertFalse(line.spans().get(0).style().effectiveModifiers().contains(Modifier.DIM));
+ }
+
@Test
void ansiToLineColorCodesProduceStyledSpans() {
// ESC[31m = ANSI Red foreground
@@ -147,6 +220,61 @@ class TuiHelperTest {
assertEquals("", rawContent(line));
}
+ @Test
+ void skipColumnsKeepsSpanStyles() {
+ Style red = Style.EMPTY.fg(Color.RED);
+ Line line = Line.from(Span.styled("abc", red), Span.raw("def"));
+
+ Line skipped = TuiHelper.skipColumns(line, 2);
+
+ assertEquals("cdef", rawContent(skipped));
+ assertEquals(red, skipped.spans().get(0).style());
+ assertEquals("", rawContent(TuiHelper.skipColumns(line, 10)));
+ assertEquals(line, TuiHelper.skipColumns(line, 0));
+ }
+
+ @Test
+ void skipColumnsCountsDisplayWidth() {
+ // the CJK characters take two columns each
+ assertEquals("日本x", rawContent(TuiHelper.skipColumns(Line.from(Span.raw("ab日本x")), 2)));
+ assertEquals("本x", rawContent(TuiHelper.skipColumns(Line.from(Span.raw("日本x")), 2)));
+ }
+
+ @Test
+ void listItemAtRowWalksItemsByTheirHeights() {
+ int[] heights = { 1, 2, 3, 1 };
+ assertEquals(0, TuiHelper.listItemAtRow(0, 0, heights));
+ assertEquals(1, TuiHelper.listItemAtRow(2, 0, heights));
+ assertEquals(2, TuiHelper.listItemAtRow(3, 0, heights));
+ // scrolled by 4 rows: the list starts with the last two rows of item 2
+ assertEquals(2, TuiHelper.listItemAtRow(0, 4, heights));
+ assertEquals(2, TuiHelper.listItemAtRow(1, 4, heights));
+ assertEquals(3, TuiHelper.listItemAtRow(2, 4, heights));
+ assertEquals(-1, TuiHelper.listItemAtRow(3, 4, heights));
+ assertEquals(-1, TuiHelper.listItemAtRow(-1, 4, heights));
+ }
+
+ @Test
+ void tableRowAtLineStartsWithTheFirstRowEndingBelowTheOffset() {
+ int[] twoLines = { 2, 2, 2, 2, 2 };
+ assertEquals(0, TuiHelper.tableRowAtLine(1, 0, twoLines));
+ assertEquals(1, TuiHelper.tableRowAtLine(2, 0, twoLines));
+ // scrolled by 4 lines (two rows)
+ assertEquals(2, TuiHelper.tableRowAtLine(0, 4, twoLines));
+ assertEquals(3, TuiHelper.tableRowAtLine(3, 4, twoLines));
+ // an offset inside row 1 (lines 2 and 3): the table draws row 1 in full at the top
+ assertEquals(1, TuiHelper.tableRowAtLine(0, 3, twoLines));
+ assertEquals(2, TuiHelper.tableRowAtLine(2, 3, twoLines));
+ assertEquals(-1, TuiHelper.tableRowAtLine(6, 4, twoLines));
+ }
+
+ @Test
+ void wrapWordsByDisplayWidth() {
+ // each CJK character takes two columns: "日本語 日本語" is 13 columns, not 7
+ assertEquals(List.of("日本語", "日本語 a"), TuiHelper.wrapWords("日本語 日本語 a", 8));
+ assertEquals(List.of("one two", "three"), TuiHelper.wrapWords("one two three", 8));
+ }
+
// ---- shortTypeName tests ----
@Test
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditorTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditorTest.java
index 22ac6b777ead..c44f1c0e1421 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditorTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditorTest.java
@@ -156,6 +156,57 @@ class YamlBlockEditorTest {
assertThat(uncommented.get(1)).contains("other: x");
}
+ @Test
+ void toggleCommentKeepsACommentInsideTheBlockAComment() {
+ List<String> lines = YamlBlockEditor.toLines("""
+ - to:
+ # keep the old uri for now
+ uri: log:info
+ - to: log:end
+ """);
+
+ List<String> commented = YamlBlockEditor.toggleComment(lines,
+ YamlBlockEditor.findCommentBlock(lines, 0, true, "#"), "#");
+ assertThat(commented).startsWith("# - to:", " # # keep the old uri for now", " # uri: log:info",
+ "- to: log:end");
+
+ // brought back, the comment is still a comment
+ List<String> back = YamlBlockEditor.toggleComment(commented,
+ YamlBlockEditor.findCommentBlock(commented, 0, true, "#"), "#");
+ assertThat(back).isEqualTo(lines);
+ }
+
+ @Test
+ void toggleCommentWithJavaMarker() {
+ List<String> lines = YamlBlockEditor.toLines(" from(\"timer:tick\")\n // .to(\"log:x\")\n");
+
+ List<String> commented = YamlBlockEditor.toggleComment(lines, new YamlBlockEditor.BlockRange(0, 0), "//");
+ assertThat(commented.get(0)).isEqualTo(" // from(\"timer:tick\")");
+
+ List<String> uncommented = YamlBlockEditor.toggleComment(lines, new YamlBlockEditor.BlockRange(1, 1), "//");
+ assertThat(uncommented.get(1)).isEqualTo(" .to(\"log:x\")");
+ }
+
+ @Test
+ void findCommentBlockFindsCommentedOutBlockAsItWas() {
+ List<String> lines = YamlBlockEditor.toLines("""
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to: log:info
+ # - to:
+ # uri: log:warn
+ - to: log:end
+ """);
+ int row = findLineContaining(lines, "# - to:");
+
+ YamlBlockEditor.BlockRange block = YamlBlockEditor.findCommentBlock(lines, row, true, "#");
+
+ assertThat(block.startRow()).isEqualTo(row);
+ assertThat(block.endRow()).isEqualTo(row + 1);
+ }
+
@Test
void nonYamlListModeUsesSingleLineBlocks() {
List<String> lines = YamlBlockEditor.toLines("alpha\nbeta\ngamma\n");
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramTextTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramTextTest.java
new file mode 100644
index 000000000000..aa1333cb2ce0
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramTextTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.diagram;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.text.CharWidth;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DiagramTextTest {
+
+ @Test
+ void wrapMeasuresByDisplayWidth() {
+ // ten CJK characters are 20 columns
+ assertThat(DiagramText.wrap("注文を処理する経路です", 10, 3))
+ .allSatisfy(line -> assertThat(CharWidth.of(line)).isLessThanOrEqualTo(10))
+ .hasSize(3);
+ assertThat(DiagramText.wrap("timer:tick", 20, 3)).containsExactly("timer:tick");
+ assertThat(DiagramText.wrap("send to kafka:orders", 12, 3)).containsExactly("send to", "kafka:orders");
+ }
+
+ @Test
+ void wrapCutsTheLastLineWithASpaceBetweenTheParts() {
+ assertThat(DiagramText.wrap("Logs incoming request bodies for debugging and monitoring purposes only", 20, 3))
+ .hasSize(3)
+ .last().satisfies(line -> assertThat(line).endsWith("...").doesNotContain("andmon"));
+ }
+
+ @Test
+ void wrapCutsTheLastLineAsTheTextGoesOn() {
+ // broken after the :, the cut last line has no space added where the text has none
+ assertThat(DiagramText.wrap("abc:defghijklmnopqrstuvwxyz0123456789", 20, 1))
+ .containsExactly("abc:defghijklmnop...");
+ }
+
+ @Test
+ void writeClipsToTheAreaByColumns() {
+ Rect area = new Rect(2, 0, 6, 1);
+ Buffer buffer = Buffer.empty(new Rect(0, 0, 10, 1));
+
+ // scrolled two columns to the left: the first CJK character is out of view
+ DiagramText.write(buffer, area, 0, 0, "日本語です", Style.EMPTY);
+
+ assertThat(buffer.get(2, 0).symbol()).isEqualTo("本");
+ assertThat(buffer.get(4, 0).symbol()).isEqualTo("語");
+ assertThat(buffer.get(6, 0).symbol()).isEqualTo("で");
+ // nothing past the right edge of the area
+ assertThat(buffer.get(8, 0).symbol()).isEqualTo(" ");
+ }
+
+ @Test
+ void writeLeavesTheColumnOfAWideCharacterCutByTheEdgeBlank() {
+ Rect area = new Rect(2, 0, 6, 1);
+ Buffer buffer = Buffer.empty(new Rect(0, 0, 10, 1));
+
+ // scrolled one column: the first CJK character takes the column left of the area and the first one in it
+ DiagramText.write(buffer, area, 1, 0, "日本語です", Style.EMPTY);
+
+ assertThat(buffer.get(2, 0).symbol()).isEqualTo(" ");
+ assertThat(buffer.get(3, 0).symbol()).isEqualTo("本");
+ assertThat(buffer.get(5, 0).symbol()).isEqualTo("語");
+ // で would end past the right edge of the area
+ assertThat(buffer.get(7, 0).symbol()).isEqualTo(" ");
+ }
+
+ @Test
+ void fitCutsWithAnEllipsis() {
+ assertThat(DiagramText.fit("orders", 10)).isEqualTo("orders");
+ assertThat(DiagramText.fit("日本語の名前", 7)).isEqualTo("日本語…");
+ }
+}