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);
+    }
+}

Reply via email to