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 633c1c9d8859 CAMEL-23839: Fix TUI theme background and add --theme CLI
option
633c1c9d8859 is described below
commit 633c1c9d88592ef028e464f2c3113afcc5ff6751
Author: Adriano Machado <[email protected]>
AuthorDate: Tue Jul 7 12:58:17 2026 -0400
CAMEL-23839: Fix TUI theme background and add --theme CLI option
Fix background color rendering by patching unset fg/bg cells after each
frame. Add --theme=dark|light CLI option to override persisted theme for
the current session. Introduce ThemeMode enum to centralize dark/light
parsing, persistence, and activation. Use Theme.info() for integration
names instead of hardcoded Color.CYAN. Harden persistence logging with
separate read/write failure flags. Includes tests for base-color
patching, ThemeMode, and persisted-mode fallback paths.
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc | 3 +
.../modules/ROOT/pages/camel-jbang-tui.adoc | 13 +-
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 21 +-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 55 +++++
.../dsl/jbang/core/commands/tui/OverviewTab.java | 2 +-
.../camel/dsl/jbang/core/commands/tui/Theme.java | 246 +++++++++++++++------
.../dsl/jbang/core/commands/tui/ThemeMode.java | 71 ++++++
.../tui/ThemeModeCompletionCandidates.java | 28 +++
.../dsl/jbang/core/commands/tui/TuiCommand.java | 9 +
.../src/main/resources/tui/themes/dark.tcss | 28 ++-
.../src/main/resources/tui/themes/light.tcss | 28 ++-
.../jbang/core/commands/tui/CamelMonitorTest.java | 103 +++++++++
.../tui/ThemeModeCompletionCandidatesTest.java | 35 +++
.../dsl/jbang/core/commands/tui/ThemeModeTest.java | 84 +++++++
.../dsl/jbang/core/commands/tui/ThemeTest.java | 156 +++++++++++--
15 files changed, 768 insertions(+), 114 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index e22ff8fd638d..13cd6ce04a09 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -18,6 +18,9 @@ See the xref:camel-upgrade-recipes-tool.adoc[documentation]
page for details.
The Camel JBang CLI now automatic resolves quarkus version to use,
instead of hardcoded `3.33.1.1`
(https://github.com/apache/camel/commit/d1f4713ebdf787ab04a31c50a6f07e3ca66f0c2b).
+The Camel TUI (`camel tui`) accepts `--theme=dark` or `--theme=light` to
choose the color palette at startup.
+When omitted, the persisted `camel.tui.theme` value from
`.camel-jbang-user.properties` is used.
+
=== camel-langchain4j-agent
The `Agent.chat()` method return type has changed from `String` to
`Result<String>` (from `dev.langchain4j.service.Result`).
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index f2dc24b20ab6..38e0253bc3cb 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -306,7 +306,13 @@ The Doctor checks your development environment and reports
issues:
== Theme
The TUI ships with two color themes, *dark* (the default) and *light*, defined
as
-CSS stylesheets. Press *F4* on any screen to toggle between them at runtime.
+CSS stylesheets. Open the *F2* actions menu and choose *Light Theme* or *Dark
Theme*
+to switch at runtime.
+
+Pass `--theme=dark` or `--theme=light` on the command line to pick the palette
for
+the current session. The CLI value overrides the persisted preference from
+`.camel-jbang-user.properties`; runtime toggles and the config file still
apply on
+later launches when `--theme` is omitted.
The brand orange accent is identical in both themes; status colors (success,
warning, error) and borders adapt for readability on dark and light terminals.
@@ -327,7 +333,6 @@ in `.camel-jbang-user.properties` and restored the next
time you open the TUI.
| *F1* / *?* | Context-sensitive help (toggle)
| *F2* | Actions menu
| *F3* | Switch between integrations (when multiple running)
-| *F4* | Toggle light / dark theme
| *Shift+F5* | Take screenshot
| *Ctrl+R* | Start/stop tape recording
| *Ctrl+C* / *Q* | Quit
@@ -497,6 +502,10 @@ vhs demo.tape # .tape -> .gif
| Screen refresh interval in milliseconds.
| `100`
+| `--theme`
+| Color theme for this session: `dark` or `light`. Overrides the persisted
`camel.tui.theme` preference when set.
+|
+
| `--record`
| Replay a `.tape` file and record the session to an Asciinema `.cast` file.
|
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 7682441e0c14..9449dc1c06ea 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
@@ -88,6 +88,7 @@ class ActionsPopup {
private final Runnable burstCallback;
private Runnable resetStatsAction;
private Runnable resetScreenAction;
+ private Runnable themeToggleAction;
private Runnable openShellAction;
private Runnable browseFilesAction;
private Runnable switchIntegrationAction;
@@ -175,6 +176,10 @@ class ActionsPopup {
this.resetScreenAction = resetScreenAction;
}
+ void setThemeToggleAction(Runnable themeToggleAction) {
+ this.themeToggleAction = themeToggleAction;
+ }
+
void setOpenShellAction(Runnable openShellAction) {
this.openShellAction = openShellAction;
}
@@ -361,7 +366,7 @@ class ActionsPopup {
labels.add("Run Doctor");
labels.add("Reset Stats");
labels.add("Reset Screen");
- labels.add("dark".equals(Theme.mode()) ? "Light Theme" : "Dark Theme");
+ labels.add(Theme.isDark() ? "Light Theme" : "Dark Theme");
labels.add("───");
// Group 3: Recording & Presentation
labels.add("Take Screenshot");
@@ -604,7 +609,7 @@ class ActionsPopup {
}
} else if (action == Action.TOGGLE_THEME) {
showActionsMenu = false;
- Theme.toggle();
+ toggleTheme();
} else if (action == Action.STOP_ALL) {
showActionsMenu = false;
stopAllPopup.open();
@@ -850,7 +855,7 @@ class ActionsPopup {
items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.DOCTOR, "Run
Doctor")));
items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.RESET, "Reset
Stats")));
items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.CLEAN, "Reset
Screen")));
- String themeLabel = "dark".equals(Theme.mode())
+ String themeLabel = Theme.isDark()
? TuiIcons.menuItem(TuiIcons.LIGHT_THEME, "Light Theme")
: TuiIcons.menuItem(TuiIcons.DARK_THEME, "Dark Theme");
items.add(ListItem.from(themeLabel));
@@ -1090,7 +1095,7 @@ class ActionsPopup {
resetScreenAction.run();
}
}
- case TOGGLE_THEME -> Theme.toggle();
+ case TOGGLE_THEME -> toggleTheme();
case SCREENSHOT -> screenshotAction.run();
case SHOW_KEYSTROKES -> toggleKeystrokes.run();
case TAPE_RECORDING -> toggleTapeRecording.run();
@@ -1111,4 +1116,12 @@ class ActionsPopup {
return gotoTabPopup.consumePendingEntry();
}
+ private void toggleTheme() {
+ if (themeToggleAction != null) {
+ themeToggleAction.run();
+ } else {
+ Theme.toggle();
+ }
+ }
+
}
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 b57e497f0a77..de71e4d94c70 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
@@ -33,6 +33,8 @@ import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.buffer.Cell;
import dev.tamboui.layout.Constraint;
import dev.tamboui.layout.Layout;
import dev.tamboui.layout.Rect;
@@ -107,6 +109,11 @@ public class CamelMonitor extends CamelCommand {
defaultValue = "8123")
int mcpPort = 8123;
+ @CommandLine.Option(names = { "--theme" },
+ description = "Color theme: dark or light (overrides
persisted preference for this session)",
+ completionCandidates =
ThemeModeCompletionCandidates.class)
+ String theme;
+
// State
private final TabsState tabsState = new TabsState(TAB_OVERVIEW);
private TabRegistry tabRegistry;
@@ -163,6 +170,16 @@ public class CamelMonitor extends CamelCommand {
public Integer doCall() throws Exception {
System.setProperty("java.awt.headless", "true");
+ if (theme != null) {
+ if (!Theme.isValidMode(theme)) {
+ String expected = String.join("' or '", ThemeMode.ids());
+ throw new CommandLine.ParameterException(
+ new CommandLine(this),
+ "Invalid value for option '--theme': expected '" +
expected + "', was '" + theme + "'");
+ }
+ Theme.applyStartupMode(theme);
+ }
+
// Configure TamboUI recording if --record is specified
if (record != null) {
Path tapeFile = Path.of(record);
@@ -446,6 +463,10 @@ public class CamelMonitor extends CamelCommand {
ctx.runner = tui;
actionsPopup.setScheduler(tui.scheduler());
actionsPopup.setResetScreenAction(() -> tui.terminal().clear());
+ actionsPopup.setThemeToggleAction(() -> {
+ Theme.toggle();
+ tui.terminal().clear();
+ });
// Preload diagram data if an integration was auto-selected
tabRegistry.routesTab().preloadDiagram();
tabRegistry.diagramTab().preloadDiagram();
@@ -1114,6 +1135,7 @@ public class CamelMonitor extends CamelCommand {
helpOverlay.render(frame, contentArea);
}
renderFooter(frame, mainChunks.get(3));
+ applyThemeBaseColors(frame.buffer(), area);
recordingManager.updateBuffer(frame.buffer());
recordingManager.processPendingScreenshot();
@@ -1228,6 +1250,7 @@ public class CamelMonitor extends CamelCommand {
int x5 = area.x() + Math.max(0, (area.width() - CharWidth.of(line5)) /
2);
frame.buffer().setString(x5, startY + 4, line5, normal);
+ applyThemeBaseColors(frame.buffer(), area);
}
private void renderTabs(Frame frame, Rect area) {
@@ -1337,6 +1360,38 @@ public class CamelMonitor extends CamelCommand {
}
}
+ /**
+ * Fills cells that still have no explicit fg/bg after widget rendering.
TamboUI clears the buffer to
+ * {@link Cell#EMPTY} each frame; when bg is unset the terminal falls back
to its own default (usually black). Only
+ * patches missing colors so selection highlights, zebra stripes, and
other widget backgrounds are preserved.
+ */
+ static void applyThemeBaseColors(Buffer buffer, Rect area) {
+ Color baseBg = Theme.baseBg();
+ Color baseFg = Theme.baseFg();
+ Rect intersection = buffer.area().intersection(area);
+ for (int y = intersection.top(); y < intersection.bottom(); y++) {
+ for (int x = intersection.left(); x < intersection.right(); x++) {
+ Cell cell = buffer.get(x, y);
+ if (cell.isContinuation()) {
+ continue;
+ }
+ Style style = cell.style();
+ Color cellBg = style.bg().orElse(null);
+ Color cellFg = style.fg().orElse(null);
+ if (cellBg == null || cellFg == null) {
+ Style patch = Style.EMPTY;
+ if (cellBg == null) {
+ patch = patch.bg(baseBg);
+ }
+ if (cellFg == null) {
+ patch = patch.fg(baseFg);
+ }
+ buffer.set(x, y, cell.patchStyle(patch));
+ }
+ }
+ }
+ }
+
private void computeTabBadges(String[] badgeTexts, Style[] badgeStyles) {
Style yellow = Style.EMPTY.fg(Color.YELLOW).bold();
Style cyan = Style.EMPTY.fg(Color.CYAN).bold();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
index 4af1263a93b8..55914b8cd547 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
@@ -328,7 +328,7 @@ class OverviewTab extends AbstractTab {
String platformIcon = TuiIcons.runtimeIcon(info.platform !=
null ? info.platform : "");
String nameText = platformIcon + " " + (info.name != null ?
info.name : "");
List<Span> nameSpans = new ArrayList<>();
- nameSpans.add(Span.styled(nameText,
Style.EMPTY.fg(Color.CYAN)));
+ nameSpans.add(Span.styled(nameText, Theme.info()));
if (info.devMode) {
nameSpans.add(Span.styled(" [dev]",
Style.EMPTY.fg(Color.YELLOW).dim()));
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Theme.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Theme.java
index e3c666174523..748a4ebb7707 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Theme.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Theme.java
@@ -39,10 +39,10 @@ import org.slf4j.LoggerFactory;
* palette lives in one place. Values are resolved from CSS stylesheets
({@code dark.tcss} / {@code light.tcss}) through
* a shared {@link StyleEngine}; the active stylesheet can be switched at
runtime and is persisted to user config.
* <p/>
- * Palette policy: the brand accent is Camel orange (truecolor), reserved for
accent and borders. Status colors use ANSI
- * names in dark mode (so they respect the terminal) and explicit darker hex
in light mode. If a stylesheet is missing
- * or malformed, the facade falls back to the built-in palette below and logs
once, so a cosmetic failure never crashes
- * the TUI.
+ * Palette policy: the brand accent is Camel orange (truecolor), used for
accent tokens, focused borders, and titles.
+ * Status colors use explicit truecolor hex values in both themes (no ANSI
names); accent-bg/hint-key/selection keep
+ * plain white/black foregrounds. Fallbacks mirror {@code dark.tcss}. If a
stylesheet is missing or malformed, the
+ * facade falls back to the built-in palette below and logs once, so a
cosmetic failure never crashes the TUI.
*/
final class Theme {
@@ -52,34 +52,38 @@ final class Theme {
private static final Logger LOG = LoggerFactory.getLogger(Theme.class);
private static final String PROP_KEY = "camel.tui.theme";
- private static final String DARK = "dark";
- private static final String LIGHT = "light";
// Fallback palette mirrors the dark stylesheet, used when CSS is
unavailable.
private static final Style FALLBACK_ACCENT_BG =
Style.EMPTY.fg(Color.WHITE).bg(ACCENT).bold();
private static final Style FALLBACK_HINT_KEY =
Style.EMPTY.fg(Color.BLACK).bg(ACCENT).bold();
- private static final Style FALLBACK_BORDER =
Style.EMPTY.fg(Color.DARK_GRAY);
+ private static final Style FALLBACK_BORDER =
Style.EMPTY.fg(Color.rgb(0x50, 0x50, 0x50));
private static final Style FALLBACK_BORDER_FOCUSED =
Style.EMPTY.fg(ACCENT);
private static final Style FALLBACK_TITLE = Style.EMPTY.fg(ACCENT).bold();
- private static final Style FALLBACK_SUCCESS =
Style.EMPTY.fg(Color.LIGHT_GREEN);
- private static final Style FALLBACK_WARNING =
Style.EMPTY.fg(Color.LIGHT_YELLOW);
- private static final Style FALLBACK_ERROR =
Style.EMPTY.fg(Color.LIGHT_RED);
- private static final Style FALLBACK_MUTED = Style.EMPTY.dim();
- private static final Style FALLBACK_SELECTION =
Style.EMPTY.fg(Color.WHITE).bold().onBlue();
- private static final Style FALLBACK_INFO = Style.EMPTY.fg(Color.CYAN);
- private static final Style FALLBACK_NOTICE = Style.EMPTY.fg(Color.MAGENTA);
- private static final Style FALLBACK_MCP_ACTIVE =
Style.EMPTY.fg(Color.LIGHT_GREEN);
- private static final Style FALLBACK_MCP_IDLE =
Style.EMPTY.fg(Color.DARK_GRAY);
- private static final Style FALLBACK_MCP_DOWN =
Style.EMPTY.fg(Color.LIGHT_RED);
- private static final Color FALLBACK_ZEBRA = Color.rgb(0x1C, 0x1C, 0x1C);
+ private static final Style FALLBACK_SUCCESS =
Style.EMPTY.fg(Color.rgb(0x4E, 0xC9, 0xB0));
+ private static final Style FALLBACK_WARNING =
Style.EMPTY.fg(Color.rgb(0xDC, 0xDC, 0xAA));
+ private static final Style FALLBACK_ERROR = Style.EMPTY.fg(Color.rgb(0xF4,
0x87, 0x71));
+ private static final Style FALLBACK_MUTED = Style.EMPTY.fg(Color.rgb(0x80,
0x80, 0x80));
+ private static final Style FALLBACK_SELECTION =
Style.EMPTY.fg(Color.WHITE).bg(Color.rgb(0x26, 0x4F, 0x78)).bold();
+ private static final Style FALLBACK_INFO = Style.EMPTY.fg(Color.rgb(0x9C,
0xDC, 0xFE));
+ private static final Style FALLBACK_NOTICE =
Style.EMPTY.fg(Color.rgb(0xC5, 0x86, 0xC0));
+ private static final Style FALLBACK_MCP_ACTIVE =
Style.EMPTY.fg(Color.rgb(0x4E, 0xC9, 0xB0));
+ private static final Style FALLBACK_MCP_IDLE =
Style.EMPTY.fg(Color.rgb(0x60, 0x60, 0x60));
+ private static final Style FALLBACK_MCP_DOWN =
Style.EMPTY.fg(Color.rgb(0xF4, 0x87, 0x71));
+ private static final Color FALLBACK_ZEBRA = Color.rgb(0x25, 0x25, 0x25);
+ private static final Color FALLBACK_BASE_BG = Color.rgb(0x1E, 0x1E, 0x1E);
+ private static final Color FALLBACK_BASE_FG = Color.rgb(0xD4, 0xD4, 0xD4);
private static final Map<String, Style> CACHE = new HashMap<>();
private static boolean initialized;
- private static boolean fallbackLogged;
+ private static boolean persistedModeLoaded;
+ private static boolean stylesheetFallbackLogged;
+ private static boolean tokenFallbackLogged;
+ private static boolean persistedReadFallbackLogged;
+ private static boolean persistedWriteFallbackLogged;
private static boolean testMode;
private static StyleEngine engine;
- private static String mode = DARK;
+ private static ThemeMode mode = ThemeMode.DARK;
private Theme() {
}
@@ -92,7 +96,7 @@ final class Theme {
try {
return e.resolve(new Token("accent")).foreground().orElse(ACCENT);
} catch (RuntimeException ex) {
- logFallbackOnce(ex);
+ logTokenFallbackOnce(ex);
return ACCENT;
}
}
@@ -109,11 +113,43 @@ final class Theme {
try {
return e.resolve(new
Token("row-alt")).background().orElse(FALLBACK_ZEBRA);
} catch (RuntimeException ex) {
- logFallbackOnce(ex);
+ logTokenFallbackOnce(ex);
return FALLBACK_ZEBRA;
}
}
+ /**
+ * Base background color for the main content area. Theme-aware (dark on
dark mode, white on light mode).
+ */
+ static Color baseBg() {
+ StyleEngine e = engine();
+ if (e == null) {
+ return FALLBACK_BASE_BG;
+ }
+ try {
+ return e.resolve(new
Token("base-bg")).background().orElse(FALLBACK_BASE_BG);
+ } catch (RuntimeException ex) {
+ logTokenFallbackOnce(ex);
+ return FALLBACK_BASE_BG;
+ }
+ }
+
+ /**
+ * Base foreground color for normal text. Theme-aware (light gray on dark
mode, dark gray on light mode).
+ */
+ static Color baseFg() {
+ StyleEngine e = engine();
+ if (e == null) {
+ return FALLBACK_BASE_FG;
+ }
+ try {
+ return e.resolve(new
Token("base-fg")).foreground().orElse(FALLBACK_BASE_FG);
+ } catch (RuntimeException ex) {
+ logTokenFallbackOnce(ex);
+ return FALLBACK_BASE_FG;
+ }
+ }
+
/** White-on-orange: active tab highlight. */
static Style accentBg() {
return style("accent-bg", FALLBACK_ACCENT_BG);
@@ -160,7 +196,7 @@ final class Theme {
return style("selection", FALLBACK_SELECTION);
}
- /** Informational accent (header integration count). */
+ /** Informational accent (header integration count, integration name text,
popup prompts). */
static Style info() {
return style("info", FALLBACK_INFO);
}
@@ -188,37 +224,85 @@ final class Theme {
/** The active theme mode: {@code "dark"} or {@code "light"}. */
static String mode() {
engine();
- return mode;
+ return mode.id();
}
- /** Flip the active theme, clear the cache, persist the new value, and
return the new mode. */
+ /** True if the active mode is dark. */
+ static boolean isDark() {
+ engine();
+ return mode == ThemeMode.DARK;
+ }
+
+ /** Flip the active theme mode, persist it (outside test mode), and
activate it, returning the new mode. */
static synchronized String toggle() {
- String next = DARK.equals(mode) ? LIGHT : DARK;
- setMode(next);
+ ThemeMode next = mode.toggle();
if (!testMode) {
persist(next);
}
- return next;
+ activate(next, false);
+ return next.id();
}
- /** Activate a specific mode without persisting. Unknown values fall back
to dark. */
+ /**
+ * Activate a specific mode without persisting. Unknown values fall back
to dark.
+ * <p/>
+ * Test-only entry point: unlike {@link #applyStartupMode(String)}, this
does not mark the persisted preference as
+ * loaded, so outside test mode a later {@link #engine()}
re-initialization can still overwrite the mode with
+ * whatever is on disk.
+ */
static synchronized void setMode(String newMode) {
- String resolved = DARK.equals(newMode) || LIGHT.equals(newMode) ?
newMode : DARK;
- mode = resolved;
+ activate(ThemeMode.parseOrDefault(newMode), false);
+ }
+
+ /**
+ * Applies a CLI-selected theme for this process only. Marks the persisted
preference as already loaded so the value
+ * from {@code --theme} wins over {@code camel.tui.theme} in user config.
+ *
+ * @throws IllegalArgumentException if {@code newMode} is not a known
theme mode; callers should normally validate
+ * with {@link #isValidMode(String)}
first to report a friendlier CLI error.
+ */
+ static synchronized void applyStartupMode(String newMode) {
+ ThemeMode parsed = ThemeMode.parse(newMode)
+ .orElseThrow(() -> new IllegalArgumentException("Unknown Camel
TUI theme mode: " + newMode));
+ activate(parsed, true);
+ }
+
+ /** Whether {@code value} parses to a known theme mode. */
+ static boolean isValidMode(String value) {
+ return ThemeMode.parse(value).isPresent();
+ }
+
+ /** Test hook: enable in-memory-only mode so tests never touch user config
files. */
+ static synchronized void resetForTesting() {
+ resetForTesting(true);
+ }
+
+ /**
+ * Test hook like {@link #resetForTesting()}, but lets the caller keep
{@code testMode} disabled so a test can
+ * exercise the real persistence path (disk read/write) against an
isolated home directory.
+ */
+ static synchronized void resetForTesting(boolean testModeValue) {
+ testMode = testModeValue;
+ stylesheetFallbackLogged = false;
+ tokenFallbackLogged = false;
+ persistedReadFallbackLogged = false;
+ persistedWriteFallbackLogged = false;
+ persistedModeLoaded = false;
CACHE.clear();
engine = null;
initialized = false;
- engine();
+ mode = ThemeMode.DARK;
}
- /** Test hook: enable in-memory-only mode so tests never touch user config
files. */
- static synchronized void resetForTesting() {
- testMode = true;
- fallbackLogged = false;
+ private static synchronized void activate(ThemeMode newMode, boolean
cliOverride) {
+ mode = newMode;
+ if (cliOverride) {
+ persistedModeLoaded = true;
+ }
CACHE.clear();
engine = null;
initialized = false;
- mode = DARK;
+ engine();
}
private static synchronized Style style(String id, Style fallback) {
@@ -226,14 +310,19 @@ final class Theme {
if (e == null) {
return fallback;
}
- return CACHE.computeIfAbsent(id, key -> {
- try {
- return e.resolve(new Token(key)).toStyle();
- } catch (RuntimeException ex) {
- logFallbackOnce(ex);
- return fallback;
- }
- });
+ Style cached = CACHE.get(id);
+ if (cached != null) {
+ return cached;
+ }
+ try {
+ Style resolved = e.resolve(new Token(id)).toStyle();
+ CACHE.put(id, resolved);
+ return resolved;
+ } catch (RuntimeException ex) {
+ // Not cached: a transient resolution failure must not permanently
lock this token to the fallback.
+ logTokenFallbackOnce(ex);
+ return fallback;
+ }
}
private static synchronized StyleEngine engine() {
@@ -241,22 +330,29 @@ final class Theme {
return engine;
}
initialized = true;
- if (!testMode) {
- mode = loadPersistedMode();
+ if (!testMode && !persistedModeLoaded) {
+ ThemeMode[] loaded = { ThemeMode.DARK };
+ if (tryLoadPersistedMode(loaded)) {
+ // Only mark as loaded on success (including "nothing
persisted yet"). A read failure leaves this
+ // false so the next reinitialization gets another chance
instead of pinning the session forever.
+ mode = loaded[0];
+ persistedModeLoaded = true;
+ }
}
try {
StyleEngine e = StyleEngine.create();
+ String stylesheet = mode.id();
// Read CSS content via Theme's own classloader instead of
delegating
// to StyleEngine.loadStylesheet() which uses
StyleEngine.class.getClassLoader().
// Under parallel test execution the two classloaders can diverge,
causing
// intermittent "resource not found" failures in CI.
- String cssContent = loadCssResource("tui/themes/" + mode +
".tcss");
- e.addStylesheet(mode, cssContent);
- e.setActiveStylesheet(mode);
+ String cssContent = loadCssResource(mode.stylesheetResource());
+ e.addStylesheet(stylesheet, cssContent);
+ e.setActiveStylesheet(stylesheet);
engine = e;
} catch (Exception ex) {
engine = null;
- logFallbackOnce(ex);
+ logStylesheetFallbackOnce(ex);
}
return engine;
}
@@ -288,41 +384,61 @@ final class Theme {
}
}
- private static String loadPersistedMode() {
- String[] holder = { DARK };
+ private static boolean tryLoadPersistedMode(ThemeMode[] result) {
try {
CommandLineHelper.loadProperties(props -> {
- String v = props.getProperty(PROP_KEY);
- if (DARK.equals(v) || LIGHT.equals(v)) {
- holder[0] = v;
- }
+ ThemeMode.parse(props.getProperty(PROP_KEY)).ifPresent(parsed
-> result[0] = parsed);
});
+ return true;
} catch (RuntimeException ex) {
- // Config unreadable; keep the default mode.
+ logPersistedReadFallbackOnce(ex);
+ return false;
}
- return holder[0];
}
- private static void persist(String newMode) {
+ private static void persist(ThemeMode newMode) {
try {
CommandLineHelper.createPropertyFile(false);
CommandLineHelper.loadProperties(props -> {
- props.setProperty(PROP_KEY, newMode);
+ props.setProperty(PROP_KEY, newMode.id());
CommandLineHelper.storeProperties(props,
new Printer.QuietPrinter(new
Printer.SystemOutPrinter()), false);
});
} catch (Exception ex) {
- logFallbackOnce(ex);
+ logPersistedWriteFallbackOnce(ex);
}
}
- private static void logFallbackOnce(Throwable t) {
- if (!fallbackLogged) {
- fallbackLogged = true;
+ private static void logStylesheetFallbackOnce(Throwable t) {
+ if (!stylesheetFallbackLogged) {
+ stylesheetFallbackLogged = true;
LOG.warn("Camel TUI theme stylesheet unavailable; using built-in
palette", t);
}
}
+ private static void logTokenFallbackOnce(Throwable t) {
+ if (!tokenFallbackLogged) {
+ tokenFallbackLogged = true;
+ LOG.warn("Camel TUI theme token resolution failed; using built-in
fallback color", t);
+ }
+ }
+
+ private static void logPersistedReadFallbackOnce(Throwable t) {
+ if (!persistedReadFallbackLogged) {
+ persistedReadFallbackLogged = true;
+ LOG.warn("Camel TUI theme preference could not be read; falling
back to the default theme for this session",
+ t);
+ }
+ }
+
+ private static void logPersistedWriteFallbackOnce(Throwable t) {
+ if (!persistedWriteFallbackLogged) {
+ persistedWriteFallbackLogged = true;
+ LOG.warn("Camel TUI theme preference could not be saved; the
selected theme may not persist across restarts",
+ t);
+ }
+ }
+
/** Minimal synthetic {@link Styleable}: an id-only token used for engine
resolution. */
private record Token(String id) implements Styleable {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeMode.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeMode.java
new file mode 100644
index 000000000000..812505ae8233
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeMode.java
@@ -0,0 +1,71 @@
+/*
+ * 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.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+
+/** Canonical dark/light palette selector for the Camel TUI. */
+enum ThemeMode {
+
+ DARK("dark"),
+ LIGHT("light");
+
+ private final String id;
+
+ ThemeMode(String id) {
+ this.id = id;
+ }
+
+ /** Lowercase config / CLI value ({@code dark} or {@code light}). */
+ String id() {
+ return id;
+ }
+
+ /** Classpath resource path of this mode's stylesheet, e.g. {@code
tui/themes/dark.tcss}. */
+ String stylesheetResource() {
+ return "tui/themes/" + id + ".tcss";
+ }
+
+ // Only two modes exist today, so toggling is a simple flip; revisit this
if a third mode is ever added.
+ ThemeMode toggle() {
+ return this == DARK ? LIGHT : DARK;
+ }
+
+ static Optional<ThemeMode> parse(String value) {
+ if (value == null) {
+ return Optional.empty();
+ }
+ String normalized = value.strip().toLowerCase(Locale.ROOT);
+ for (ThemeMode mode : values()) {
+ if (mode.id.equals(normalized)) {
+ return Optional.of(mode);
+ }
+ }
+ return Optional.empty();
+ }
+
+ static ThemeMode parseOrDefault(String value) {
+ return parse(value).orElse(DARK);
+ }
+
+ static List<String> ids() {
+ return Arrays.stream(values()).map(m -> m.id).toList();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeModeCompletionCandidates.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeModeCompletionCandidates.java
new file mode 100644
index 000000000000..fffab74a9619
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeModeCompletionCandidates.java
@@ -0,0 +1,28 @@
+/*
+ * 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.Iterator;
+
+/** Picocli tab-completion candidates for {@code --theme}. */
+public class ThemeModeCompletionCandidates implements Iterable<String> {
+
+ @Override
+ public Iterator<String> iterator() {
+ return ThemeMode.ids().iterator();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
index 623720abfcbd..4b3081693e6d 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
@@ -50,6 +50,11 @@ public class TuiCommand extends CamelCommand {
arity = "0..1")
String record;
+ @CommandLine.Option(names = { "--theme" },
+ description = "Color theme: dark or light (overrides
persisted preference for this session)",
+ completionCandidates =
ThemeModeCompletionCandidates.class)
+ String theme;
+
public TuiCommand(CamelJBangMain main, ClassLoader classLoader) {
super(main);
this.classLoader = classLoader;
@@ -76,6 +81,10 @@ public class TuiCommand extends CamelCommand {
args.add("--record");
args.add(record);
}
+ if (theme != null) {
+ args.add("--theme");
+ args.add(theme);
+ }
CamelMonitor cmd = new CamelMonitor(getMain(), classLoader);
return new CommandLine(cmd).execute(args.toArray(String[]::new));
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/themes/dark.tcss
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/themes/dark.tcss
index 41c141bc8752..123cf359f33b 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/themes/dark.tcss
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/themes/dark.tcss
@@ -17,21 +17,25 @@
/* Camel TUI dark theme (default). Single source of color truth for Theme
tokens. */
$brand: #F69123;
+$dark-bg: #1E1E1E;
+$dark-fg: #D4D4D4;
#accent { color: $brand; }
#accent-bg { color: white; background: $brand; text-style: bold; }
#hint-key { color: black; background: $brand; text-style: bold; }
-#border { color: dark-gray; }
+#border { color: #505050; }
#border-focused { color: $brand; }
#title { color: $brand; text-style: bold; }
-#success { color: light-green; }
-#warning { color: light-yellow; }
-#error { color: light-red; }
-#muted { text-style: dim; }
-#selection { color: white; background: blue; text-style: bold; }
-#info { color: cyan; }
-#notice { color: magenta; }
-#row-alt { background: #1C1C1C; }
-#mcp-active { color: light-green; }
-#mcp-idle { color: dark-gray; }
-#mcp-down { color: light-red; }
+#success { color: #4EC9B0; }
+#warning { color: #DCDCAA; }
+#error { color: #F48771; }
+#muted { color: #808080; }
+#selection { color: white; background: #264F78; text-style: bold; }
+#info { color: #9CDCFE; }
+#notice { color: #C586C0; }
+#row-alt { background: #252525; }
+#mcp-active { color: #4EC9B0; }
+#mcp-idle { color: #606060; }
+#mcp-down { color: #F48771; }
+#base-bg { background: $dark-bg; }
+#base-fg { color: $dark-fg; }
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/themes/light.tcss
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/themes/light.tcss
index de7dee5b7a93..1cc88e46e3a4 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/themes/light.tcss
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/themes/light.tcss
@@ -17,21 +17,25 @@
/* Camel TUI light theme. Brand orange stays truecolor; status hues darkened
for light terminals. */
$brand: #F69123;
+$light-bg: #FFFFFF;
+$light-fg: #24292E;
#accent { color: $brand; }
#accent-bg { color: white; background: $brand; text-style: bold; }
#hint-key { color: black; background: $brand; text-style: bold; }
-#border { color: #888888; }
+#border { color: #C0C0C0; }
#border-focused { color: $brand; }
#title { color: $brand; text-style: bold; }
-#success { color: #007700; }
-#warning { color: #996600; }
-#error { color: #cc0000; }
-#muted { color: #666666; }
-#selection { color: white; background: blue; text-style: bold; }
-#info { color: #006688; }
-#notice { color: #884488; }
-#row-alt { background: #EBEBEB; }
-#mcp-active { color: #007700; }
-#mcp-idle { color: #888888; }
-#mcp-down { color: #cc0000; }
+#success { color: #22863A; }
+#warning { color: #B08800; }
+#error { color: #D73A49; }
+#muted { color: #6A737D; }
+#selection { color: white; background: #0366D6; text-style: bold; }
+#info { color: #0366D6; }
+#notice { color: #6F42C1; }
+#row-alt { background: #F6F8FA; }
+#mcp-active { color: #22863A; }
+#mcp-idle { color: #959DA5; }
+#mcp-down { color: #D73A49; }
+#base-bg { background: $light-bg; }
+#base-fg { color: $light-fg; }
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java
index 6573b8d00bd8..0f2d3b99ec57 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java
@@ -19,19 +19,49 @@ 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.buffer.Cell;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
import dev.tamboui.text.Span;
import dev.tamboui.tui.event.KeyCode;
import dev.tamboui.tui.event.KeyEvent;
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
+import picocli.CommandLine;
import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hint;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+@Isolated
class CamelMonitorTest {
+ @BeforeEach
+ void resetTheme() {
+ Theme.resetForTesting();
+ Theme.setMode("dark");
+ }
+
+ // --theme is validated at the very top of doCall(), before any
TUI/terminal setup, so an invalid value
+ // must be rejected immediately without needing a real terminal.
+
+ @Test
+ void doCallRejectsInvalidThemeBeforeAnyTuiSetup() {
+ CamelMonitor monitor = new CamelMonitor(new CamelJBangMain(),
Thread.currentThread().getContextClassLoader());
+ monitor.theme = "sepia";
+
+ CommandLine.ParameterException ex =
assertThrows(CommandLine.ParameterException.class, monitor::doCall,
+ "an invalid --theme value must be rejected before any
TUI/terminal setup runs");
+ assertTrue(ex.getMessage().contains("sepia"), "the rejected value
should be echoed back in the error message");
+ }
+
// '?' is an alternative to F1 for opening the help overlay. Unlike F1, it
must be suppressed
// while a text input is focused, otherwise typing '?' in a search or
probe field would
// pop up help instead of inserting the character.
@@ -199,4 +229,77 @@ class CamelMonitorTest {
assertEquals(-1, CamelMonitor.footerRegionAt(startX, endX, 18),
"column 18 is past the second region");
assertEquals(-1, CamelMonitor.footerRegionAt(null, endX, 3), "no
captured geometry yet");
}
+
+ // applyThemeBaseColors patches unset terminal cells with the active theme
canvas colors while
+ // preserving widget-specific foreground and background styles.
+
+ @Test
+ void applyThemeBaseColorsPatchesUnsetCells() {
+ Rect area = new Rect(0, 0, 4, 2);
+ Buffer buffer = Buffer.empty(area);
+
+ CamelMonitor.applyThemeBaseColors(buffer, area);
+
+ Cell cell = buffer.get(0, 0);
+ assertEquals(Theme.baseBg(), cell.style().bg().orElse(null), "unset
cells receive the theme background");
+ assertEquals(Theme.baseFg(), cell.style().fg().orElse(null), "unset
cells receive the theme foreground");
+ }
+
+ @Test
+ void applyThemeBaseColorsPreservesExplicitSelectionStyle() {
+ Rect area = new Rect(0, 0, 4, 2);
+ Buffer buffer = Buffer.empty(area);
+ Style selection = Theme.selectionBg();
+ buffer.setString(1, 0, "X", selection);
+
+ CamelMonitor.applyThemeBaseColors(buffer, area);
+
+ Cell cell = buffer.get(1, 0);
+ assertEquals(selection.bg().orElse(null),
cell.style().bg().orElse(null), "selection background is preserved");
+ assertEquals(selection.fg().orElse(null),
cell.style().fg().orElse(null), "selection foreground is preserved");
+ }
+
+ @Test
+ void applyThemeBaseColorsPatchesOnlyMissingBackground() {
+ Rect area = new Rect(0, 0, 4, 2);
+ Buffer buffer = Buffer.empty(area);
+ Color explicitFg = Color.rgb(0xFF, 0x00, 0x00);
+ buffer.setString(2, 0, "Y", Style.EMPTY.fg(explicitFg));
+
+ CamelMonitor.applyThemeBaseColors(buffer, area);
+
+ Cell cell = buffer.get(2, 0);
+ assertEquals(explicitFg, cell.style().fg().orElse(null), "explicit
foreground is preserved");
+ assertEquals(Theme.baseBg(), cell.style().bg().orElse(null), "missing
background is patched");
+ }
+
+ @Test
+ void applyThemeBaseColorsPatchesOnlyMissingForeground() {
+ Rect area = new Rect(0, 0, 4, 2);
+ Buffer buffer = Buffer.empty(area);
+ Color explicitBg = Color.rgb(0x00, 0x00, 0xFF);
+ buffer.setString(3, 0, "Z", Style.EMPTY.bg(explicitBg));
+
+ CamelMonitor.applyThemeBaseColors(buffer, area);
+
+ Cell cell = buffer.get(3, 0);
+ assertEquals(explicitBg, cell.style().bg().orElse(null), "explicit
background is preserved");
+ assertEquals(Theme.baseFg(), cell.style().fg().orElse(null), "missing
foreground is patched");
+ }
+
+ @Test
+ void applyThemeBaseColorsUsesActiveThemePalette() {
+ Rect area = new Rect(0, 0, 2, 1);
+ Buffer buffer = Buffer.empty(area);
+ Theme.setMode("dark");
+ Color darkBg = Theme.baseBg();
+
+ Theme.setMode("light");
+ CamelMonitor.applyThemeBaseColors(buffer, area);
+
+ Cell cell = buffer.get(0, 0);
+ assertEquals(Theme.baseBg(), cell.style().bg().orElse(null), "light
palette background is applied");
+ assertEquals(Theme.baseFg(), cell.style().fg().orElse(null), "light
palette foreground is applied");
+ assertFalse(darkBg.equals(cell.style().bg().orElse(null)), "patched
background must follow the active theme");
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeModeCompletionCandidatesTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeModeCompletionCandidatesTest.java
new file mode 100644
index 000000000000..266a0d083901
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeModeCompletionCandidatesTest.java
@@ -0,0 +1,35 @@
+/*
+ * 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 org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class ThemeModeCompletionCandidatesTest {
+
+ @Test
+ void offersDarkAndLightForShellCompletion() {
+ List<String> candidates = new ArrayList<>();
+ new ThemeModeCompletionCandidates().forEach(candidates::add);
+
+ assertEquals(List.of("dark", "light"), candidates);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeModeTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeModeTest.java
new file mode 100644
index 000000000000..e3a104eece5e
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeModeTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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.Optional;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+class ThemeModeTest {
+
+ @Test
+ void parseAcceptsCanonicalIds() {
+ assertEquals(Optional.of(ThemeMode.DARK), ThemeMode.parse("dark"));
+ assertEquals(Optional.of(ThemeMode.LIGHT), ThemeMode.parse("light"));
+ }
+
+ @Test
+ void parseIsCaseInsensitive() {
+ assertEquals(Optional.of(ThemeMode.LIGHT), ThemeMode.parse("LIGHT"));
+ assertEquals(Optional.of(ThemeMode.LIGHT), ThemeMode.parse("LiGhT"));
+ }
+
+ @Test
+ void parseStripsSurroundingWhitespace() {
+ assertEquals(Optional.of(ThemeMode.DARK), ThemeMode.parse(" dark "));
+ assertEquals(Optional.of(ThemeMode.DARK), ThemeMode.parse("\tdark\n"));
+ }
+
+ @Test
+ void parseRejectsUnknownOrNullValues() {
+ assertEquals(Optional.empty(), ThemeMode.parse("sepia"));
+ assertEquals(Optional.empty(), ThemeMode.parse(""));
+ assertEquals(Optional.empty(), ThemeMode.parse(null));
+ }
+
+ @Test
+ void parseOrDefaultFallsBackToDarkForUnknownValues() {
+ assertEquals(ThemeMode.DARK, ThemeMode.parseOrDefault("sepia"));
+ assertEquals(ThemeMode.DARK, ThemeMode.parseOrDefault(null));
+ assertEquals(ThemeMode.LIGHT, ThemeMode.parseOrDefault("light"));
+ }
+
+ @Test
+ void toggleFlipsBetweenTheTwoModes() {
+ assertEquals(ThemeMode.LIGHT, ThemeMode.DARK.toggle());
+ assertEquals(ThemeMode.DARK, ThemeMode.LIGHT.toggle());
+ }
+
+ @Test
+ void idsListsBothCanonicalValuesInDeclarationOrder() {
+ assertEquals(List.of("dark", "light"), ThemeMode.ids());
+ }
+
+ @Test
+ void stylesheetResourcePointsAtTheModeSpecificTcssFile() {
+ assertEquals("tui/themes/dark.tcss",
ThemeMode.DARK.stylesheetResource());
+ assertEquals("tui/themes/light.tcss",
ThemeMode.LIGHT.stylesheetResource());
+ }
+
+ @Test
+ void idIsDistinctLowercaseCanonicalFormPerMode() {
+ assertEquals("dark", ThemeMode.DARK.id());
+ assertEquals("light", ThemeMode.LIGHT.id());
+ assertNotEquals(ThemeMode.DARK.id(), ThemeMode.LIGHT.id());
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeTest.java
index 2416608df439..91c020f5371e 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ThemeTest.java
@@ -16,16 +16,24 @@
*/
package org.apache.camel.dsl.jbang.core.commands.tui;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
import dev.tamboui.style.Color;
import dev.tamboui.style.Style;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.parallel.Isolated;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
@Isolated
class ThemeTest {
@@ -55,20 +63,22 @@ class ThemeTest {
Theme.setMode("dark");
assertEquals(Style.EMPTY.fg(Color.WHITE).bg(Theme.ACCENT).bold(),
Theme.accentBg());
assertEquals(Style.EMPTY.fg(Color.BLACK).bg(Theme.ACCENT).bold(),
Theme.hintKey());
- assertEquals(Style.EMPTY.fg(Color.DARK_GRAY), Theme.border());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0x50, 0x50, 0x50)),
Theme.border());
assertEquals(Style.EMPTY.fg(Theme.ACCENT), Theme.borderFocused());
assertEquals(Style.EMPTY.fg(Theme.ACCENT).bold(), Theme.title());
- assertEquals(Style.EMPTY.fg(Color.LIGHT_GREEN), Theme.success());
- assertEquals(Style.EMPTY.fg(Color.LIGHT_YELLOW), Theme.warning());
- assertEquals(Style.EMPTY.fg(Color.LIGHT_RED), Theme.error());
- assertEquals(Style.EMPTY.dim(), Theme.muted());
- assertEquals(Style.EMPTY.fg(Color.WHITE).bold().onBlue(),
Theme.selectionBg());
- assertEquals(Style.EMPTY.fg(Color.CYAN), Theme.info());
- assertEquals(Style.EMPTY.fg(Color.MAGENTA), Theme.notice());
- assertEquals(Style.EMPTY.fg(Color.LIGHT_GREEN), Theme.mcpActive());
- assertEquals(Style.EMPTY.fg(Color.DARK_GRAY), Theme.mcpIdle());
- assertEquals(Style.EMPTY.fg(Color.LIGHT_RED), Theme.mcpDown());
- assertEquals(Color.rgb(0x1C, 0x1C, 0x1C), Theme.zebra());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0x4E, 0xC9, 0xB0)),
Theme.success());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0xDC, 0xDC, 0xAA)),
Theme.warning());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0xF4, 0x87, 0x71)),
Theme.error());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0x80, 0x80, 0x80)),
Theme.muted());
+ assertEquals(Style.EMPTY.fg(Color.WHITE).bg(Color.rgb(0x26, 0x4F,
0x78)).bold(), Theme.selectionBg());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0x9C, 0xDC, 0xFE)),
Theme.info());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0xC5, 0x86, 0xC0)),
Theme.notice());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0x4E, 0xC9, 0xB0)),
Theme.mcpActive());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0x60, 0x60, 0x60)),
Theme.mcpIdle());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0xF4, 0x87, 0x71)),
Theme.mcpDown());
+ assertEquals(Color.rgb(0x25, 0x25, 0x25), Theme.zebra());
+ assertEquals(Color.rgb(0x1E, 0x1E, 0x1E), Theme.baseBg());
+ assertEquals(Color.rgb(0xD4, 0xD4, 0xD4), Theme.baseFg());
}
@Test
@@ -76,14 +86,17 @@ class ThemeTest {
// Light theme: status hues are explicit dark hex; brand orange is
unchanged.
Theme.setMode("light");
assertEquals(Color.rgb(0xF6, 0x91, 0x23), Theme.accent());
- assertEquals(Style.EMPTY.fg(Color.rgb(0x00, 0x77, 0x00)),
Theme.success());
- assertEquals(Style.EMPTY.fg(Color.rgb(0x88, 0x88, 0x88)),
Theme.border());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0x22, 0x86, 0x3A)),
Theme.success());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0xC0, 0xC0, 0xC0)),
Theme.border());
// MCP indicator hues track the light palette: idle gray and down red
differ from the dark ANSI variants.
- assertEquals(Style.EMPTY.fg(Color.rgb(0x00, 0x77, 0x00)),
Theme.mcpActive());
- assertEquals(Style.EMPTY.fg(Color.rgb(0x88, 0x88, 0x88)),
Theme.mcpIdle());
- assertEquals(Style.EMPTY.fg(Color.rgb(0xcc, 0x00, 0x00)),
Theme.mcpDown());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0x22, 0x86, 0x3A)),
Theme.mcpActive());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0x95, 0x9D, 0xA5)),
Theme.mcpIdle());
+ assertEquals(Style.EMPTY.fg(Color.rgb(0xD7, 0x3A, 0x49)),
Theme.mcpDown());
// Zebra background is theme-aware: light gray on light, unlike the
dark gray used on dark.
- assertEquals(Color.rgb(0xEB, 0xEB, 0xEB), Theme.zebra());
+ assertEquals(Color.rgb(0xF6, 0xF8, 0xFA), Theme.zebra());
+ // Base colors differ significantly between themes: white background
and dark text on light mode.
+ assertEquals(Color.rgb(0xFF, 0xFF, 0xFF), Theme.baseBg());
+ assertEquals(Color.rgb(0x24, 0x29, 0x2E), Theme.baseFg());
}
@Test
@@ -108,6 +121,111 @@ class ThemeTest {
assertEquals("dark", Theme.mode());
}
+ @Test
+ void applyStartupModeSelectsLightPalette() {
+ Theme.setMode("dark");
+ Theme.applyStartupMode("light");
+ assertEquals("light", Theme.mode());
+ assertEquals(Color.rgb(0xFF, 0xFF, 0xFF), Theme.baseBg());
+ }
+
+ @Test
+ void applyStartupModeAcceptsMixedCase() {
+ Theme.applyStartupMode("LIGHT");
+ assertEquals("light", Theme.mode());
+ }
+
+ @Test
+ void applyStartupModeRejectsUnknownValue() {
+ // Unlike setMode(), an invalid --theme value must never be silently
coerced to a default: CamelMonitor relies
+ // on isValidMode() for a friendly CLI error, but the type itself must
also refuse to guess.
+ assertThrows(IllegalArgumentException.class, () ->
Theme.applyStartupMode("sepia"));
+ }
+
+ @Test
+ void setModeAcceptsMixedCase() {
+ Theme.setMode("LIGHT");
+ assertEquals("light", Theme.mode());
+ }
+
+ @Test
+ void isValidModeAcceptsDarkAndLightOnly() {
+ assertTrue(Theme.isValidMode("dark"));
+ assertTrue(Theme.isValidMode("LIGHT"));
+ assertFalse(Theme.isValidMode("sepia"));
+ assertFalse(Theme.isValidMode(null));
+ }
+
+ @Test
+ void toggleTakesEffectOnFirstActivationOutsideTestMode(@TempDir Path
tempDir) {
+ // Regression test for the "toggle needs two activations" bug: every
other test here runs through
+ // Theme.resetForTesting(), which forces testMode=true and
short-circuits both persist() and the
+ // persisted-mode disk-reload guard in engine() - exactly the two
paths responsible for the original bug.
+ // This test leaves testMode disabled, against an isolated home
directory, to actually exercise them.
+ String originalHomeDir = CommandLineHelper.getHomeDir().toString();
+ CommandLineHelper.useHomeDir(tempDir.toString());
+ try {
+ Theme.resetForTesting(false);
+ Theme.setMode("dark");
+ assertEquals("dark", Theme.mode());
+
+ String newMode = Theme.toggle();
+
+ assertEquals("light", newMode, "a single toggle() call must flip
the mode immediately");
+ assertEquals("light", Theme.mode(), "the effective mode must
reflect the toggle without a second activation");
+ } finally {
+ CommandLineHelper.useHomeDir(originalHomeDir);
+ Theme.resetForTesting();
+ }
+ }
+
+ @Test
+ void persistedModeReadFailureFallsBackAndRetriesOnNextInit(@TempDir Path
tempDir) throws Exception {
+ // Regression test for the read-failure branch of
tryLoadPersistedMode(): a corrupt config file must not crash
+ // the TUI, and must not permanently pin the session to the default
theme - the very next reinitialization
+ // (triggered here by setMode()) should retry the disk read and pick
up a since-fixed value.
+ String originalHomeDir = CommandLineHelper.getHomeDir().toString();
+ CommandLineHelper.useHomeDir(tempDir.toString());
+ try {
+ Path configFile = tempDir.resolve(CommandLineHelper.USER_CONFIG);
+ Files.writeString(configFile, "camel.tui.theme=\\uZZZZ\n");
+
+ Theme.resetForTesting(false);
+ assertEquals("dark", Theme.mode(), "a corrupt config file must
fall back to the default theme, not crash");
+
+ Files.writeString(configFile, "camel.tui.theme=light\n");
+ Theme.setMode("dark");
+
+ assertEquals("light", Theme.mode(),
+ "a later reinitialization must retry the persisted-mode
read instead of being pinned by the earlier failure");
+ } finally {
+ CommandLineHelper.useHomeDir(originalHomeDir);
+ Theme.resetForTesting();
+ }
+ }
+
+ @Test
+ void applyStartupModeOverridesPersistedConfigOutsideTestMode(@TempDir Path
tempDir) throws Exception {
+ // Regression test for the "--theme wins over camel.tui.theme"
contract: exercised against real disk I/O
+ // (outside test mode), unlike applyStartupModeSelectsLightPalette
which runs under testMode=true, where
+ // persistence loading is unconditionally skipped and so can't prove
an override actually happened.
+ String originalHomeDir = CommandLineHelper.getHomeDir().toString();
+ CommandLineHelper.useHomeDir(tempDir.toString());
+ try {
+ Path configFile = tempDir.resolve(CommandLineHelper.USER_CONFIG);
+ Files.writeString(configFile, "camel.tui.theme=light\n");
+
+ Theme.resetForTesting(false);
+ Theme.applyStartupMode("dark");
+
+ assertEquals("dark", Theme.mode(), "--theme must win over a
persisted camel.tui.theme value");
+ assertEquals(Color.rgb(0x1E, 0x1E, 0x1E), Theme.baseBg(), "the
dark palette must actually be active");
+ } finally {
+ CommandLineHelper.useHomeDir(originalHomeDir);
+ Theme.resetForTesting();
+ }
+ }
+
@Test
void accessorsNeverReturnNull() {
// Resilience: even when resolution is exercised the palette is always
usable.
@@ -128,5 +246,7 @@ class ThemeTest {
assertNotNull(Theme.mcpIdle());
assertNotNull(Theme.mcpDown());
assertNotNull(Theme.zebra());
+ assertNotNull(Theme.baseBg());
+ assertNotNull(Theme.baseFg());
}
}