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 cc637efc3fda camel-jbang - TUI: Add inline quick doc (i toggle) for
source viewer
cc637efc3fda is described below
commit cc637efc3fda4ecc0a4d1953c8ad06a22d6cdc69
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Jul 25 22:27:19 2026 +0200
camel-jbang - TUI: Add inline quick doc (i toggle) for source viewer
camel-jbang - TUI: Allow i (quick doc) shortcut from diagram drill-down view
camel-jbang - TUI: Improve quick doc with clustered options, Diagram tab
support, and format persistence
- Fix ProcessorDetailDevConsole creating duplicate from1 entry that
overwrote component doc with EIP doc
- Add clustered endpoint/EIP option docs for Java/XML formats where params
are in the URI
- Enable i (quick doc) toggle on Diagram tab source viewer
- Persist quick doc state across format switches (Tab/Shift+Tab)
- Add Shift+Tab for reverse format cycling in source viewer
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../impl/console/ProcessorDetailDevConsole.java | 28 ++-
.../camel/cli/connector/LocalCliConnector.java | 13 +-
.../dsl/jbang/core/commands/tui/DiagramTab.java | 59 +++++
.../dsl/jbang/core/commands/tui/RoutesTab.java | 276 ++++++++++++++++++++-
.../dsl/jbang/core/commands/tui/SourceViewer.java | 188 ++++++++++++--
5 files changed, 541 insertions(+), 23 deletions(-)
diff --git
a/core/camel-console/src/main/java/org/apache/camel/impl/console/ProcessorDetailDevConsole.java
b/core/camel-console/src/main/java/org/apache/camel/impl/console/ProcessorDetailDevConsole.java
index a334114649e7..ebe2b4a37764 100644
---
a/core/camel-console/src/main/java/org/apache/camel/impl/console/ProcessorDetailDevConsole.java
+++
b/core/camel-console/src/main/java/org/apache/camel/impl/console/ProcessorDetailDevConsole.java
@@ -148,7 +148,7 @@ public class ProcessorDetailDevConsole extends
AbstractDevConsole {
processors.add(fromEntry);
try {
- String xml = mr.dumpRouteAsXml();
+ String xml = mr.dumpRouteAsXml(false, true, true);
if (xml != null && !xml.isBlank()) {
DocumentBuilderFactory factory =
DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl",
true);
@@ -158,6 +158,12 @@ public class ProcessorDetailDevConsole extends
AbstractDevConsole {
Document doc = builder.parse(new InputSource(new
StringReader(xml)));
Element routeElement = doc.getDocumentElement();
+ // extract source line number for the from entry from the
<from> child element
+ NodeList fromNodes = routeElement.getElementsByTagName("from");
+ if (fromNodes.getLength() > 0) {
+ extractSourceLineNumber((Element) fromNodes.item(0),
fromEntry);
+ }
+
collectProcessors(routeElement, processors);
}
} catch (Exception e) {
@@ -184,16 +190,23 @@ public class ProcessorDetailDevConsole extends
AbstractDevConsole {
}
String type = elem.getTagName();
+ // skip <from> elements — already handled as the manual fromEntry
with endpointUri
+ if ("from".equals(type)) {
+ continue;
+ }
JsonObject entry = new JsonObject();
entry.put("id", id);
entry.put("type", type);
+ extractSourceLineNumber(elem, entry);
+
JsonObject opts = new JsonObject();
NamedNodeMap attrs = elem.getAttributes();
for (int j = 0; j < attrs.getLength(); j++) {
Attr attr = (Attr) attrs.item(j);
String name = attr.getName();
- if (!"id".equals(name) && !"customId".equals(name) &&
!name.startsWith("xmlns")) {
+ if (!"id".equals(name) && !"customId".equals(name) &&
!name.startsWith("xmlns")
+ && !"sourceLineNumber".equals(name) &&
!"sourceLocation".equals(name)) {
opts.put(name, attr.getValue());
}
}
@@ -272,4 +285,15 @@ public class ProcessorDetailDevConsole extends
AbstractDevConsole {
}
return null;
}
+
+ private static void extractSourceLineNumber(Element elem, JsonObject
entry) {
+ String lineStr = elem.getAttribute("sourceLineNumber");
+ if (lineStr != null && !lineStr.isEmpty()) {
+ try {
+ entry.put("line", Integer.parseInt(lineStr));
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ }
+ }
}
diff --git
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
index 615f43f7907c..290f2ec1c903 100644
---
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
+++
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
@@ -740,9 +740,18 @@ public class LocalCliConnector extends ServiceSupport
implements CliConnector, C
String filter = root.getString("filter");
String format = root.getString("format");
String uriAsParameters = root.getString("uriAsParameters");
+ Map<String, Object> params = new HashMap<>();
+ if (filter != null) {
+ params.put("filter", filter);
+ }
+ if (format != null) {
+ params.put("format", format);
+ }
+ if (uriAsParameters != null) {
+ params.put("uriAsParameters", uriAsParameters);
+ }
JsonObject json
- = (JsonObject) dc.call(DevConsole.MediaType.JSON,
- Map.of("filter", filter, "format", format,
"uriAsParameters", uriAsParameters));
+ = (JsonObject) dc.call(DevConsole.MediaType.JSON, params);
LOG.trace("Updating output file: {}", outputFile);
IOHelper.writeText(json.toJson(), outputFile);
} else {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
index b2c69a82ce18..3e33db3fbddc 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
@@ -1098,6 +1098,8 @@ class DiagramTab extends AbstractTab {
diagram.scrollToSelectedEipNode();
}
});
+ sourceViewer.setQuickDocProvider(this::provideAllQuickDocs);
+ ensureProcessorDetailLoaded(routeId);
var rl = diagram.getRouteLayout(routeId);
sourceViewer.loadSource(ctx, routeId, 0, rl != null ? rl.source :
null);
}
@@ -1120,6 +1122,8 @@ class DiagramTab extends AbstractTab {
sourceViewer.hide();
}
});
+ sourceViewer.setQuickDocProvider(this::provideAllQuickDocs);
+ ensureProcessorDetailLoaded(drillDownRouteId);
var rl2 = diagram.getRouteLayout(drillDownRouteId);
sourceViewer.loadSource(ctx, drillDownRouteId, targetLine, rl2 != null
? rl2.source : null);
}
@@ -1420,6 +1424,61 @@ class DiagramTab extends AbstractTab {
}
}
+ // ---- Quick doc (i toggle in source viewer) ----
+
+ private Map<Integer, List<String>> provideAllQuickDocs(List<JsonObject>
cd) {
+ if (cachedRouteDetail == null || cd.isEmpty()) {
+ return Map.of();
+ }
+
+ JsonArray processors = (JsonArray) cachedRouteDetail.get("processors");
+ if (processors == null || processors.isEmpty()) {
+ return Map.of();
+ }
+
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ CamelCatalog catalog = info != null ? getCatalog(info) : null;
+
+ Map<Integer, List<String>> result = new LinkedHashMap<>();
+ for (Object obj : processors) {
+ JsonObject proc = (JsonObject) obj;
+ Integer line = proc.getInteger("line");
+ if (line == null || line <= 0) {
+ continue;
+ }
+
+ int eipIdx = RoutesTab.findCodeDataIndex(cd, line, -1);
+ if (eipIdx < 0 || result.containsKey(eipIdx)) {
+ continue;
+ }
+ String endpointUri = proc.getString("endpointUri");
+ String type = proc.getString("type");
+
+ if (endpointUri != null && catalog != null) {
+ RoutesTab.buildEndpointInlineDoc(result, cd, catalog,
endpointUri, eipIdx);
+ } else if (type != null) {
+ RoutesTab.buildEipInlineDoc(result, cd, catalog, type,
proc.getMap("options"), eipIdx);
+ }
+ }
+ return result;
+ }
+
+ private void ensureProcessorDetailLoaded(String routeId) {
+ if (routeId != null && cachedRouteDetail == null
+ && !routeId.equals(detailLoadingRouteId)) {
+ detailLoadingRouteId = routeId;
+ detailLoading = true;
+ if (ctx.runner != null) {
+ ctx.backgroundExecutor.execute(() -> {
+ JsonObject result = requestRouteProcessorDetail(routeId);
+ cachedRouteDetail = result;
+ cachedRouteDetailId = routeId;
+ detailLoading = false;
+ });
+ }
+ }
+ }
+
private static int numWidth(long... values) {
long max = 0;
for (long v : values) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
index a284cf16c034..ad5e22996c01 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
@@ -156,7 +156,6 @@ class RoutesTab extends AbstractTab {
loadSourceForSelectedNode();
return true;
}
-
// Source viewer toggle (topology mode)
if (topologyMode && diagram.isShowDiagram() && ke.isChar('c')) {
loadSourceForSelectedTopologyRoute();
@@ -1663,6 +1662,8 @@ class RoutesTab extends AbstractTab {
diagram.scrollToSelectedEipNode();
}
});
+ sourceViewer.setQuickDocProvider(this::provideAllQuickDocs);
+ ensureProcessorDetailLoaded(routeId);
var rl = diagram.getRouteLayout(routeId);
sourceViewer.loadSource(ctx, routeId, 0, rl != null ? rl.source :
null);
}
@@ -1695,6 +1696,8 @@ class RoutesTab extends AbstractTab {
diagram.scrollToSelectedEipNode();
}
});
+ sourceViewer.setQuickDocProvider(this::provideAllQuickDocs);
+ ensureProcessorDetailLoaded(routeId);
var rl2 = diagram.getRouteLayout(routeId);
sourceViewer.loadSource(ctx, routeId, 0, rl2 != null ? rl2.source :
null);
}
@@ -1717,6 +1720,8 @@ class RoutesTab extends AbstractTab {
sourceViewer.hide();
}
});
+ sourceViewer.setQuickDocProvider(this::provideAllQuickDocs);
+ ensureProcessorDetailLoaded(drillDownRouteId);
var rl3 = diagram.getRouteLayout(drillDownRouteId);
sourceViewer.loadSource(ctx, drillDownRouteId, targetLine, rl3 != null
? rl3.source : null);
}
@@ -2028,6 +2033,275 @@ class RoutesTab extends AbstractTab {
return null;
}
+ // ---- Quick doc (q toggle in source viewer) ----
+
+ private Map<Integer, List<String>> provideAllQuickDocs(List<JsonObject>
cd) {
+ if (cachedRouteDetail == null || cd.isEmpty()) {
+ return Map.of();
+ }
+
+ JsonArray processors = (JsonArray) cachedRouteDetail.get("processors");
+ if (processors == null || processors.isEmpty()) {
+ return Map.of();
+ }
+
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ CamelCatalog catalog = info != null ? getCatalog(info) : null;
+
+ Map<Integer, List<String>> result = new LinkedHashMap<>();
+ for (Object obj : processors) {
+ JsonObject proc = (JsonObject) obj;
+ Integer line = proc.getInteger("line");
+ if (line == null || line <= 0) {
+ continue;
+ }
+
+ int eipIdx = findCodeDataIndex(cd, line, -1);
+ if (eipIdx < 0 || result.containsKey(eipIdx)) {
+ continue;
+ }
+ String endpointUri = proc.getString("endpointUri");
+ String type = proc.getString("type");
+
+ if (endpointUri != null && catalog != null) {
+ buildEndpointInlineDoc(result, cd, catalog, endpointUri,
eipIdx);
+ } else if (type != null) {
+ buildEipInlineDoc(result, cd, catalog, type,
proc.getMap("options"), eipIdx);
+ }
+ }
+ return result;
+ }
+
+ private void ensureProcessorDetailLoaded(String routeId) {
+ if (routeId != null && cachedRouteDetail == null
+ && !routeId.equals(detailLoadingRouteId)) {
+ detailLoadingRouteId = routeId;
+ detailLoading = true;
+ if (ctx.runner != null) {
+ ctx.backgroundExecutor.execute(() -> {
+ JsonObject result = requestRouteProcessorDetail(routeId);
+ cachedRouteDetail = result;
+ cachedRouteDetailId = routeId;
+ detailLoading = false;
+ });
+ }
+ }
+ }
+
+ static void buildEndpointInlineDoc(
+ Map<Integer, List<String>> result, List<JsonObject> cd,
+ CamelCatalog catalog, String endpointUri, int eipIdx) {
+
+ String component = endpointUri.contains(":") ?
endpointUri.substring(0, endpointUri.indexOf(':')) : endpointUri;
+ ComponentModel model = catalog.componentModel(component);
+ if (model == null) {
+ return;
+ }
+
+ String compTitle = model.getTitle() != null ? model.getTitle() :
component;
+ String desc = model.getDescription() != null ?
truncateText(model.getDescription(), 80) : "";
+
+ Map<String, BaseOptionModel> optionDocs = new LinkedHashMap<>();
+ for (ComponentModel.EndpointOptionModel opt :
model.getEndpointOptions()) {
+ if (opt.getName() != null) {
+ optionDocs.put(opt.getName(), opt);
+ }
+ }
+
+ int beforeSize = result.size();
+ List<String> titleLines = new ArrayList<>();
+ titleLines.add(compTitle + " — " + desc);
+ result.put(eipIdx, titleLines);
+
+ inlineParameterDocs(result, cd, eipIdx, optionDocs);
+
+ // For Java/XML where params are in the URI (not on separate source
lines),
+ // cluster the option docs under the title
+ if (result.size() == beforeSize + 1) {
+ clusterEndpointOptions(titleLines, catalog, endpointUri,
optionDocs);
+ }
+ }
+
+ private static void clusterEndpointOptions(
+ List<String> docLines, CamelCatalog catalog,
+ String endpointUri, Map<String, BaseOptionModel> optionDocs) {
+ try {
+ Map<String, String> props =
catalog.endpointProperties(endpointUri);
+ if (props != null) {
+ for (Map.Entry<String, String> entry : props.entrySet()) {
+ BaseOptionModel optModel = optionDocs.get(entry.getKey());
+ if (optModel != null) {
+ String optDoc = formatOptionDoc(optModel);
+ if (optDoc != null) {
+ docLines.add(entry.getKey() + ": " +
entry.getValue() + " — " + optDoc);
+ }
+ }
+ }
+ }
+ } catch (Exception e) {
+ // ignore URI parse errors
+ }
+ }
+
+ static void buildEipInlineDoc(
+ Map<Integer, List<String>> result, List<JsonObject> cd,
+ CamelCatalog catalog, String type, JsonObject opts, int eipIdx) {
+
+ EipModel model = catalog != null ? catalog.eipModel(type) : null;
+ List<String> titleLines = new ArrayList<>();
+ if (model != null && model.getTitle() != null) {
+ String desc = model.getDescription() != null ?
truncateText(model.getDescription(), 80) : "";
+ titleLines.add(model.getTitle() + " — " + desc);
+ } else {
+ titleLines.add(type);
+ }
+
+ int beforeSize = result.size();
+ result.put(eipIdx, titleLines);
+
+ if (model != null) {
+ Map<String, BaseOptionModel> optionDocs = new LinkedHashMap<>();
+ for (BaseOptionModel opt : model.getOptions()) {
+ if (opt.getName() != null) {
+ optionDocs.put(opt.getName(), opt);
+ }
+ }
+ inlineParameterDocs(result, cd, eipIdx, optionDocs);
+
+ // For Java/XML where options are on the same line,
+ // cluster the option docs under the title
+ if (result.size() == beforeSize + 1 && opts != null) {
+ clusterEipOptions(titleLines, opts, optionDocs);
+ }
+ }
+ }
+
+ private static void clusterEipOptions(
+ List<String> docLines, JsonObject opts,
+ Map<String, BaseOptionModel> optionDocs) {
+ for (Map.Entry<String, Object> entry : opts.entrySet()) {
+ BaseOptionModel optModel = optionDocs.get(entry.getKey());
+ if (optModel != null) {
+ String optDoc = formatOptionDoc(optModel);
+ if (optDoc != null) {
+ docLines.add(entry.getKey() + ": " + entry.getValue() + "
— " + optDoc);
+ }
+ }
+ }
+ }
+
+ static void inlineParameterDocs(
+ Map<Integer, List<String>> result, List<JsonObject> cd,
+ int eipIdx, Map<String, BaseOptionModel> optionDocs) {
+
+ if (optionDocs.isEmpty()) {
+ return;
+ }
+
+ int eipIndent = lineIndent(cd, eipIdx);
+ int childIndent = -1;
+
+ for (int i = eipIdx + 1; i < cd.size(); i++) {
+ String code = cd.get(i).get("code") != null ?
cd.get(i).get("code").toString() : "";
+ int indent = leadingSpaces(code);
+ if (indent < eipIndent && !code.isBlank()) {
+ break;
+ }
+ if (childIndent < 0 && indent > eipIndent) {
+ childIndent = indent;
+ }
+ if (childIndent > 0 && indent != childIndent) {
+ continue;
+ }
+ String trimmed = code.stripLeading();
+ int colon = trimmed.indexOf(':');
+ if (colon <= 0) {
+ continue;
+ }
+ String key = trimmed.substring(0, colon).strip();
+ if ("parameters".equals(key) || "uri".equals(key) ||
"steps".equals(key)
+ || "id".equals(key) || "description".equals(key)) {
+ continue;
+ }
+
+ BaseOptionModel doc = optionDocs.get(key);
+ if (doc != null) {
+ String docLine = formatOptionDoc(doc);
+ if (docLine != null) {
+ result.put(i, List.of(docLine));
+ }
+ }
+ }
+ }
+
+ static String formatOptionDoc(BaseOptionModel doc) {
+ StringBuilder sb = new StringBuilder();
+ if (doc.getDescription() != null && !doc.getDescription().isEmpty()) {
+ sb.append(truncateText(doc.getDescription(), 80));
+ }
+ List<String> meta = new ArrayList<>();
+ if (doc.getType() != null) {
+ meta.add(doc.getType());
+ }
+ if (doc.isRequired()) {
+ meta.add("required");
+ }
+ if (doc.getDefaultValue() != null) {
+ meta.add("default: " + doc.getDefaultValue());
+ }
+ if (!meta.isEmpty()) {
+ if (!sb.isEmpty()) {
+ sb.append(" ");
+ }
+ sb.append("(").append(String.join(", ", meta)).append(")");
+ }
+ return !sb.isEmpty() ? sb.toString() : null;
+ }
+
+ static int lineIndent(List<JsonObject> cd, int idx) {
+ if (idx < 0 || idx >= cd.size()) {
+ return 0;
+ }
+ String code = cd.get(idx).get("code") != null ?
cd.get(idx).get("code").toString() : "";
+ return leadingSpaces(code);
+ }
+
+ static int leadingSpaces(String s) {
+ int count = 0;
+ for (int i = 0; i < s.length(); i++) {
+ if (s.charAt(i) == ' ') {
+ count++;
+ } else {
+ break;
+ }
+ }
+ return count;
+ }
+
+ static int findCodeDataIndex(List<JsonObject> cd, int targetLine, int
fallback) {
+ for (int i = 0; i < cd.size(); i++) {
+ Integer lineNum = cd.get(i).getInteger("line");
+ if (lineNum != null && lineNum == targetLine) {
+ return i;
+ }
+ }
+ return Math.max(0, fallback);
+ }
+
+ static String truncateText(String text, int maxLen) {
+ if (text == null) {
+ return "";
+ }
+ int dot = text.indexOf('.');
+ if (dot > 0 && dot < maxLen) {
+ return text.substring(0, dot + 1);
+ }
+ if (text.length() <= maxLen) {
+ return text;
+ }
+ return text.substring(0, maxLen - 3) + "...";
+ }
+
@Override
public String description() {
return "Route list with state, message counts, throughput, and failure
statistics";
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 4985cc301b3f..c685be6ed7fe 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
@@ -58,6 +58,11 @@ import org.apache.camel.util.json.Jsoner;
*/
class SourceViewer {
+ @FunctionalInterface
+ interface QuickDocProvider {
+ Map<Integer, List<String>> provideAll(List<JsonObject> codeData);
+ }
+
private boolean visible;
private List<String> lines = Collections.emptyList();
private List<JsonObject> codeData = Collections.emptyList();
@@ -85,6 +90,9 @@ class SourceViewer {
private boolean markdownMode;
private String rawMarkdownContent;
private int markdownScroll;
+ private QuickDocProvider quickDocProvider;
+ private boolean quickDocEnabled;
+ private Map<Integer, List<String>> quickDocEntries =
Collections.emptyMap();
private record CachedSource(
List<String> lines, List<JsonObject> codeData,
@@ -98,6 +106,8 @@ class SourceViewer {
void hide() {
visible = false;
onLineSelected = null;
+ quickDocEnabled = false;
+ quickDocEntries = Collections.emptyMap();
}
void reset() {
@@ -122,12 +132,42 @@ class SourceViewer {
markdownMode = false;
rawMarkdownContent = null;
markdownScroll = 0;
+ quickDocProvider = null;
+ quickDocEnabled = false;
+ quickDocEntries = Collections.emptyMap();
}
void setOnLineSelected(IntConsumer callback) {
this.onLineSelected = callback;
}
+ 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);
+ if (quickDocEntries == null) {
+ quickDocEntries = Collections.emptyMap();
+ }
+ }
+ }
+
+ void setQuickDocProvider(QuickDocProvider provider) {
+ this.quickDocProvider = provider;
+ }
+
boolean handleKeyEvent(KeyEvent ke) {
if (!visible) {
return false;
@@ -153,7 +193,7 @@ class SourceViewer {
onLineSelected = null;
return true;
}
- if (isMarkdownFile && (ke.isKey(KeyCode.TAB) || ke.isLeft() ||
ke.isRight())) {
+ if (isMarkdownFile && ke.isKey(KeyCode.TAB)) {
markdownMode = !markdownMode;
return true;
}
@@ -173,8 +213,7 @@ class SourceViewer {
}
return true;
}
- if (currentRouteId != null
- && (ke.isKey(KeyCode.TAB) || ke.isRight() || ke.isLeft())) {
+ if (currentRouteId != null && ke.isKey(KeyCode.TAB)) {
String[] formats = { "yaml", "java", "xml" };
int idx = 0;
for (int i = 0; i < formats.length; i++) {
@@ -183,11 +222,12 @@ class SourceViewer {
break;
}
}
- if (ke.isLeft()) {
+ if (ke.hasShift()) {
idx = (idx - 1 + formats.length) % formats.length;
} else {
idx = (idx + 1) % formats.length;
}
+ quickDocEntries = Collections.emptyMap();
switchFormat(formats[idx]);
return true;
}
@@ -198,6 +238,10 @@ class SourceViewer {
}
return true;
}
+ if (ke.isChar('i') && quickDocProvider != null) {
+ toggleQuickDoc();
+ return true;
+ }
if (ke.isChar('w')) {
wordWrap = !wordWrap;
scrollX = 0;
@@ -348,14 +392,37 @@ class SourceViewer {
int currentMatchLine = search.currentMatchLine();
- int end = Math.min(scrollY + visibleLines, lines.size());
+ int gutterWidth = quickDocEnabled && !quickDocEntries.isEmpty() ?
computeGutterWidth() : 0;
+
List<Line> visible = new ArrayList<>();
- for (int i = scrollY; i < end; i++) {
+ for (int i = scrollY; i < lines.size() && visible.size() <
visibleLines; i++) {
String raw = lines.get(i);
boolean isSelected = (i == selectedLine);
Line line = highlightSourceLine(raw, hSkip, isSelected,
inner.width());
line = search.applyHighlights(line, i, currentMatchLine);
visible.add(line);
+
+ List<String> docLines = quickDocEnabled ? quickDocEntries.get(i) :
null;
+ if (docLines != null) {
+ String code = i < codeData.size() &&
codeData.get(i).get("code") != null
+ ? codeData.get(i).get("code").toString()
+ : "";
+ int si = 0;
+ while (si < code.length() && code.charAt(si) == ' ') {
+ si++;
+ }
+ for (String docText : docLines) {
+ for (Line docLine : renderQuickDocLines(docText, si,
gutterWidth, inner.width())) {
+ if (visible.size() >= visibleLines) {
+ break;
+ }
+ if (hSkip > 0) {
+ docLine = applyHorizontalSkip(docLine, hSkip);
+ }
+ visible.add(docLine);
+ }
+ }
+ }
}
List<Rect> hChunks = Layout.horizontal()
@@ -365,8 +432,10 @@ class SourceViewer {
Overflow overflow = wordWrap ? Overflow.WRAP_WORD : Overflow.CLIP;
frame.renderWidget(Paragraph.builder().text(Text.from(visible)).overflow(overflow).build(),
hChunks.get(0));
- if (lines.size() > visibleLines) {
-
vScrollState.contentLength(lines.size()).viewportContentLength(visibleLines).position(scrollY);
+ int totalDocLines = quickDocEnabled ?
quickDocEntries.values().stream().mapToInt(List::size).sum() : 0;
+ int totalContentLines = lines.size() + totalDocLines;
+ if (totalContentLines > visibleLines) {
+
vScrollState.contentLength(totalContentLines).viewportContentLength(visibleLines).position(scrollY);
frame.renderStatefulWidget(Scrollbar.builder().build(),
hChunks.get(1), vScrollState);
}
if (!wordWrap) {
@@ -384,7 +453,7 @@ class SourceViewer {
if (markdownMode) {
TuiHelper.hint(spans, "Esc/c", "close");
TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "scroll");
- TuiHelper.hint(spans, "←→", "tab");
+ TuiHelper.hint(spans, "Tab", "format");
TuiHelper.hint(spans, "PgUp/PgDn", "page");
return;
}
@@ -397,19 +466,15 @@ class SourceViewer {
} else {
TuiHelper.hint(spans, "Esc/c", "close");
}
- TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "navigate");
- if (isMarkdownFile) {
- TuiHelper.hint(spans, "←→", "tab");
+ if (quickDocProvider != null) {
+ TuiHelper.hint(spans, "i", "quick doc" + (quickDocEnabled ? "
[on]" : ""));
}
- if (currentRouteId != null) {
- TuiHelper.hint(spans, "←→", "tab");
+ TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "navigate");
+ if (isMarkdownFile || currentRouteId != null) {
+ TuiHelper.hint(spans, "Tab", "format");
}
search.renderSearchHints(spans);
TuiHelper.hint(spans, "w", "wrap" + (wordWrap ? " [on]" : " [off]"));
- if (!wordWrap) {
- TuiHelper.hint(spans, TuiIcons.HINT_H, "horizontal");
- }
- TuiHelper.hint(spans, "PgUp/PgDn", "page");
if (onLineSelected != null) {
TuiHelper.hint(spans, "Enter", "select node");
}
@@ -735,6 +800,7 @@ class SourceViewer {
scrollX = 0;
pendingScroll = true;
search.reset();
+ refreshQuickDoc();
return;
}
@@ -824,6 +890,7 @@ class SourceViewer {
scrollX = 0;
pendingScroll = true;
search.reset();
+ refreshQuickDoc();
});
}
@@ -937,6 +1004,91 @@ 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(String text, int sourceIndent, int
gutterWidth, int viewportWidth) {
+ 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 static String objToString(Object o) {
return o != null ? o.toString() : "";
}