This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch tui-run-options-and-features in repository https://gitbox.apache.org/repos/asf/camel.git
commit f8facb2a8e5cb5c440987a32d352e648ca5171bf Author: Claus Ibsen <[email protected]> AuthorDate: Thu May 21 22:26:42 2026 +0200 camel-tui: Add reusable fuzzy filter with inline search Adds FuzzyFilter utility for fuzzy matching with character-level highlighting. Integrates into the classpath viewer so typing immediately filters the list with matched characters highlighted. Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../jbang/core/commands/tui/ClasspathPopup.java | 105 ++++++++++++++--- .../dsl/jbang/core/commands/tui/FuzzyFilter.java | 125 +++++++++++++++++++++ 2 files changed, 215 insertions(+), 15 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathPopup.java index 625c642ee558..6a63320034ec 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathPopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathPopup.java @@ -27,6 +27,7 @@ import dev.tamboui.style.Style; 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; @@ -46,10 +47,14 @@ import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hintLa class ClasspathPopup { + private static final Style MATCH_STYLE = Style.EMPTY.fg(Color.YELLOW).bold(); + private boolean visible; private final ListState listState = new ListState(); + private final FuzzyFilter fuzzyFilter = new FuzzyFilter(); private List<JarEntry> entries; - private String title; + private List<FilteredEntry> filteredEntries; + private String baseTitle; private String errorMessage; boolean isVisible() { @@ -90,7 +95,9 @@ class ClasspathPopup { entries.add(parseJarEntry(path)); } entries.sort((a, b) -> a.display().compareToIgnoreCase(b.display())); - title = (integrationName != null ? integrationName : pid) + " - Classpath (" + entries.size() + " JARs)"; + baseTitle = (integrationName != null ? integrationName : pid) + " - Classpath"; + fuzzyFilter.clearFilter(); + refilter(); listState.select(0); visible = true; } else { @@ -116,31 +123,45 @@ class ClasspathPopup { return false; } if (ke.isCancel()) { - visible = false; + if (fuzzyFilter.hasFilter()) { + fuzzyFilter.clearFilter(); + refilter(); + } else { + visible = false; + } + } else if (ke.isDeleteBackward()) { + if (fuzzyFilter.hasFilter()) { + fuzzyFilter.deleteChar(); + refilter(); + } } else if (ke.isUp()) { listState.selectPrevious(); } else if (ke.isDown()) { - listState.selectNext(entries != null ? entries.size() : 0); + listState.selectNext(filteredEntries != null ? filteredEntries.size() : 0); } else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) { for (int i = 0; i < 10; i++) { listState.selectPrevious(); } } else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) { - if (entries != null) { + if (filteredEntries != null) { for (int i = 0; i < 10; i++) { - listState.selectNext(entries.size()); + listState.selectNext(filteredEntries.size()); } } + } else if (ke.code() == KeyCode.CHAR) { + fuzzyFilter.appendChar(ke.character()); + refilter(); } return true; } void render(Frame frame, Rect area) { - if (entries == null || entries.isEmpty()) { + if (filteredEntries == null) { return; } + int itemCount = filteredEntries.size(); int popupW = Math.min(100, area.width() - 4); - int popupH = Math.min(entries.size() + 2, Math.min(30, area.height() - 4)); + int popupH = Math.min(itemCount + 2, Math.min(30, area.height() - 4)); int x = area.left() + Math.max(0, (area.width() - popupW) / 2); int y = area.top() + Math.max(0, (area.height() - popupH) / 2); Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height())); @@ -149,9 +170,24 @@ class ClasspathPopup { int contentW = popupW - 4; List<ListItem> items = new ArrayList<>(); - for (JarEntry entry : entries) { - String line = formatEntry(entry, contentW); - items.add(ListItem.from(line).style(entry.isCamel() ? Style.EMPTY : Style.EMPTY.dim())); + for (FilteredEntry fe : filteredEntries) { + String displayText = formatEntry(fe.entry(), contentW); + Style normalStyle = fe.entry().isCamel() ? Style.EMPTY : Style.EMPTY.dim(); + if (fe.matchPositions() != null && fe.matchPositions().length > 0) { + // offset match positions by the formatting prefix (2 spaces) + int[] adjusted = adjustPositions(fe.matchPositions(), fe.entry(), displayText); + Line line = FuzzyFilter.highlightLine(displayText, adjusted, normalStyle, MATCH_STYLE); + items.add(ListItem.from(Text.from(line))); + } else { + items.add(ListItem.from(displayText).style(normalStyle)); + } + } + + String title = " " + baseTitle + " (" + filteredEntries.size(); + if (fuzzyFilter.hasFilter()) { + title += "/" + entries.size() + ") [" + fuzzyFilter.filter() + "] "; + } else { + title += ") "; } ListWidget list = ListWidget.builder() @@ -161,10 +197,12 @@ class ClasspathPopup { .scrollMode(ScrollMode.AUTO_SCROLL) .block(Block.builder() .borderType(BorderType.ROUNDED) - .title(" " + title + " ") + .title(title) .titleBottom(Title.from(Line.from( Span.styled(" ↑↓", MonitorContext.HINT_KEY_STYLE), Span.raw(" navigate │"), - Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), Span.raw(" back ")))) + Span.styled(" type", MonitorContext.HINT_KEY_STYLE), Span.raw(" filter │"), + Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), + Span.raw(fuzzyFilter.hasFilter() ? " clear " : " back ")))) .build()) .build(); frame.renderStatefulWidget(list, popup, listState); @@ -172,7 +210,42 @@ class ClasspathPopup { void renderFooter(List<Span> spans) { hint(spans, "↑↓", "navigate"); - hintLast(spans, "Esc", "back"); + hint(spans, "type", "filter"); + hintLast(spans, "Esc", fuzzyFilter.hasFilter() ? "clear" : "back"); + } + + private void refilter() { + filteredEntries = new ArrayList<>(); + if (entries == null) { + return; + } + for (JarEntry entry : entries) { + if (!fuzzyFilter.hasFilter()) { + filteredEntries.add(new FilteredEntry(entry, null)); + } else { + int[] positions = fuzzyFilter.match(entry.display()); + if (positions != null) { + filteredEntries.add(new FilteredEntry(entry, positions)); + } + } + } + listState.select(filteredEntries.isEmpty() ? null : 0); + } + + private int[] adjustPositions(int[] matchPositions, JarEntry entry, String displayText) { + // match positions are relative to entry.display() (groupId:artifactId:version) + // displayText is formatted with " " prefix and column layout + // find where the GAV text starts in the display text + String searchText = entry.display(); + int offset = displayText.indexOf(searchText.substring(0, Math.min(searchText.length(), 5))); + if (offset < 0) { + offset = 2; + } + int[] adjusted = new int[matchPositions.length]; + for (int i = 0; i < matchPositions.length; i++) { + adjusted[i] = matchPositions[i] + offset; + } + return adjusted; } private String formatEntry(JarEntry entry, int width) { @@ -191,7 +264,6 @@ class ClasspathPopup { int repoIdx = normalized.indexOf("/repository/"); if (repoIdx >= 0) { String relative = normalized.substring(repoIdx + "/repository/".length()); - // relative: org/apache/camel/camel-core/4.x.0/camel-core-4.x.0.jar int lastSlash = relative.lastIndexOf('/'); if (lastSlash > 0) { String parentPath = relative.substring(0, lastSlash); @@ -226,4 +298,7 @@ class ClasspathPopup { return groupId != null && groupId.startsWith("org.apache.camel"); } } + + record FilteredEntry(JarEntry entry, int[] matchPositions) { + } } 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 new file mode 100644 index 000000000000..067de6c2b617 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilter.java @@ -0,0 +1,125 @@ +/* + * 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.Style; +import dev.tamboui.text.Line; +import dev.tamboui.text.Span; + +/** + * Reusable fuzzy filter for TUI list views. Manages filter state and provides utilities for fuzzy matching and + * highlighted rendering of matched characters. + * + * <p> + * Usage: call {@link #appendChar(char)} / {@link #deleteChar()} as the user types. Use {@link #match(String)} to test + * each item and get match positions. Use {@link #highlightLine(String, int[], Style, Style)} to render with matched + * characters highlighted. + */ +class FuzzyFilter { + + private String filter = ""; + + boolean hasFilter() { + return !filter.isEmpty(); + } + + String filter() { + return filter; + } + + void appendChar(char c) { + filter += Character.toLowerCase(c); + } + + void deleteChar() { + if (!filter.isEmpty()) { + filter = filter.substring(0, filter.length() - 1); + } + } + + void clearFilter() { + filter = ""; + } + + /** + * Fuzzy-match the filter against the given text. Characters in the filter must appear in order (but not + * consecutively) in the text. Case-insensitive. + * + * @return positions of matched characters in the text, or null if no match + */ + int[] match(String text) { + return fuzzyMatch(text, filter); + } + + /** + * Static fuzzy match: find each character of {@code pattern} in order within {@code text} (case-insensitive). + * + * @return array of match positions, or null if pattern doesn't match + */ + static int[] fuzzyMatch(String text, String pattern) { + if (pattern == null || pattern.isEmpty()) { + return new int[0]; + } + String lowerText = text.toLowerCase(); + int[] positions = new int[pattern.length()]; + int textIdx = 0; + for (int i = 0; i < pattern.length(); i++) { + char c = pattern.charAt(i); + int found = lowerText.indexOf(c, textIdx); + if (found < 0) { + return null; + } + positions[i] = found; + textIdx = found + 1; + } + return positions; + } + + /** + * Build a {@link Line} with matched character positions highlighted. + * + * @param text the full display text + * @param matchPositions positions of matched characters (from {@link #match(String)}) + * @param normalStyle style for non-matched characters + * @param matchStyle style for matched characters + * @return a Line with interleaved normal and highlighted spans + */ + static Line highlightLine(String text, int[] matchPositions, Style normalStyle, Style matchStyle) { + if (matchPositions == null || matchPositions.length == 0) { + return Line.from(Span.styled(text, normalStyle)); + } + List<Span> spans = new ArrayList<>(); + int pos = 0; + for (int matchIdx : matchPositions) { + if (matchIdx < pos || matchIdx >= text.length()) { + continue; + } + if (matchIdx > pos) { + spans.add(Span.styled(text.substring(pos, matchIdx), normalStyle)); + } + spans.add(Span.styled(text.substring(matchIdx, matchIdx + 1), matchStyle)); + pos = matchIdx + 1; + } + if (pos < text.length()) { + spans.add(Span.styled(text.substring(pos), normalStyle)); + } + return Line.from(spans); + } +}
