From 28d721d0450c48c277a6115594cc78bd2814b747 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 25 Jun 2026 16:17:31 +0100 Subject: [PATCH 1/6] chore(tui): upgrade tamboui to 0.4.0 --- docs/modules/ROOT/pages/architecture.adoc | 5 +- pom.xml | 67 +++----------- .../runtime/AppBindingsProfile.java | 30 ++++--- .../quarkusforge/ui/AppKeyActions.java | 21 ++--- .../quarkusforge/ui/CoreTuiController.java | 16 +++- .../quarkusforge/ui/CoreUiReducer.java | 55 ++++++------ .../ui/ExtensionCatalogNavigation.java | 8 +- .../ayagmar/quarkusforge/ui/InputEffects.java | 43 +++------ .../ui/PostGenerationMenuState.java | 24 +++-- .../quarkusforge/ui/UiKeyMatchers.java | 46 ---------- .../quarkusforge/ui/UiTextInputKeys.java | 21 ----- .../QuarkusForgeCliBindingsProfileTest.java | 90 ++++++++++++------- .../ui/CoreTuiShellPilotTest.java | 33 ++++--- .../quarkusforge/ui/CoreUiReducerTest.java | 12 ++- .../ui/ExtensionCatalogNavigationTest.java | 11 ++- .../quarkusforge/ui/UiKeyMatchersTest.java | 40 --------- 16 files changed, 205 insertions(+), 317 deletions(-) delete mode 100644 src/main/java/dev/ayagmar/quarkusforge/ui/UiKeyMatchers.java delete mode 100644 src/main/java/dev/ayagmar/quarkusforge/ui/UiTextInputKeys.java delete mode 100644 src/test/java/dev/ayagmar/quarkusforge/ui/UiKeyMatchersTest.java diff --git a/docs/modules/ROOT/pages/architecture.adoc b/docs/modules/ROOT/pages/architecture.adoc index b555e14c..9cbd72c7 100644 --- a/docs/modules/ROOT/pages/architecture.adoc +++ b/docs/modules/ROOT/pages/architecture.adoc @@ -113,7 +113,7 @@ Startup policy now lives under `application/`: `LiveStartupMetadataLoader` owns == UI Layer Design -The TUI layer uses an immediate-mode rendering architecture built on the https://github.com/ayagmar/tamboui[TamboUI] framework. +The TUI layer uses an immediate-mode rendering architecture built on the https://github.com/tamboui/tamboui[TamboUI] framework. === Component Decomposition @@ -172,8 +172,7 @@ CoreTuiController (Orchestration Shell) ├── UiTextConstants UI text content ├── CatalogLoadCoordinator Async catalog lifecycle + startup-overlay timing via reducer intents ├── GenerationFlowCoordinator Async generation lifecycle - ├── GenerationStateMachine Allowed generation-state transitions - └── UiTextInputKeys Text-input edit-key normalization + └── GenerationStateMachine Allowed generation-state transitions .... Reducer-visible UI semantics are now converged for form and extension read-model state. `ProjectRequest`, `ValidationReport`, extension search/filter/read-model summaries, catalog lifecycle semantics, and overlay visibility flags update through reducer intents/effects and live in reducer-owned `UiState`. `UiRenderModel` is assembled immediately before render and carries the derived `metadataPanel`, `extensionsPanel`, `footer`, `generation`, and `startupOverlay` slices. Mutable widget internals such as `TextInputState` and `ListState` stay in adapter/runtime context rather than `UiState`. diff --git a/pom.xml b/pom.xml index 2cb3f4f1..0b7662f0 100644 --- a/pom.xml +++ b/pom.xml @@ -16,12 +16,7 @@ dev/tamboui/tui/bindings/.*\\.properties$ - 0.3.0 - 0.3.0 - ${tamboui.core.version} - ${tamboui.core.version} - 0.3.0 - ${tamboui.tui.version} + 0.4.0 4.7.7 2.21.3 6.0.3 @@ -47,51 +42,14 @@ true - - - ossrh-snapshots - https://central.sonatype.com/repository/maven-snapshots/ - - false - - - true - never - - - - dev.tamboui - tamboui-annotations - ${tamboui.annotations.version} - - - dev.tamboui - tamboui-core - ${tamboui.core.version} - - - dev.tamboui - tamboui-panama-backend - ${tamboui.panama-backend.version} - - - dev.tamboui - tamboui-jline3-backend - ${tamboui.jline3-backend.version} - - - dev.tamboui - tamboui-tui - ${tamboui.tui.version} - - - dev.tamboui - tamboui-widgets - ${tamboui.widgets.version} + tamboui-bom + ${tamboui.version} + pom + import @@ -100,18 +58,19 @@ dev.tamboui tamboui-tui - ${tamboui.tui.version} + + + dev.tamboui + tamboui-toolkit dev.tamboui tamboui-panama-backend - ${tamboui.panama-backend.version} runtime dev.tamboui tamboui-jline3-backend - ${tamboui.jline3-backend.version} runtime @@ -447,19 +406,21 @@ dev.tamboui tamboui-tui - ${tamboui.tui.version} + provided + + + dev.tamboui + tamboui-toolkit provided dev.tamboui tamboui-panama-backend - ${tamboui.panama-backend.version} provided dev.tamboui tamboui-jline3-backend - ${tamboui.jline3-backend.version} provided diff --git a/src/main/java/dev/ayagmar/quarkusforge/runtime/AppBindingsProfile.java b/src/main/java/dev/ayagmar/quarkusforge/runtime/AppBindingsProfile.java index 37fee4dc..05c8833b 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/runtime/AppBindingsProfile.java +++ b/src/main/java/dev/ayagmar/quarkusforge/runtime/AppBindingsProfile.java @@ -1,7 +1,6 @@ package dev.ayagmar.quarkusforge.runtime; import dev.ayagmar.quarkusforge.ui.AppKeyActions; -import dev.tamboui.tui.bindings.Actions; import dev.tamboui.tui.bindings.BindingSets; import dev.tamboui.tui.bindings.Bindings; import dev.tamboui.tui.bindings.KeyTrigger; @@ -10,29 +9,38 @@ final class AppBindingsProfile { private AppBindingsProfile() {} static Bindings bindings() { - return BindingSets.standard().toBuilder() - .bind(KeyTrigger.ch('j'), Actions.MOVE_DOWN) - .bind(KeyTrigger.ch('k'), Actions.MOVE_UP) - .bind(KeyTrigger.ch('h'), Actions.MOVE_LEFT) - .bind(KeyTrigger.ch('l'), Actions.MOVE_RIGHT) - .bind(KeyTrigger.ch('g'), Actions.HOME) - .bind(KeyTrigger.ch('G'), Actions.END) + return BindingSets.vim().toBuilder() .bind(KeyTrigger.ch('/'), AppKeyActions.FOCUS_EXTENSION_SEARCH) .bind(KeyTrigger.ctrl('f'), AppKeyActions.FOCUS_EXTENSION_SEARCH) + .bind(KeyTrigger.ctrl('F'), AppKeyActions.FOCUS_EXTENSION_SEARCH) .bind(KeyTrigger.ctrl('l'), AppKeyActions.FOCUS_EXTENSION_LIST) + .bind(KeyTrigger.ctrl('L'), AppKeyActions.FOCUS_EXTENSION_LIST) .bind(KeyTrigger.ctrl('k'), AppKeyActions.TOGGLE_FAVORITES_FILTER) + .bind(KeyTrigger.ctrl('K'), AppKeyActions.TOGGLE_FAVORITES_FILTER) .bind(KeyTrigger.ctrl('y'), AppKeyActions.CYCLE_PRESET_FILTER) + .bind(KeyTrigger.ctrl('Y'), AppKeyActions.CYCLE_PRESET_FILTER) .bind(KeyTrigger.ctrl('j'), AppKeyActions.JUMP_TO_FAVORITE) + .bind(KeyTrigger.ctrl('J'), AppKeyActions.JUMP_TO_FAVORITE) .bind(KeyTrigger.ctrl('r'), AppKeyActions.RELOAD_CATALOG) + .bind(KeyTrigger.ctrl('R'), AppKeyActions.RELOAD_CATALOG) .bind(KeyTrigger.ctrl('e'), AppKeyActions.TOGGLE_ERROR_DETAILS) - .bind(KeyTrigger.ch('v'), AppKeyActions.CATEGORY_FILTER_CYCLE) + .bind(KeyTrigger.ctrl('E'), AppKeyActions.TOGGLE_ERROR_DETAILS) + .bind(KeyTrigger.chIgnoreCase('v'), AppKeyActions.CATEGORY_FILTER_CYCLE) .bind(KeyTrigger.ch('c'), AppKeyActions.TOGGLE_CATEGORY) .bind(KeyTrigger.ch('C'), AppKeyActions.OPEN_ALL_CATEGORIES) - .bind(KeyTrigger.ch('x'), AppKeyActions.CLEAR_SELECTED_EXTENSIONS) - .bind(KeyTrigger.ch('f'), AppKeyActions.FAVORITE_TOGGLE) + .bind(KeyTrigger.chIgnoreCase('x'), AppKeyActions.CLEAR_SELECTED_EXTENSIONS) + .bind(KeyTrigger.chIgnoreCase('f'), AppKeyActions.FAVORITE_TOGGLE) .bind(KeyTrigger.ch('?'), AppKeyActions.OPEN_HELP) .bind(KeyTrigger.ctrl('p'), AppKeyActions.OPEN_COMMAND_PALETTE) + .bind(KeyTrigger.ctrl('P'), AppKeyActions.OPEN_COMMAND_PALETTE) .bind(KeyTrigger.alt('g'), AppKeyActions.SUBMIT_GENERATION) + .bind(KeyTrigger.alt('G'), AppKeyActions.SUBMIT_GENERATION) + .bind(KeyTrigger.alt('s'), AppKeyActions.TOGGLE_SELECTED_FILTER) + .bind(KeyTrigger.alt('S'), AppKeyActions.TOGGLE_SELECTED_FILTER) + .bind(KeyTrigger.alt('n'), AppKeyActions.NEXT_INVALID_FIELD) + .bind(KeyTrigger.alt('N'), AppKeyActions.NEXT_INVALID_FIELD) + .bind(KeyTrigger.alt('p'), AppKeyActions.PREVIOUS_INVALID_FIELD) + .bind(KeyTrigger.alt('P'), AppKeyActions.PREVIOUS_INVALID_FIELD) .build(); } } diff --git a/src/main/java/dev/ayagmar/quarkusforge/ui/AppKeyActions.java b/src/main/java/dev/ayagmar/quarkusforge/ui/AppKeyActions.java index abfdd5e1..bedd803f 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/ui/AppKeyActions.java +++ b/src/main/java/dev/ayagmar/quarkusforge/ui/AppKeyActions.java @@ -1,6 +1,5 @@ package dev.ayagmar.quarkusforge.ui; -import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; public final class AppKeyActions { @@ -84,10 +83,7 @@ static boolean isErrorDetailsToggleKey(KeyEvent keyEvent) { } static boolean isFocusExtensionSearchSlashKey(KeyEvent keyEvent) { - return keyEvent.code() == KeyCode.CHAR - && !keyEvent.hasCtrl() - && !keyEvent.hasAlt() - && keyEvent.character() == '/'; + return !keyEvent.hasCtrl() && !keyEvent.hasAlt() && keyEvent.isChar('/'); } static boolean isFocusExtensionSearchCtrlKey(KeyEvent keyEvent) { @@ -107,25 +103,20 @@ static boolean isPreviousInvalidFieldKey(KeyEvent keyEvent) { } private static boolean hasAction(KeyEvent keyEvent, String action) { - return keyEvent.action().filter(action::equals).isPresent(); + return keyEvent.matches(action); } private static boolean isPlainChar(KeyEvent keyEvent, char lower, char upper) { - return keyEvent.code() == KeyCode.CHAR - && !keyEvent.hasCtrl() + return !keyEvent.hasCtrl() && !keyEvent.hasAlt() - && (keyEvent.character() == lower || keyEvent.character() == upper); + && (keyEvent.isChar(lower) || keyEvent.isChar(upper)); } private static boolean isCtrlChar(KeyEvent keyEvent, char lower, char upper) { - return keyEvent.code() == KeyCode.CHAR - && keyEvent.hasCtrl() - && (keyEvent.character() == lower || keyEvent.character() == upper); + return keyEvent.hasCtrl() && (keyEvent.isChar(lower) || keyEvent.isChar(upper)); } private static boolean isAltChar(KeyEvent keyEvent, char lower, char upper) { - return keyEvent.code() == KeyCode.CHAR - && keyEvent.hasAlt() - && (keyEvent.character() == lower || keyEvent.character() == upper); + return keyEvent.hasAlt() && (keyEvent.isChar(lower) || keyEvent.isChar(upper)); } } diff --git a/src/main/java/dev/ayagmar/quarkusforge/ui/CoreTuiController.java b/src/main/java/dev/ayagmar/quarkusforge/ui/CoreTuiController.java index 52d3a563..b2c3d085 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/ui/CoreTuiController.java +++ b/src/main/java/dev/ayagmar/quarkusforge/ui/CoreTuiController.java @@ -17,6 +17,7 @@ import dev.ayagmar.quarkusforge.util.OutputPathResolver; import dev.tamboui.terminal.Frame; import dev.tamboui.tui.event.Event; +import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; import dev.tamboui.tui.event.ResizeEvent; import dev.tamboui.tui.event.TickEvent; @@ -797,12 +798,12 @@ public UiAction handleCommandPaletteKey(KeyEvent keyEvent) { return routeIntent( new UiIntent.CommandPaletteIntent(new UiIntent.CommandPaletteCommand.Dismiss())); } - if (keyEvent.isUp() || UiKeyMatchers.isVimUpKey(keyEvent)) { + if (keyEvent.isUp()) { dispatchIntent( new UiIntent.CommandPaletteIntent(new UiIntent.CommandPaletteCommand.MoveSelection(-1))); return UiAction.handled(false); } - if (keyEvent.isDown() || UiKeyMatchers.isVimDownKey(keyEvent)) { + if (keyEvent.isDown()) { dispatchIntent( new UiIntent.CommandPaletteIntent(new UiIntent.CommandPaletteCommand.MoveSelection(1))); return UiAction.handled(false); @@ -817,8 +818,8 @@ public UiAction handleCommandPaletteKey(KeyEvent keyEvent) { new UiIntent.CommandPaletteIntent(new UiIntent.CommandPaletteCommand.JumpEnd())); return UiAction.handled(false); } - if (UiKeyMatchers.isDigitKey(keyEvent)) { - int selected = Character.digit(keyEvent.character(), 10) - 1; + if (isDigitKey(keyEvent)) { + int selected = Character.digit(keyEvent.codePoint(), 10) - 1; if (selected >= 0 && selected < UiTextConstants.COMMAND_PALETTE_ENTRIES.size()) { dispatchIntent( new UiIntent.CommandPaletteIntent( @@ -836,6 +837,13 @@ public UiAction handleCommandPaletteKey(KeyEvent keyEvent) { return UiAction.handled(false); } + private static boolean isDigitKey(KeyEvent keyEvent) { + return keyEvent.code() == KeyCode.CHAR + && !keyEvent.hasCtrl() + && !keyEvent.hasAlt() + && Character.isDigit(keyEvent.codePoint()); + } + @Override public UiAction handlePostGenerationMenuKey(KeyEvent keyEvent) { UiIntent.PostGenerationCommand command = diff --git a/src/main/java/dev/ayagmar/quarkusforge/ui/CoreUiReducer.java b/src/main/java/dev/ayagmar/quarkusforge/ui/CoreUiReducer.java index 4ae8806a..1e9c390e 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/ui/CoreUiReducer.java +++ b/src/main/java/dev/ayagmar/quarkusforge/ui/CoreUiReducer.java @@ -541,12 +541,10 @@ && isUpNavigation(keyEvent) && state.extensions().listSelectionAtTop()) { return reduceSharedAction(state, CommandPaletteAction.FOCUS_EXTENSION_SEARCH); } - if (focusTarget == FocusTarget.EXTENSION_LIST - && (keyEvent.isLeft() || UiKeyMatchers.isVimLeftKey(keyEvent))) { + if (focusTarget == FocusTarget.EXTENSION_LIST && keyEvent.isLeft()) { return reduceExtensionCommand(state, UiIntent.ExtensionCommand.HIERARCHY_LEFT); } - if (focusTarget == FocusTarget.EXTENSION_LIST - && (keyEvent.isRight() || UiKeyMatchers.isVimRightKey(keyEvent))) { + if (focusTarget == FocusTarget.EXTENSION_LIST && keyEvent.isRight()) { return reduceExtensionCommand(state, UiIntent.ExtensionCommand.HIERARCHY_RIGHT); } if (focusTarget == FocusTarget.EXTENSION_LIST && keyEvent.isPageDown()) { @@ -649,10 +647,10 @@ private static ReduceResult reduceFocusNavigation(UiState state, KeyEvent keyEve return moveFocus(state, focusTarget, 1); } if (focusTarget == FocusTarget.SUBMIT) { - if (UiKeyMatchers.isVimDownKey(keyEvent)) { + if (keyEvent.isDown()) { return moveFocus(state, focusTarget, 1); } - if (UiKeyMatchers.isVimUpKey(keyEvent)) { + if (keyEvent.isUp()) { return moveFocus(state, focusTarget, -1); } } @@ -715,18 +713,11 @@ private static UiState focusExtensionListForSharedAction(UiState state) { } private static boolean isExtensionNavigationKey(KeyEvent keyEvent) { - return keyEvent.isUp() - || UiKeyMatchers.isVimUpKey(keyEvent) - || keyEvent.isDown() - || UiKeyMatchers.isVimDownKey(keyEvent) - || keyEvent.isHome() - || UiKeyMatchers.isVimHomeKey(keyEvent) - || keyEvent.isEnd() - || UiKeyMatchers.isVimEndKey(keyEvent); + return keyEvent.isUp() || keyEvent.isDown() || keyEvent.isHome() || keyEvent.isEnd(); } private static boolean isUpNavigation(KeyEvent keyEvent) { - return keyEvent.isUp() || UiKeyMatchers.isVimUpKey(keyEvent); + return keyEvent.isUp(); } private static ReduceResult reduceMetadataInput( @@ -736,16 +727,7 @@ private static ReduceResult reduceMetadataInput( if (!MetadataSelectorManager.isSelectorFocus(focusTarget)) { return new ReduceResult(state, List.of(), UiAction.ignored()); } - if (!keyEvent.isLeft() - && !UiKeyMatchers.isVimLeftKey(keyEvent) - && !keyEvent.isUp() - && !UiKeyMatchers.isVimUpKey(keyEvent) - && !keyEvent.isRight() - && !UiKeyMatchers.isVimRightKey(keyEvent) - && !keyEvent.isDown() - && !UiKeyMatchers.isVimDownKey(keyEvent) - && !keyEvent.isHome() - && !keyEvent.isEnd()) { + if (!isMetadataSelectionKey(keyEvent)) { return new ReduceResult(state, List.of(), UiAction.ignored()); } if (!metadataIntent.optionsAvailable()) { @@ -759,8 +741,7 @@ private static ReduceResult reduceMetadataInput( private static ReduceResult reduceTextInput(UiState state, KeyEvent keyEvent) { FocusTarget focusTarget = state.focusTarget(); - if (!UiFocusPredicates.isTextInputFocus(focusTarget) - || !UiTextInputKeys.isSupportedEditKey(keyEvent)) { + if (!UiFocusPredicates.isTextInputFocus(focusTarget) || !isTextInputEditKey(keyEvent)) { return new ReduceResult(state, List.of(), UiAction.ignored()); } return new ReduceResult( @@ -769,6 +750,26 @@ private static ReduceResult reduceTextInput(UiState state, KeyEvent keyEvent) { UiAction.handled(false)); } + private static boolean isMetadataSelectionKey(KeyEvent keyEvent) { + return keyEvent.isLeft() + || keyEvent.isUp() + || keyEvent.isRight() + || keyEvent.isDown() + || keyEvent.isHome() + || keyEvent.isEnd(); + } + + private static boolean isTextInputEditKey(KeyEvent keyEvent) { + if (keyEvent.code() == KeyCode.CHAR) { + int codePoint = keyEvent.codePoint(); + return !keyEvent.hasCtrl() && !keyEvent.hasAlt() && codePoint >= 32 && codePoint != 127; + } + return switch (keyEvent.code()) { + case BACKSPACE, DELETE, LEFT, RIGHT, HOME, END -> true; + default -> false; + }; + } + private static ReduceResult reducePostGeneration( UiState state, UiIntent.PostGenerationCommand command) { UiState.PostGenerationView postGeneration = state.postGeneration(); diff --git a/src/main/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogNavigation.java b/src/main/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogNavigation.java index 26e518b0..12d131d1 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogNavigation.java +++ b/src/main/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogNavigation.java @@ -193,19 +193,19 @@ boolean handleNavigationKey(ExtensionCatalogRows rows, KeyEvent keyEvent) { if (navigationRowIndexes.isEmpty()) { return false; } - if (keyEvent.isUp() || UiKeyMatchers.isVimUpKey(keyEvent)) { + if (keyEvent.isUp()) { selectPrevious(navigationRowIndexes); return true; } - if (keyEvent.isDown() || UiKeyMatchers.isVimDownKey(keyEvent)) { + if (keyEvent.isDown()) { selectNext(navigationRowIndexes); return true; } - if (keyEvent.isHome() || UiKeyMatchers.isVimHomeKey(keyEvent)) { + if (keyEvent.isHome()) { listState.select(navigationRowIndexes.getFirst()); return true; } - if (keyEvent.isEnd() || UiKeyMatchers.isVimEndKey(keyEvent)) { + if (keyEvent.isEnd()) { listState.select(navigationRowIndexes.getLast()); return true; } diff --git a/src/main/java/dev/ayagmar/quarkusforge/ui/InputEffects.java b/src/main/java/dev/ayagmar/quarkusforge/ui/InputEffects.java index 0edc887c..d3971c85 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/ui/InputEffects.java +++ b/src/main/java/dev/ayagmar/quarkusforge/ui/InputEffects.java @@ -4,7 +4,7 @@ import dev.ayagmar.quarkusforge.domain.CliPrefillMapper; import dev.ayagmar.quarkusforge.domain.ProjectRequest; import dev.ayagmar.quarkusforge.domain.ValidationReport; -import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.toolkit.Toolkit; import dev.tamboui.tui.event.KeyEvent; import dev.tamboui.widgets.input.TextInputState; import java.util.EnumMap; @@ -64,16 +64,10 @@ void moveTextInputCursorToEnd(FocusTarget target) { } List applyMetadataSelectorKey(FocusTarget target, KeyEvent keyEvent) { - if (keyEvent.isLeft() - || UiKeyMatchers.isVimLeftKey(keyEvent) - || keyEvent.isUp() - || UiKeyMatchers.isVimUpKey(keyEvent)) { + if (keyEvent.isLeft() || keyEvent.isUp()) { return applySelectorCycle(target, -1); } - if (keyEvent.isRight() - || UiKeyMatchers.isVimRightKey(keyEvent) - || keyEvent.isDown() - || UiKeyMatchers.isVimDownKey(keyEvent)) { + if (keyEvent.isRight() || keyEvent.isDown()) { return applySelectorCycle(target, 1); } if (keyEvent.isHome()) { @@ -200,35 +194,18 @@ private static String selectorValue(ProjectRequest request, FocusTarget target) } private static TextInputEffectResult handleTextInputKey(TextInputState state, KeyEvent event) { - if (state == null || !UiTextInputKeys.isSupportedEditKey(event)) { + if (state == null) { return TextInputEffectResult.IGNORED; } - if (event.code() == KeyCode.CHAR) { - state.insert(event.character()); - return TextInputEffectResult.MUTATED; - } - if (event.isDeleteBackward()) { - state.deleteBackward(); - return TextInputEffectResult.MUTATED; + String previousText = state.text(); + int previousCursorPosition = state.cursorPosition(); + if (!Toolkit.handleTextInputKey(state, event)) { + return TextInputEffectResult.IGNORED; } - if (event.isDeleteForward()) { - state.deleteForward(); + if (!state.text().equals(previousText)) { return TextInputEffectResult.MUTATED; } - if (event.isLeft()) { - state.moveCursorLeft(); - return TextInputEffectResult.CURSOR_MOVED; - } - if (event.isRight()) { - state.moveCursorRight(); - return TextInputEffectResult.CURSOR_MOVED; - } - if (event.isHome()) { - state.moveCursorToStart(); - return TextInputEffectResult.CURSOR_MOVED; - } - if (event.isEnd()) { - state.moveCursorToEnd(); + if (state.cursorPosition() != previousCursorPosition) { return TextInputEffectResult.CURSOR_MOVED; } return TextInputEffectResult.IGNORED; diff --git a/src/main/java/dev/ayagmar/quarkusforge/ui/PostGenerationMenuState.java b/src/main/java/dev/ayagmar/quarkusforge/ui/PostGenerationMenuState.java index 94277433..9daf7893 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/ui/PostGenerationMenuState.java +++ b/src/main/java/dev/ayagmar/quarkusforge/ui/PostGenerationMenuState.java @@ -1,5 +1,6 @@ package dev.ayagmar.quarkusforge.ui; +import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; import java.util.List; @@ -36,10 +37,10 @@ UiIntent.PostGenerationCommand handleKey(UiState.PostGenerationView view, KeyEve if (keyEvent.isCancel()) { return new UiIntent.PostGenerationCommand.Quit(); } - if (keyEvent.isUp() || UiKeyMatchers.isVimUpKey(keyEvent)) { + if (keyEvent.isUp()) { return new UiIntent.PostGenerationCommand.MoveActionSelection(-1); } - if (keyEvent.isDown() || UiKeyMatchers.isVimDownKey(keyEvent)) { + if (keyEvent.isDown()) { return new UiIntent.PostGenerationCommand.MoveActionSelection(1); } if (keyEvent.isFocusPrevious()) { @@ -48,8 +49,8 @@ UiIntent.PostGenerationCommand handleKey(UiState.PostGenerationView view, KeyEve if (keyEvent.isFocusNext()) { return new UiIntent.PostGenerationCommand.MoveActionSelection(1); } - if (UiKeyMatchers.isDigitKey(keyEvent)) { - int selected = Character.digit(keyEvent.character(), 10) - 1; + if (isDigitKey(keyEvent)) { + int selected = Character.digit(keyEvent.codePoint(), 10) - 1; if (selected >= 0 && selected < view.actions().size()) { return new UiIntent.PostGenerationCommand.SelectActionIndex(selected); } @@ -68,10 +69,10 @@ private UiIntent.PostGenerationCommand handleGithubVisibilityKey(KeyEvent keyEve if (keyEvent.isCancel()) { return new UiIntent.PostGenerationCommand.CancelGithubVisibility(); } - if (keyEvent.isUp() || UiKeyMatchers.isVimUpKey(keyEvent)) { + if (keyEvent.isUp()) { return new UiIntent.PostGenerationCommand.MoveGithubVisibilitySelection(-1); } - if (keyEvent.isDown() || UiKeyMatchers.isVimDownKey(keyEvent)) { + if (keyEvent.isDown()) { return new UiIntent.PostGenerationCommand.MoveGithubVisibilitySelection(1); } if (keyEvent.isFocusPrevious()) { @@ -80,8 +81,8 @@ private UiIntent.PostGenerationCommand handleGithubVisibilityKey(KeyEvent keyEve if (keyEvent.isFocusNext()) { return new UiIntent.PostGenerationCommand.MoveGithubVisibilitySelection(1); } - if (UiKeyMatchers.isDigitKey(keyEvent)) { - int selected = Character.digit(keyEvent.character(), 10) - 1; + if (isDigitKey(keyEvent)) { + int selected = Character.digit(keyEvent.codePoint(), 10) - 1; if (selected >= 0 && selected < UiTextConstants.GITHUB_VISIBILITY_LABELS.size()) { return new UiIntent.PostGenerationCommand.SelectGithubVisibilityIndex(selected); } @@ -92,4 +93,11 @@ private UiIntent.PostGenerationCommand handleGithubVisibilityKey(KeyEvent keyEve } return new UiIntent.PostGenerationCommand.Noop(); } + + private static boolean isDigitKey(KeyEvent keyEvent) { + return keyEvent.code() == KeyCode.CHAR + && !keyEvent.hasCtrl() + && !keyEvent.hasAlt() + && Character.isDigit(keyEvent.codePoint()); + } } diff --git a/src/main/java/dev/ayagmar/quarkusforge/ui/UiKeyMatchers.java b/src/main/java/dev/ayagmar/quarkusforge/ui/UiKeyMatchers.java deleted file mode 100644 index df86e12e..00000000 --- a/src/main/java/dev/ayagmar/quarkusforge/ui/UiKeyMatchers.java +++ /dev/null @@ -1,46 +0,0 @@ -package dev.ayagmar.quarkusforge.ui; - -import dev.tamboui.tui.event.KeyCode; -import dev.tamboui.tui.event.KeyEvent; - -final class UiKeyMatchers { - private UiKeyMatchers() {} - - static boolean isVimUpKey(KeyEvent keyEvent) { - return isPlainChar(keyEvent, 'k', 'K'); - } - - static boolean isVimDownKey(KeyEvent keyEvent) { - return isPlainChar(keyEvent, 'j', 'J'); - } - - static boolean isVimLeftKey(KeyEvent keyEvent) { - return isPlainChar(keyEvent, 'h', 'H'); - } - - static boolean isVimRightKey(KeyEvent keyEvent) { - return isPlainChar(keyEvent, 'l', 'L'); - } - - static boolean isVimHomeKey(KeyEvent keyEvent) { - return isPlainChar(keyEvent, 'g', 'g'); - } - - static boolean isVimEndKey(KeyEvent keyEvent) { - return isPlainChar(keyEvent, 'G', 'G'); - } - - static boolean isPlainChar(KeyEvent keyEvent, char lower, char upper) { - return keyEvent.code() == KeyCode.CHAR - && !keyEvent.hasCtrl() - && !keyEvent.hasAlt() - && (keyEvent.character() == lower || keyEvent.character() == upper); - } - - static boolean isDigitKey(KeyEvent keyEvent) { - return keyEvent.code() == KeyCode.CHAR - && !keyEvent.hasCtrl() - && !keyEvent.hasAlt() - && Character.isDigit(keyEvent.character()); - } -} diff --git a/src/main/java/dev/ayagmar/quarkusforge/ui/UiTextInputKeys.java b/src/main/java/dev/ayagmar/quarkusforge/ui/UiTextInputKeys.java deleted file mode 100644 index 409863cd..00000000 --- a/src/main/java/dev/ayagmar/quarkusforge/ui/UiTextInputKeys.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.ayagmar.quarkusforge.ui; - -import dev.tamboui.tui.event.KeyCode; -import dev.tamboui.tui.event.KeyEvent; - -/** Centralized predicate for text-input edit keys accepted by UI text fields. */ -final class UiTextInputKeys { - private UiTextInputKeys() {} - - static boolean isSupportedEditKey(KeyEvent event) { - if (event.code() == KeyCode.CHAR && !event.hasCtrl() && !event.hasAlt()) { - return true; - } - return event.isDeleteBackward() - || event.isDeleteForward() - || event.isLeft() - || event.isRight() - || event.isHome() - || event.isEnd(); - } -} diff --git a/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliBindingsProfileTest.java b/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliBindingsProfileTest.java index afcab301..789ca536 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliBindingsProfileTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/cli/QuarkusForgeCliBindingsProfileTest.java @@ -23,49 +23,75 @@ void appTuiConfigUsesAppBindingsProfile() { void profilePreservesNavigationBindings() { Bindings bindings = TuiBootstrapService.appBindingsProfile(); - assertThat(bindings.actionFor(KeyEvent.ofChar('j'))).contains(Actions.MOVE_DOWN); - assertThat(bindings.actionFor(KeyEvent.ofChar('k'))).contains(Actions.MOVE_UP); - assertThat(bindings.actionFor(KeyEvent.ofChar('h'))).contains(Actions.MOVE_LEFT); - assertThat(bindings.actionFor(KeyEvent.ofChar('l'))).contains(Actions.MOVE_RIGHT); - assertThat(bindings.actionFor(KeyEvent.ofChar('g'))).contains(Actions.HOME); - assertThat(bindings.actionFor(KeyEvent.ofChar('G'))).contains(Actions.END); + assertThat(bindings.matches(KeyEvent.ofChar('j'), Actions.MOVE_DOWN)).isTrue(); + assertThat(bindings.matches(KeyEvent.ofChar('k'), Actions.MOVE_UP)).isTrue(); + assertThat(bindings.matches(KeyEvent.ofChar('h'), Actions.MOVE_LEFT)).isTrue(); + assertThat(bindings.matches(KeyEvent.ofChar('l'), Actions.MOVE_RIGHT)).isTrue(); + assertThat(bindings.matches(KeyEvent.ofChar('g'), Actions.HOME)).isTrue(); + assertThat(bindings.matches(KeyEvent.ofChar('G'), Actions.END)).isTrue(); } @Test void profilePreservesGlobalShortcutBindings() { Bindings bindings = TuiBootstrapService.appBindingsProfile(); - assertThat(bindings.actionFor(KeyEvent.ofChar('/'))) - .contains(AppKeyActions.FOCUS_EXTENSION_SEARCH); - assertThat(bindings.actionFor(KeyEvent.ofChar('f', KeyModifiers.CTRL))) - .contains(AppKeyActions.FOCUS_EXTENSION_SEARCH); - assertThat(bindings.actionFor(KeyEvent.ofChar('l', KeyModifiers.CTRL))) - .contains(AppKeyActions.FOCUS_EXTENSION_LIST); - assertThat(bindings.actionFor(KeyEvent.ofChar('r', KeyModifiers.CTRL))) - .contains(AppKeyActions.RELOAD_CATALOG); - assertThat(bindings.actionFor(KeyEvent.ofChar('p', KeyModifiers.CTRL))) - .contains(AppKeyActions.OPEN_COMMAND_PALETTE); - assertThat(bindings.actionFor(KeyEvent.ofChar('g', KeyModifiers.ALT))) - .contains(AppKeyActions.SUBMIT_GENERATION); + assertThat(bindings.matches(KeyEvent.ofChar('/'), AppKeyActions.FOCUS_EXTENSION_SEARCH)) + .isTrue(); + assertThat( + bindings.matches( + KeyEvent.ofChar('f', KeyModifiers.CTRL), AppKeyActions.FOCUS_EXTENSION_SEARCH)) + .isTrue(); + assertThat( + bindings.matches( + KeyEvent.ofChar('l', KeyModifiers.CTRL), AppKeyActions.FOCUS_EXTENSION_LIST)) + .isTrue(); + assertThat( + bindings.matches(KeyEvent.ofChar('r', KeyModifiers.CTRL), AppKeyActions.RELOAD_CATALOG)) + .isTrue(); + assertThat( + bindings.matches( + KeyEvent.ofChar('p', KeyModifiers.CTRL), AppKeyActions.OPEN_COMMAND_PALETTE)) + .isTrue(); + assertThat( + bindings.matches( + KeyEvent.ofChar('g', KeyModifiers.ALT), AppKeyActions.SUBMIT_GENERATION)) + .isTrue(); } @Test void profilePreservesExtensionShortcutBindings() { Bindings bindings = TuiBootstrapService.appBindingsProfile(); - assertThat(bindings.actionFor(KeyEvent.ofChar('v'))) - .contains(AppKeyActions.CATEGORY_FILTER_CYCLE); - assertThat(bindings.actionFor(KeyEvent.ofChar('c'))).contains(AppKeyActions.TOGGLE_CATEGORY); - assertThat(bindings.actionFor(KeyEvent.ofChar('C'))) - .contains(AppKeyActions.OPEN_ALL_CATEGORIES); - assertThat(bindings.actionFor(KeyEvent.ofChar('x'))) - .contains(AppKeyActions.CLEAR_SELECTED_EXTENSIONS); - assertThat(bindings.actionFor(KeyEvent.ofChar('f'))).contains(AppKeyActions.FAVORITE_TOGGLE); - assertThat(bindings.actionFor(KeyEvent.ofChar('k', KeyModifiers.CTRL))) - .contains(AppKeyActions.TOGGLE_FAVORITES_FILTER); - assertThat(bindings.actionFor(KeyEvent.ofChar('j', KeyModifiers.CTRL))) - .contains(AppKeyActions.JUMP_TO_FAVORITE); - assertThat(bindings.actionFor(KeyEvent.ofChar('e', KeyModifiers.CTRL))) - .contains(AppKeyActions.TOGGLE_ERROR_DETAILS); + assertThat(bindings.matches(KeyEvent.ofChar('v'), AppKeyActions.CATEGORY_FILTER_CYCLE)) + .isTrue(); + assertThat(bindings.matches(KeyEvent.ofChar('c'), AppKeyActions.TOGGLE_CATEGORY)).isTrue(); + assertThat(bindings.matches(KeyEvent.ofChar('C'), AppKeyActions.OPEN_ALL_CATEGORIES)).isTrue(); + assertThat(bindings.matches(KeyEvent.ofChar('x'), AppKeyActions.CLEAR_SELECTED_EXTENSIONS)) + .isTrue(); + assertThat(bindings.matches(KeyEvent.ofChar('f'), AppKeyActions.FAVORITE_TOGGLE)).isTrue(); + assertThat( + bindings.matches( + KeyEvent.ofChar('k', KeyModifiers.CTRL), AppKeyActions.TOGGLE_FAVORITES_FILTER)) + .isTrue(); + assertThat( + bindings.matches( + KeyEvent.ofChar('j', KeyModifiers.CTRL), AppKeyActions.JUMP_TO_FAVORITE)) + .isTrue(); + assertThat( + bindings.matches( + KeyEvent.ofChar('e', KeyModifiers.CTRL), AppKeyActions.TOGGLE_ERROR_DETAILS)) + .isTrue(); + assertThat( + bindings.matches( + KeyEvent.ofChar('s', KeyModifiers.ALT), AppKeyActions.TOGGLE_SELECTED_FILTER)) + .isTrue(); + assertThat( + bindings.matches( + KeyEvent.ofChar('n', KeyModifiers.ALT), AppKeyActions.NEXT_INVALID_FIELD)) + .isTrue(); + assertThat( + bindings.matches( + KeyEvent.ofChar('p', KeyModifiers.ALT), AppKeyActions.PREVIOUS_INVALID_FIELD)) + .isTrue(); } } diff --git a/src/test/java/dev/ayagmar/quarkusforge/ui/CoreTuiShellPilotTest.java b/src/test/java/dev/ayagmar/quarkusforge/ui/CoreTuiShellPilotTest.java index 1c7d2209..b64608d3 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/ui/CoreTuiShellPilotTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/ui/CoreTuiShellPilotTest.java @@ -11,6 +11,7 @@ import dev.ayagmar.quarkusforge.domain.MetadataCompatibilityContext; import dev.ayagmar.quarkusforge.domain.ProjectRequest; import dev.ayagmar.quarkusforge.domain.ProjectRequestValidator; +import dev.ayagmar.quarkusforge.runtime.TuiBootstrapService; import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; import dev.tamboui.tui.event.KeyModifiers; @@ -27,6 +28,10 @@ class CoreTuiShellPilotTest { @RegisterExtension final SystemPropertyExtension systemProperties = new SystemPropertyExtension(); + private static KeyEvent appChar(char character) { + return KeyEvent.ofChar(character, TuiBootstrapService.appBindingsProfile()); + } + @Test void focusTraversalCyclesWithTabAndShiftTab() { CoreTuiController controller = UiControllerTestHarness.controller(); @@ -79,9 +84,9 @@ void vimListMotionsJAndKNavigateCatalogRows() { UiControllerTestHarness.moveFocusTo(controller, FocusTarget.EXTENSION_LIST); String firstId = controller.focusedListExtensionId(); - controller.onEvent(KeyEvent.ofChar('j')); + controller.onEvent(appChar('j')); String secondId = controller.focusedListExtensionId(); - controller.onEvent(KeyEvent.ofChar('k')); + controller.onEvent(appChar('k')); assertThat(secondId).isNotEqualTo(firstId); assertThat(controller.focusedListExtensionId()).isEqualTo(firstId); @@ -461,7 +466,7 @@ void commandPaletteRunsSelectedAction() { assertThat(controller.focusTarget()).isEqualTo(FocusTarget.GROUP_ID); controller.onEvent(KeyEvent.ofChar('p', KeyModifiers.CTRL)); - controller.onEvent(KeyEvent.ofChar('j')); + controller.onEvent(appChar('j')); controller.onEvent(KeyEvent.ofKey(KeyCode.ENTER)); assertThat(controller.commandPaletteVisible()).isFalse(); @@ -609,11 +614,11 @@ void submitFocusSupportsVimJkTraversal() { CoreTuiController controller = UiControllerTestHarness.controller(); UiControllerTestHarness.moveFocusTo(controller, FocusTarget.SUBMIT); - controller.onEvent(KeyEvent.ofChar('k')); + controller.onEvent(appChar('k')); assertThat(controller.focusTarget()).isEqualTo(FocusTarget.EXTENSION_LIST); UiControllerTestHarness.moveFocusTo(controller, FocusTarget.SUBMIT); - controller.onEvent(KeyEvent.ofChar('j')); + controller.onEvent(appChar('j')); assertThat(controller.focusTarget()).isEqualTo(FocusTarget.GROUP_ID); } @@ -1112,11 +1117,11 @@ void leftAndRightSupportSectionHierarchyNavigation() { assertThat(controller.focusedListExtensionId()).isEmpty(); assertThat(controller.statusMessage()).contains("Moved to section: Core"); - controller.onEvent(KeyEvent.ofChar('l')); + controller.onEvent(appChar('l')); assertThat(controller.focusedListExtensionId()).isEqualTo("io.quarkus:quarkus-arc"); assertThat(controller.statusMessage()).contains("Moved to first item in section: Core"); - controller.onEvent(KeyEvent.ofChar('h')); + controller.onEvent(appChar('h')); controller.onEvent(KeyEvent.ofKey(KeyCode.LEFT)); assertThat(controller.statusMessage()).contains("Closed category: Core"); assertThat(UiControllerTestHarness.renderToString(controller)).contains("▶ Core "); @@ -1217,7 +1222,7 @@ void categoryCloseCanBeUndoneWithSameKeyAndListNavigationRemainsPredictable() { assertThat(UiControllerTestHarness.renderToString(controller)).contains("CDI"); assertThat(UiControllerTestHarness.renderToString(controller)).contains("▼ Core"); - controller.onEvent(KeyEvent.ofChar('j')); + controller.onEvent(appChar('j')); assertThat(controller.focusedListExtensionId()).isEqualTo("io.quarkus:quarkus-arc"); } @@ -1240,9 +1245,9 @@ void allCollapsedCategoriesStillSupportHeaderNavigationAndReopen() { UiControllerTestHarness.moveFocusTo(controller, FocusTarget.EXTENSION_LIST); controller.onEvent(KeyEvent.ofChar('c')); - controller.onEvent(KeyEvent.ofChar('j')); + controller.onEvent(appChar('j')); controller.onEvent(KeyEvent.ofChar('c')); - controller.onEvent(KeyEvent.ofChar('j')); + controller.onEvent(appChar('j')); controller.onEvent(KeyEvent.ofChar('c')); String allCollapsed = UiControllerTestHarness.renderToString(controller); @@ -1254,7 +1259,7 @@ void allCollapsedCategoriesStillSupportHeaderNavigationAndReopen() { assertThat(allCollapsed).doesNotContain("JDBC PostgreSQL"); assertThat(controller.focusedListExtensionId()).isEmpty(); - controller.onEvent(KeyEvent.ofChar('k')); + controller.onEvent(appChar('k')); controller.onEvent(KeyEvent.ofChar('c')); assertThat(controller.statusMessage()).contains("Opened category: Web"); @@ -1309,7 +1314,7 @@ void metadataSelectorsCycleFromLoadedOptionsAndBlockFreeTextEdits() { // Selector uses "Platform:" prefix in compact mode assertThat(UiControllerTestHarness.renderToString(controller)).contains("Platform:"); - controller.onEvent(KeyEvent.ofChar('l')); + controller.onEvent(appChar('l')); assertThat(controller.request().platformStream()).isEqualTo("io.quarkus.platform:3.20"); assertThat(controller.uiState().request().platformStream()) .isEqualTo("io.quarkus.platform:3.20"); @@ -1318,7 +1323,7 @@ void metadataSelectorsCycleFromLoadedOptionsAndBlockFreeTextEdits() { assertThat(controller.validation().errors().getFirst().field()).isEqualTo("compatibility"); UiControllerTestHarness.moveFocusTo(controller, FocusTarget.JAVA_VERSION); - controller.onEvent(KeyEvent.ofChar('h')); + controller.onEvent(appChar('h')); assertThat(controller.request().javaVersion()).isEqualTo("21"); assertThat(controller.uiState().request().javaVersion()).isEqualTo("21"); assertThat(controller.validation().isValid()).isTrue(); @@ -1329,7 +1334,7 @@ void metadataSelectorsCycleFromLoadedOptionsAndBlockFreeTextEdits() { controller.onEvent(KeyEvent.ofChar('x')); assertThat(controller.request().buildTool()).isEqualTo(originalBuildTool); - controller.onEvent(KeyEvent.ofChar('l')); + controller.onEvent(appChar('l')); assertThat(controller.request().buildTool()).isEqualTo("gradle"); assertThat(controller.uiState().request().buildTool()).isEqualTo("gradle"); assertThat(controller.statusMessage()).isEqualTo("Build tool selected: gradle"); diff --git a/src/test/java/dev/ayagmar/quarkusforge/ui/CoreUiReducerTest.java b/src/test/java/dev/ayagmar/quarkusforge/ui/CoreUiReducerTest.java index 5390ff9e..e4707b1e 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/ui/CoreUiReducerTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/ui/CoreUiReducerTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import dev.tamboui.tui.bindings.BindingSets; import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; import java.nio.file.Path; @@ -593,14 +594,15 @@ void extensionStateUpdatedIntentMakesExtensionViewReducerOwned() { @Test void extensionInteractionIntentRoutesListMovementThroughEffect() { + KeyEvent keyEvent = vimChar('j'); ReduceResult result = reducer.reduce( stateWithFocus(baseState(), FocusTarget.EXTENSION_LIST), - new UiIntent.ExtensionInteractionIntent(KeyEvent.ofChar('j'))); + new UiIntent.ExtensionInteractionIntent(keyEvent)); assertThat(result.action()).isEqualTo(UiAction.handled(false)); assertThat(result.effects()) - .containsExactly(new UiEffect.ApplyExtensionNavigationKey(KeyEvent.ofChar('j'))); + .containsExactly(new UiEffect.ApplyExtensionNavigationKey(keyEvent)); assertThat(result.nextState()) .isEqualTo(stateWithFocus(baseState(), FocusTarget.EXTENSION_LIST)); } @@ -626,7 +628,7 @@ void extensionInteractionIntentMovesTopListSelectionBackToSearch() { UiState.ExtensionView.snapshot(7, 7, 0, false, false, "", "", "", "", true, true)); ReduceResult result = - reducer.reduce(state, new UiIntent.ExtensionInteractionIntent(KeyEvent.ofChar('k'))); + reducer.reduce(state, new UiIntent.ExtensionInteractionIntent(vimChar('k'))); assertThat(result.action()).isEqualTo(UiAction.handled(false)); assertThat(result.effects()) @@ -635,6 +637,10 @@ void extensionInteractionIntentMovesTopListSelectionBackToSearch() { assertThat(result.nextState().statusMessage()).isEqualTo("Focus moved to extensionSearch"); } + private static KeyEvent vimChar(char character) { + return KeyEvent.ofChar(character, BindingSets.vim()); + } + @Test void extensionInteractionIntentTogglesSectionHeadersThroughSharedActionPath() { UiState state = diff --git a/src/test/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogNavigationTest.java b/src/test/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogNavigationTest.java index 2c8e3f7b..e3cb35ce 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogNavigationTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogNavigationTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import dev.tamboui.tui.bindings.BindingSets; import dev.tamboui.tui.event.KeyEvent; import java.util.List; import java.util.Set; @@ -99,21 +100,25 @@ void keyboardNavigationUsesSectionHeadersWhenFocusedAndSelectableRowsOtherwise() ExtensionCatalogNavigation navigation = new ExtensionCatalogNavigation(); ExtensionCatalogRows rows = rows(false); - assertThat(navigation.handleNavigationKey(rows, KeyEvent.ofChar('j'))).isTrue(); + assertThat(navigation.handleNavigationKey(rows, vimChar('j'))).isTrue(); assertThat(navigation.selectedRow()).isEqualTo(1); assertThat(navigation.isSelectionAtTop(rows)).isTrue(); - assertThat(navigation.handleNavigationKey(rows, KeyEvent.ofChar('G'))).isTrue(); + assertThat(navigation.handleNavigationKey(rows, vimChar('G'))).isTrue(); assertThat(navigation.selectedRow()).isEqualTo(5); assertThat(navigation.isSelectionAtTop(rows)).isFalse(); navigation.listState().select(2); - assertThat(navigation.handleNavigationKey(rows, KeyEvent.ofChar('g'))).isTrue(); + assertThat(navigation.handleNavigationKey(rows, vimChar('g'))).isTrue(); assertThat(navigation.selectedRow()).isZero(); assertThat(navigation.handleNavigationKey(rows, KeyEvent.ofChar('x'))).isFalse(); } + private static KeyEvent vimChar(char character) { + return KeyEvent.ofChar(character, BindingSets.vim()); + } + private static ExtensionCatalogRows rows(boolean collapseWeb) { ExtensionCatalogRows rows = new ExtensionCatalogRows(); ExtensionCatalogFilters filters = new ExtensionCatalogFilters(""); diff --git a/src/test/java/dev/ayagmar/quarkusforge/ui/UiKeyMatchersTest.java b/src/test/java/dev/ayagmar/quarkusforge/ui/UiKeyMatchersTest.java deleted file mode 100644 index cae77513..00000000 --- a/src/test/java/dev/ayagmar/quarkusforge/ui/UiKeyMatchersTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package dev.ayagmar.quarkusforge.ui; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.tamboui.tui.event.KeyCode; -import dev.tamboui.tui.event.KeyEvent; -import dev.tamboui.tui.event.KeyModifiers; -import org.junit.jupiter.api.Test; - -class UiKeyMatchersTest { - @Test - void plainCharMatchesOnlyCharWithoutModifiers() { - assertThat(UiKeyMatchers.isPlainChar(KeyEvent.ofChar('k'), 'k', 'K')).isTrue(); - assertThat(UiKeyMatchers.isPlainChar(KeyEvent.ofChar('K'), 'k', 'K')).isTrue(); - assertThat(UiKeyMatchers.isPlainChar(KeyEvent.ofChar('k', KeyModifiers.CTRL), 'k', 'K')) - .isFalse(); - assertThat(UiKeyMatchers.isPlainChar(KeyEvent.ofChar('k', KeyModifiers.ALT), 'k', 'K')) - .isFalse(); - assertThat(UiKeyMatchers.isPlainChar(KeyEvent.ofKey(KeyCode.UP), 'k', 'K')).isFalse(); - } - - @Test - void vimMovementChecksMatchConfiguredCharacters() { - assertThat(UiKeyMatchers.isVimUpKey(KeyEvent.ofChar('k'))).isTrue(); - assertThat(UiKeyMatchers.isVimDownKey(KeyEvent.ofChar('j'))).isTrue(); - assertThat(UiKeyMatchers.isVimLeftKey(KeyEvent.ofChar('h'))).isTrue(); - assertThat(UiKeyMatchers.isVimRightKey(KeyEvent.ofChar('l'))).isTrue(); - assertThat(UiKeyMatchers.isVimHomeKey(KeyEvent.ofChar('g'))).isTrue(); - assertThat(UiKeyMatchers.isVimEndKey(KeyEvent.ofChar('G'))).isTrue(); - assertThat(UiKeyMatchers.isVimUpKey(KeyEvent.ofChar('k', KeyModifiers.CTRL))).isFalse(); - } - - @Test - void digitKeyRequiresPlainCharacterDigit() { - assertThat(UiKeyMatchers.isDigitKey(KeyEvent.ofChar('7'))).isTrue(); - assertThat(UiKeyMatchers.isDigitKey(KeyEvent.ofChar('7', KeyModifiers.CTRL))).isFalse(); - assertThat(UiKeyMatchers.isDigitKey(KeyEvent.ofChar('x'))).isFalse(); - assertThat(UiKeyMatchers.isDigitKey(KeyEvent.ofKey(KeyCode.DOWN))).isFalse(); - } -} From e6b3837161fec53f31097e308a681abbbae6f37a Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 25 Jun 2026 16:27:40 +0100 Subject: [PATCH 2/6] test(ui): cover ignored text input keys --- .../quarkusforge/ui/CoreUiReducerTest.java | 11 +++++++ .../quarkusforge/ui/InputEffectsTest.java | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/test/java/dev/ayagmar/quarkusforge/ui/CoreUiReducerTest.java b/src/test/java/dev/ayagmar/quarkusforge/ui/CoreUiReducerTest.java index e4707b1e..acac3864 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/ui/CoreUiReducerTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/ui/CoreUiReducerTest.java @@ -832,6 +832,17 @@ void textInputIntentWithUnsupportedKeyIsIgnored() { assertThat(result.effects()).isEmpty(); } + @Test + void textInputIntentWithNavigationKeyIsIgnored() { + ReduceResult result = + reducer.reduce( + stateWithFocus(baseState(), FocusTarget.ARTIFACT_ID), + new UiIntent.TextInputIntent(KeyEvent.ofKey(KeyCode.UP), FocusTarget.ARTIFACT_ID)); + + assertThat(result.action()).isEqualTo(UiAction.ignored()); + assertThat(result.effects()).isEmpty(); + } + @Test void focusNavigationIntentUpdatesFocusStateDirectly() { ReduceResult result = diff --git a/src/test/java/dev/ayagmar/quarkusforge/ui/InputEffectsTest.java b/src/test/java/dev/ayagmar/quarkusforge/ui/InputEffectsTest.java index 1fdbc1c4..7342ab67 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/ui/InputEffectsTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/ui/InputEffectsTest.java @@ -72,6 +72,35 @@ void cursorMovementDoesNotTriggerSubmitRecoveryOrSearchRefresh() { assertThat(fixture.callbacks.dispatchedIntents).isEmpty(); } + @Test + void noOpDeleteDoesNotTriggerSubmitRecoveryOrSearchRefresh() { + TestFixture fixture = new TestFixture(); + String artifactId = fixture.inputStates.get(FocusTarget.ARTIFACT_ID).text(); + + List intents = + fixture.inputEffects.applyTextInputKey( + FocusTarget.ARTIFACT_ID, KeyEvent.ofKey(KeyCode.DELETE)); + + assertThat(intents).isEmpty(); + assertThat(fixture.inputStates.get(FocusTarget.ARTIFACT_ID).text()).isEqualTo(artifactId); + assertThat(fixture.callbacks.submitRecoveryCalls).isZero(); + assertThat(fixture.callbacks.scheduledQuery).isNull(); + assertThat(fixture.callbacks.dispatchedIntents).isEmpty(); + } + + @Test + void unsupportedTextInputKeyDoesNotTriggerSubmitRecoveryOrSearchRefresh() { + TestFixture fixture = new TestFixture(); + + List intents = + fixture.inputEffects.applyTextInputKey(FocusTarget.ARTIFACT_ID, KeyEvent.ofKey(KeyCode.UP)); + + assertThat(intents).isEmpty(); + assertThat(fixture.callbacks.submitRecoveryCalls).isZero(); + assertThat(fixture.callbacks.scheduledQuery).isNull(); + assertThat(fixture.callbacks.dispatchedIntents).isEmpty(); + } + @Test void metadataSelectorEffectCyclesBuildToolAndEmitsStatus() { TestFixture fixture = new TestFixture(); From 4b875d0e25d18302f2a205a0c64b47eceef6939b Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 25 Jun 2026 16:38:25 +0100 Subject: [PATCH 3/6] test(ui): cover keyboard branch paths --- .../quarkusforge/ui/AppKeyActionsTest.java | 4 ++ .../ui/CoreTuiShellPilotTest.java | 16 +++++ .../quarkusforge/ui/CoreUiReducerTest.java | 60 +++++++++++++------ .../quarkusforge/ui/InputEffectsTest.java | 20 +++++++ .../ui/PostGenerationMenuStateTest.java | 10 ++++ 5 files changed, 92 insertions(+), 18 deletions(-) diff --git a/src/test/java/dev/ayagmar/quarkusforge/ui/AppKeyActionsTest.java b/src/test/java/dev/ayagmar/quarkusforge/ui/AppKeyActionsTest.java index 147d08c6..b4e4b633 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/ui/AppKeyActionsTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/ui/AppKeyActionsTest.java @@ -13,9 +13,11 @@ class AppKeyActionsTest { @Test void keyChecksPreserveRawShortcutParity() { assertThat(AppKeyActions.isCatalogReloadKey(KeyEvent.ofChar('r', KeyModifiers.CTRL))).isTrue(); + assertThat(AppKeyActions.isCatalogReloadKey(KeyEvent.ofChar('R', KeyModifiers.CTRL))).isTrue(); assertThat(AppKeyActions.isGenerateShortcutKey(KeyEvent.ofChar('g', KeyModifiers.ALT))) .isTrue(); assertThat(AppKeyActions.isFavoriteToggleKey(KeyEvent.ofChar('f'))).isTrue(); + assertThat(AppKeyActions.isFavoriteToggleKey(KeyEvent.ofChar('F'))).isTrue(); assertThat(AppKeyActions.isSelectedOnlyFilterToggleKey(KeyEvent.ofChar('s', KeyModifiers.ALT))) .isTrue(); assertThat(AppKeyActions.isSelectedOnlyFilterToggleKey(KeyEvent.ofChar('S', KeyModifiers.ALT))) @@ -35,6 +37,8 @@ void keyChecksPreserveRawShortcutParity() { .isTrue(); assertThat(AppKeyActions.isPreviousInvalidFieldKey(KeyEvent.ofChar('p', KeyModifiers.ALT))) .isTrue(); + assertThat(AppKeyActions.isCatalogReloadKey(KeyEvent.ofChar('z'))).isFalse(); + assertThat(AppKeyActions.isFavoriteToggleKey(KeyEvent.ofChar('f', KeyModifiers.ALT))).isFalse(); assertThat(AppKeyActions.isGenerateShortcutKey(KeyEvent.ofChar('g', KeyModifiers.CTRL))) .isFalse(); assertThat(AppKeyActions.isHelpOverlayToggleKey(KeyEvent.ofChar('?'))).isTrue(); diff --git a/src/test/java/dev/ayagmar/quarkusforge/ui/CoreTuiShellPilotTest.java b/src/test/java/dev/ayagmar/quarkusforge/ui/CoreTuiShellPilotTest.java index b64608d3..09ba8411 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/ui/CoreTuiShellPilotTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/ui/CoreTuiShellPilotTest.java @@ -487,6 +487,22 @@ void commandPaletteDigitShortcutRunsSelectedAction() { assertThat(controller.statusMessage()).contains("Focus moved to extensionList"); } + @Test + void commandPaletteHandlesNavigationKeysAndIgnoresModifiedDigits() { + CoreTuiController controller = UiControllerTestHarness.controller(); + + controller.onEvent(KeyEvent.ofChar('p', KeyModifiers.CTRL)); + controller.onEvent(KeyEvent.ofKey(KeyCode.DOWN)); + controller.onEvent(KeyEvent.ofKey(KeyCode.UP)); + controller.onEvent(KeyEvent.ofKey(KeyCode.HOME)); + controller.onEvent(KeyEvent.ofKey(KeyCode.END)); + controller.onEvent(KeyEvent.ofChar('2', KeyModifiers.CTRL)); + controller.onEvent(KeyEvent.ofChar('2', KeyModifiers.ALT)); + + assertThat(controller.commandPaletteVisible()).isTrue(); + assertThat(controller.focusTarget()).isEqualTo(FocusTarget.GROUP_ID); + } + @Test void shortcutAndPaletteSharedQuickActionsStayInSync() { CoreTuiController controller = UiControllerTestHarness.controller(); diff --git a/src/test/java/dev/ayagmar/quarkusforge/ui/CoreUiReducerTest.java b/src/test/java/dev/ayagmar/quarkusforge/ui/CoreUiReducerTest.java index acac3864..db3e722b 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/ui/CoreUiReducerTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/ui/CoreUiReducerTest.java @@ -5,6 +5,7 @@ import dev.tamboui.tui.bindings.BindingSets; import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; import java.nio.file.Path; import java.util.List; import org.junit.jupiter.api.Test; @@ -607,6 +608,21 @@ void extensionInteractionIntentRoutesListMovementThroughEffect() { .isEqualTo(stateWithFocus(baseState(), FocusTarget.EXTENSION_LIST)); } + @Test + void extensionInteractionIntentRoutesArrowNavigationKeysThroughEffect() { + UiState state = stateWithFocus(baseState(), FocusTarget.EXTENSION_LIST); + + for (KeyCode keyCode : List.of(KeyCode.DOWN, KeyCode.HOME, KeyCode.END)) { + KeyEvent keyEvent = KeyEvent.ofKey(keyCode); + ReduceResult result = + reducer.reduce(state, new UiIntent.ExtensionInteractionIntent(keyEvent)); + + assertThat(result.action()).isEqualTo(UiAction.handled(false)); + assertThat(result.effects()) + .containsExactly(new UiEffect.ApplyExtensionNavigationKey(keyEvent)); + } + } + @Test void extensionInteractionIntentMovesSearchFocusToListOnDown() { ReduceResult result = @@ -768,15 +784,19 @@ void metadataIntentIsIgnoredWhenSelectorHasNoOptions() { @Test void metadataIntentWithOptionsProducesSelectorEffect() { - ReduceResult result = - reducer.reduce( - stateWithFocus(baseState(), FocusTarget.BUILD_TOOL), - new UiIntent.MetadataInputIntent( - KeyEvent.ofKey(KeyCode.LEFT), FocusTarget.BUILD_TOOL, true)); - - assertThat(result.action()).isEqualTo(UiAction.handled(false)); - assertThat(result.effects()).hasSize(1); - assertThat(result.effects().getFirst()).isInstanceOf(UiEffect.ApplyMetadataSelectorKey.class); + UiState state = stateWithFocus(baseState(), FocusTarget.BUILD_TOOL); + + for (KeyCode keyCode : + List.of(KeyCode.LEFT, KeyCode.UP, KeyCode.RIGHT, KeyCode.DOWN, KeyCode.HOME, KeyCode.END)) { + KeyEvent keyEvent = KeyEvent.ofKey(keyCode); + ReduceResult result = + reducer.reduce( + state, new UiIntent.MetadataInputIntent(keyEvent, FocusTarget.BUILD_TOOL, true)); + + assertThat(result.action()).isEqualTo(UiAction.handled(false)); + assertThat(result.effects()) + .containsExactly(new UiEffect.ApplyMetadataSelectorKey(FocusTarget.BUILD_TOOL, keyEvent)); + } } @Test @@ -821,15 +841,19 @@ void textInputIntentUsesReducerFocusInsteadOfIntentFocus() { @Test void textInputIntentWithUnsupportedKeyIsIgnored() { - ReduceResult result = - reducer.reduce( - baseState(), - new UiIntent.TextInputIntent( - KeyEvent.ofChar('a', dev.tamboui.tui.event.KeyModifiers.CTRL), - FocusTarget.ARTIFACT_ID)); - - assertThat(result.action()).isEqualTo(UiAction.ignored()); - assertThat(result.effects()).isEmpty(); + for (KeyEvent keyEvent : + List.of( + KeyEvent.ofChar('a', KeyModifiers.CTRL), + KeyEvent.ofChar('a', KeyModifiers.ALT), + KeyEvent.ofChar((char) 31), + KeyEvent.ofChar((char) 127))) { + ReduceResult result = + reducer.reduce( + baseState(), new UiIntent.TextInputIntent(keyEvent, FocusTarget.ARTIFACT_ID)); + + assertThat(result.action()).isEqualTo(UiAction.ignored()); + assertThat(result.effects()).isEmpty(); + } } @Test diff --git a/src/test/java/dev/ayagmar/quarkusforge/ui/InputEffectsTest.java b/src/test/java/dev/ayagmar/quarkusforge/ui/InputEffectsTest.java index 7342ab67..a5eab7f1 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/ui/InputEffectsTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/ui/InputEffectsTest.java @@ -91,16 +91,36 @@ void noOpDeleteDoesNotTriggerSubmitRecoveryOrSearchRefresh() { @Test void unsupportedTextInputKeyDoesNotTriggerSubmitRecoveryOrSearchRefresh() { TestFixture fixture = new TestFixture(); + fixture.inputStates.remove(FocusTarget.BUILD_TOOL); List intents = fixture.inputEffects.applyTextInputKey(FocusTarget.ARTIFACT_ID, KeyEvent.ofKey(KeyCode.UP)); + List nonTextInputIntents = + fixture.inputEffects.applyTextInputKey(FocusTarget.BUILD_TOOL, KeyEvent.ofChar('x')); assertThat(intents).isEmpty(); + assertThat(nonTextInputIntents).isEmpty(); assertThat(fixture.callbacks.submitRecoveryCalls).isZero(); assertThat(fixture.callbacks.scheduledQuery).isNull(); assertThat(fixture.callbacks.dispatchedIntents).isEmpty(); } + @Test + void metadataSelectorEffectHandlesVerticalNavigationKeys() { + for (KeyCode keyCode : List.of(KeyCode.UP, KeyCode.DOWN)) { + TestFixture fixture = new TestFixture(); + + List intents = + fixture.inputEffects.applyMetadataSelectorKey( + FocusTarget.BUILD_TOOL, KeyEvent.ofKey(keyCode)); + + assertThat(intents).hasSize(3); + assertThat(intents.getFirst()).isInstanceOf(UiIntent.FormStateUpdatedIntent.class); + assertThat(intents.get(1)).isInstanceOf(UiIntent.StatusMessageIntent.class); + assertThat(intents.get(2)).isInstanceOf(UiIntent.SubmitEditRecoveryIntent.class); + } + } + @Test void metadataSelectorEffectCyclesBuildToolAndEmitsStatus() { TestFixture fixture = new TestFixture(); diff --git a/src/test/java/dev/ayagmar/quarkusforge/ui/PostGenerationMenuStateTest.java b/src/test/java/dev/ayagmar/quarkusforge/ui/PostGenerationMenuStateTest.java index 9859e3a9..137ac488 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/ui/PostGenerationMenuStateTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/ui/PostGenerationMenuStateTest.java @@ -108,14 +108,24 @@ void githubVisibilityMenuReturnsVisibilityCommandsWithoutMutation() { true, true, 0, 0, UiTextConstants.postGenerationActions(List.of()), null, "cmd", null); UiIntent.PostGenerationCommand down = menuState.handleKey(view, KeyEvent.ofKey(KeyCode.DOWN)); + UiIntent.PostGenerationCommand up = menuState.handleKey(view, KeyEvent.ofKey(KeyCode.UP)); UiIntent.PostGenerationCommand escape = menuState.handleKey(view, KeyEvent.ofKey(KeyCode.ESCAPE)); UiIntent.PostGenerationCommand choosePublic = menuState.handleKey(view, KeyEvent.ofChar('2')); + UiIntent.PostGenerationCommand ctrlDigit = + menuState.handleKey(view, KeyEvent.ofChar('2', KeyModifiers.CTRL)); + UiIntent.PostGenerationCommand altDigit = + menuState.handleKey(view, KeyEvent.ofChar('2', KeyModifiers.ALT)); + UiIntent.PostGenerationCommand letter = menuState.handleKey(view, KeyEvent.ofChar('x')); assertThat(down).isEqualTo(new UiIntent.PostGenerationCommand.MoveGithubVisibilitySelection(1)); + assertThat(up).isEqualTo(new UiIntent.PostGenerationCommand.MoveGithubVisibilitySelection(-1)); assertThat(escape).isInstanceOf(UiIntent.PostGenerationCommand.CancelGithubVisibility.class); assertThat(choosePublic) .isEqualTo(new UiIntent.PostGenerationCommand.SelectGithubVisibilityIndex(1)); + assertThat(ctrlDigit).isInstanceOf(UiIntent.PostGenerationCommand.Noop.class); + assertThat(altDigit).isInstanceOf(UiIntent.PostGenerationCommand.Noop.class); + assertThat(letter).isInstanceOf(UiIntent.PostGenerationCommand.Noop.class); assertThat(view.githubVisibilitySelection()).isZero(); } } From bf54a560a8912676b486c758b50cfc47de196777 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 25 Jun 2026 17:08:12 +0100 Subject: [PATCH 4/6] refactor(ui): replace digit key check with shared predicate and remove redundant code --- .../quarkusforge/ui/CoreTuiController.java | 10 +--------- .../quarkusforge/ui/PostGenerationMenuState.java | 12 ++---------- .../ayagmar/quarkusforge/ui/UiKeyPredicates.java | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 19 deletions(-) create mode 100644 src/main/java/dev/ayagmar/quarkusforge/ui/UiKeyPredicates.java diff --git a/src/main/java/dev/ayagmar/quarkusforge/ui/CoreTuiController.java b/src/main/java/dev/ayagmar/quarkusforge/ui/CoreTuiController.java index b2c3d085..32118438 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/ui/CoreTuiController.java +++ b/src/main/java/dev/ayagmar/quarkusforge/ui/CoreTuiController.java @@ -17,7 +17,6 @@ import dev.ayagmar.quarkusforge.util.OutputPathResolver; import dev.tamboui.terminal.Frame; import dev.tamboui.tui.event.Event; -import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; import dev.tamboui.tui.event.ResizeEvent; import dev.tamboui.tui.event.TickEvent; @@ -818,7 +817,7 @@ public UiAction handleCommandPaletteKey(KeyEvent keyEvent) { new UiIntent.CommandPaletteIntent(new UiIntent.CommandPaletteCommand.JumpEnd())); return UiAction.handled(false); } - if (isDigitKey(keyEvent)) { + if (UiKeyPredicates.isDigitKey(keyEvent)) { int selected = Character.digit(keyEvent.codePoint(), 10) - 1; if (selected >= 0 && selected < UiTextConstants.COMMAND_PALETTE_ENTRIES.size()) { dispatchIntent( @@ -837,13 +836,6 @@ public UiAction handleCommandPaletteKey(KeyEvent keyEvent) { return UiAction.handled(false); } - private static boolean isDigitKey(KeyEvent keyEvent) { - return keyEvent.code() == KeyCode.CHAR - && !keyEvent.hasCtrl() - && !keyEvent.hasAlt() - && Character.isDigit(keyEvent.codePoint()); - } - @Override public UiAction handlePostGenerationMenuKey(KeyEvent keyEvent) { UiIntent.PostGenerationCommand command = diff --git a/src/main/java/dev/ayagmar/quarkusforge/ui/PostGenerationMenuState.java b/src/main/java/dev/ayagmar/quarkusforge/ui/PostGenerationMenuState.java index 9daf7893..b09cf4b0 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/ui/PostGenerationMenuState.java +++ b/src/main/java/dev/ayagmar/quarkusforge/ui/PostGenerationMenuState.java @@ -1,6 +1,5 @@ package dev.ayagmar.quarkusforge.ui; -import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; import java.util.List; @@ -49,7 +48,7 @@ UiIntent.PostGenerationCommand handleKey(UiState.PostGenerationView view, KeyEve if (keyEvent.isFocusNext()) { return new UiIntent.PostGenerationCommand.MoveActionSelection(1); } - if (isDigitKey(keyEvent)) { + if (UiKeyPredicates.isDigitKey(keyEvent)) { int selected = Character.digit(keyEvent.codePoint(), 10) - 1; if (selected >= 0 && selected < view.actions().size()) { return new UiIntent.PostGenerationCommand.SelectActionIndex(selected); @@ -81,7 +80,7 @@ private UiIntent.PostGenerationCommand handleGithubVisibilityKey(KeyEvent keyEve if (keyEvent.isFocusNext()) { return new UiIntent.PostGenerationCommand.MoveGithubVisibilitySelection(1); } - if (isDigitKey(keyEvent)) { + if (UiKeyPredicates.isDigitKey(keyEvent)) { int selected = Character.digit(keyEvent.codePoint(), 10) - 1; if (selected >= 0 && selected < UiTextConstants.GITHUB_VISIBILITY_LABELS.size()) { return new UiIntent.PostGenerationCommand.SelectGithubVisibilityIndex(selected); @@ -93,11 +92,4 @@ private UiIntent.PostGenerationCommand handleGithubVisibilityKey(KeyEvent keyEve } return new UiIntent.PostGenerationCommand.Noop(); } - - private static boolean isDigitKey(KeyEvent keyEvent) { - return keyEvent.code() == KeyCode.CHAR - && !keyEvent.hasCtrl() - && !keyEvent.hasAlt() - && Character.isDigit(keyEvent.codePoint()); - } } diff --git a/src/main/java/dev/ayagmar/quarkusforge/ui/UiKeyPredicates.java b/src/main/java/dev/ayagmar/quarkusforge/ui/UiKeyPredicates.java new file mode 100644 index 00000000..d6f62954 --- /dev/null +++ b/src/main/java/dev/ayagmar/quarkusforge/ui/UiKeyPredicates.java @@ -0,0 +1,16 @@ +package dev.ayagmar.quarkusforge.ui; + +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; + +/** Shared key classification helpers used by menu/palette routing paths. */ +final class UiKeyPredicates { + private UiKeyPredicates() {} + + static boolean isDigitKey(KeyEvent keyEvent) { + return keyEvent.code() == KeyCode.CHAR + && !keyEvent.hasCtrl() + && !keyEvent.hasAlt() + && Character.isDigit(keyEvent.codePoint()); + } +} From fb4c34f8e1124366dbd816d9cbc1a0334fb176a9 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 25 Jun 2026 17:39:00 +0100 Subject: [PATCH 5/6] fix(tui): default to Panama terminal backend on all platforms The Windows-specific jline3-first preference let the backend factory settle on a JLine terminal that constructs successfully but does not deliver key events, since the factory only falls through when create() throws. Panama gained a first-class Win32 console backend (SetConsoleMode + ENABLE_VIRTUAL_TERMINAL_INPUT + ReadConsoleInput) in tamboui 0.4.0, so it is now the default on every platform. The tamboui.backend / TAMBOUI_BACKEND override still allows forcing jline3 when needed. Drops the Windows backend special-case and the org.jline.terminal.dumb fallback workaround. --- docs/modules/ROOT/pages/troubleshooting.adoc | 2 +- .../runtime/TuiBootstrapService.java | 68 +------- .../quarkusforge/TuiBootstrapServiceTest.java | 159 ++---------------- 3 files changed, 22 insertions(+), 207 deletions(-) diff --git a/docs/modules/ROOT/pages/troubleshooting.adoc b/docs/modules/ROOT/pages/troubleshooting.adoc index 56b72956..2744d692 100644 --- a/docs/modules/ROOT/pages/troubleshooting.adoc +++ b/docs/modules/ROOT/pages/troubleshooting.adoc @@ -27,7 +27,7 @@ Actions: java --enable-native-access=ALL-UNNAMED -jar target/quarkus-forge.jar ---- . Verify your terminal supports 256-color or true-color (e.g., `echo $TERM`). Recommended: `xterm-256color`, `alacritty`, `kitty`, or `wezterm`. -. On Windows, use Windows Terminal or a recent ConEmu/Git Bash with VT mode enabled. Forge now defaults to the JLine backend on Windows to preserve arrow-key navigation; avoid forcing `tamboui.backend=panama` unless you are debugging backend behavior. +. On Windows, run inside a real console (Windows Terminal, PowerShell, or `cmd.exe`) so the Panama backend can enable virtual-terminal input; Git Bash/MSYS `mintty` does not expose a Win32 console and leaves keys unregistered without `winpty`. Forge defaults to the Panama backend on every platform; set `tamboui.backend=jline3` (or `TAMBOUI_BACKEND=jline3`) only if you need to fall back to JLine. [id="metadata-fallback"] == Metadata fallback on startup diff --git a/src/main/java/dev/ayagmar/quarkusforge/runtime/TuiBootstrapService.java b/src/main/java/dev/ayagmar/quarkusforge/runtime/TuiBootstrapService.java index bde591c7..70678ba9 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/runtime/TuiBootstrapService.java +++ b/src/main/java/dev/ayagmar/quarkusforge/runtime/TuiBootstrapService.java @@ -22,7 +22,6 @@ import dev.tamboui.tui.bindings.Bindings; import java.time.Duration; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; @@ -31,10 +30,7 @@ public final class TuiBootstrapService { private static final String BACKEND_PROPERTY_NAME = "tamboui.backend"; private static final String BACKEND_ENV_NAME = "TAMBOUI_BACKEND"; - private static final String JLINE_DUMB_PROPERTY_NAME = "org.jline.terminal.dumb"; private static final String PANAMA_BACKEND = "panama"; - private static final String JLINE3_BACKEND = "jline3"; - private static final String WINDOWS_BACKEND_PREFERENCE = JLINE3_BACKEND + "," + PANAMA_BACKEND; private static final ReentrantLock BACKEND_PROPERTY_LOCK = new ReentrantLock(); public static final Duration STARTUP_SPLASH_MIN_DURATION = Duration.ofMillis(450); private static final Duration TUI_TICK_RATE = Duration.ofMillis(40); @@ -64,23 +60,18 @@ public static TuiConfig appTuiConfig() { } public static String defaultBackendPreference() { - return defaultBackendPreference(System.getProperty("os.name", "")); + return PANAMA_BACKEND; } private static void configureTerminalBackendPreference() { configureTerminalBackendPreference( - System.getProperty(BACKEND_PROPERTY_NAME), - System.getenv(BACKEND_ENV_NAME), - System.getProperty("os.name", "")); + System.getProperty(BACKEND_PROPERTY_NAME), System.getenv(BACKEND_ENV_NAME)); } - private static void configureTerminalBackendPreference( - String propertyValue, String envValue, String osName) { - String backendPreference = resolveBackendPreference(propertyValue, envValue, osName); + private static void configureTerminalBackendPreference(String propertyValue, String envValue) { if (!isBackendPreferenceExplicitlyConfigured(propertyValue, envValue)) { - System.setProperty(BACKEND_PROPERTY_NAME, backendPreference); + System.setProperty(BACKEND_PROPERTY_NAME, resolveBackendPreference(propertyValue, envValue)); } - configureWindowsJlineDumbTerminalFallback(osName, backendPreference); } private static boolean isBackendPreferenceExplicitlyConfigured( @@ -91,53 +82,14 @@ private static boolean isBackendPreferenceExplicitlyConfigured( return envValue != null && !envValue.isBlank(); } - private static String defaultBackendPreference(String osName) { - if (isWindowsOsName(osName)) { - return WINDOWS_BACKEND_PREFERENCE; - } - return PANAMA_BACKEND; - } - - private static String resolveBackendPreference( - String propertyValue, String envValue, String osName) { + private static String resolveBackendPreference(String propertyValue, String envValue) { if (propertyValue != null && !propertyValue.isBlank()) { return propertyValue; } if (envValue != null && !envValue.isBlank()) { return envValue; } - return defaultBackendPreference(osName); - } - - private static void configureWindowsJlineDumbTerminalFallback( - String osName, String backendPreference) { - if (!isWindowsOsName(osName) || !backendPreferenceContains(backendPreference, JLINE3_BACKEND)) { - return; - } - String jlineDumbPropertyValue = System.getProperty(JLINE_DUMB_PROPERTY_NAME); - if (jlineDumbPropertyValue != null && !jlineDumbPropertyValue.isBlank()) { - return; - } - // JLine logs the Windows warning and returns a DumbTerminal. That leaves the TUI stuck, - // so disable the dumb fallback and let Tamboui try the next backend instead. - System.setProperty(JLINE_DUMB_PROPERTY_NAME, Boolean.FALSE.toString()); - } - - private static boolean backendPreferenceContains(String backendPreference, String backendName) { - if (backendPreference == null || backendPreference.isBlank()) { - return false; - } - return java.util.Arrays.stream(backendPreference.split(",")) - .map(String::trim) - .anyMatch(backendName::equals); - } - - private static boolean isWindowsOsName(String osName) { - if (osName == null) { - return false; - } - String normalized = osName.strip().toLowerCase(Locale.ROOT); - return normalized.startsWith("windows"); + return PANAMA_BACKEND; } public static void runHeadlessSmoke(RuntimeConfig runtimeConfig, DiagnosticLogger diagnostics) { @@ -158,7 +110,6 @@ public void runInteractiveSmoke( throws Exception { BACKEND_PROPERTY_LOCK.lock(); String previousBackendPreference = System.getProperty(BACKEND_PROPERTY_NAME); - String previousJlineDumbPreference = System.getProperty(JLINE_DUMB_PROPERTY_NAME); try { configureTerminalBackendPreference(); TuiConfig tuiConfig = appTuiConfig(); @@ -198,7 +149,6 @@ public void runInteractiveSmoke( } finally { try { restoreTerminalBackendPreference(previousBackendPreference); - restoreJlineDumbTerminalPreference(previousJlineDumbPreference); } finally { BACKEND_PROPERTY_LOCK.unlock(); } @@ -217,7 +167,6 @@ public TuiSessionSummary run( of("searchDebounceMs", Math.max(0, searchDebounceMs))); BACKEND_PROPERTY_LOCK.lock(); String previousBackendPreference = System.getProperty(BACKEND_PROPERTY_NAME); - String previousJlineDumbPreference = System.getProperty(JLINE_DUMB_PROPERTY_NAME); try { configureTerminalBackendPreference(); TuiConfig tuiConfig = appTuiConfig(); @@ -265,7 +214,6 @@ public TuiSessionSummary run( } finally { try { restoreTerminalBackendPreference(previousBackendPreference); - restoreJlineDumbTerminalPreference(previousJlineDumbPreference); } finally { BACKEND_PROPERTY_LOCK.unlock(); } @@ -391,10 +339,6 @@ private static void restoreTerminalBackendPreference(String previousBackendPrefe restoreSystemProperty(BACKEND_PROPERTY_NAME, previousBackendPreference); } - private static void restoreJlineDumbTerminalPreference(String previousJlineDumbPreference) { - restoreSystemProperty(JLINE_DUMB_PROPERTY_NAME, previousJlineDumbPreference); - } - private static void restoreSystemProperty(String propertyName, String previousValue) { if (previousValue == null) { System.clearProperty(propertyName); diff --git a/src/test/java/dev/ayagmar/quarkusforge/TuiBootstrapServiceTest.java b/src/test/java/dev/ayagmar/quarkusforge/TuiBootstrapServiceTest.java index bf4e77de..5173abd6 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/TuiBootstrapServiceTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/TuiBootstrapServiceTest.java @@ -41,28 +41,10 @@ class TuiBootstrapServiceTest { // ── static accessors ────────────────────────────────────────────── @Test - void defaultBackendPreferenceUsesPanamaOnNonWindows() { - systemProperties.set("os.name", "Linux"); + void defaultBackendPreferenceIsPanamaOnAllPlatforms() { assertThat(TuiBootstrapService.defaultBackendPreference()).isEqualTo("panama"); } - @Test - void defaultBackendPreferenceDoesNotTreatDarwinAsWindows() { - systemProperties.set("os.name", "Darwin"); - assertThat(TuiBootstrapService.defaultBackendPreference()).isEqualTo("panama"); - } - - @Test - void defaultBackendPreferenceUsesJlineThenPanamaOnWindows() { - systemProperties.set("os.name", "Windows 11"); - assertThat(TuiBootstrapService.defaultBackendPreference()).isEqualTo("jline3,panama"); - } - - @Test - void defaultBackendPreferenceHandlesNullOsName() throws Exception { - assertThat(invokeDefaultBackendPreference(null)).isEqualTo("panama"); - } - @Test void startupSplashMinDurationIs450ms() { assertThat(TuiBootstrapService.STARTUP_SPLASH_MIN_DURATION).isEqualTo(Duration.ofMillis(450)); @@ -88,69 +70,14 @@ void appTuiConfigIncludesBindings() { @Test void configureTerminalBackendPreferenceSetsDefaultOnlyWhenUnset() throws Exception { - systemProperties.clear("org.jline.terminal.dumb"); - - invokeConfigureTerminalBackendPreference(null, null, "Linux"); + invokeConfigureTerminalBackendPreference(null, null); assertThat(System.getProperty("tamboui.backend")).isEqualTo("panama"); - assertThat(System.getProperty("org.jline.terminal.dumb")).isNull(); System.setProperty("tamboui.backend", "custom"); - invokeConfigureTerminalBackendPreference("custom", null, "Linux"); + invokeConfigureTerminalBackendPreference("custom", null); assertThat(System.getProperty("tamboui.backend")).isEqualTo("custom"); - assertThat(System.getProperty("org.jline.terminal.dumb")).isNull(); - } - - @Test - void configureTerminalBackendPreferenceUsesWindowsFallbackChainAndDisablesJlineDumbTerminal() - throws Exception { - invokeConfigureTerminalBackendPreference(null, null, "Windows 11"); - assertThat(System.getProperty("tamboui.backend")).isEqualTo("jline3,panama"); - assertThat(System.getProperty("org.jline.terminal.dumb")).isEqualTo("false"); - } - - @Test - void configureTerminalBackendPreferenceDisablesJlineDumbTerminalForExplicitWindowsJline() - throws Exception { - systemProperties.clear("tamboui.backend"); - - invokeConfigureTerminalBackendPreference("jline3", null, "Windows 11"); - - assertThat(System.getProperty("tamboui.backend")).isNull(); - assertThat(System.getProperty("org.jline.terminal.dumb")).isEqualTo("false"); - } - - @Test - void configureTerminalBackendPreferenceDisablesJlineDumbTerminalForEnvProvidedWindowsJline() - throws Exception { - systemProperties.clear("tamboui.backend"); - - invokeConfigureTerminalBackendPreference(null, "jline3", "Windows 11"); - - assertThat(System.getProperty("tamboui.backend")).isNull(); - assertThat(System.getProperty("org.jline.terminal.dumb")).isEqualTo("false"); - } - - @Test - void - configureTerminalBackendPreferenceLeavesJlineDumbTerminalUntouchedWhenWindowsBackendSkipsJline() - throws Exception { - systemProperties.clear("org.jline.terminal.dumb"); - - invokeConfigureTerminalBackendPreference("panama", null, "Windows 11"); - - assertThat(System.getProperty("org.jline.terminal.dumb")).isNull(); - } - - @Test - void configureTerminalBackendPreferenceDoesNotOverrideExplicitJlineDumbTerminalSetting() - throws Exception { - systemProperties.set("org.jline.terminal.dumb", "true"); - - invokeConfigureTerminalBackendPreference(null, null, "Windows 11"); - - assertThat(System.getProperty("org.jline.terminal.dumb")).isEqualTo("true"); } @Test @@ -164,18 +91,9 @@ void backendPreferenceIsExplicitlyConfiguredOnlyForNonBlankProperty() throws Exc @Test void resolveBackendPreferencePrefersPropertyThenEnvThenDefault() throws Exception { - assertThat(invokeResolveBackendPreference("jline3", "panama", "Windows 11")) - .isEqualTo("jline3"); - assertThat(invokeResolveBackendPreference(" ", "panama", "Windows 11")).isEqualTo("panama"); - assertThat(invokeResolveBackendPreference(null, null, "Windows 11")).isEqualTo("jline3,panama"); - } - - @Test - void backendPreferenceContainsHandlesBlankAndCommaSeparatedEntries() throws Exception { - assertThat(invokeBackendPreferenceContains(null, "jline3")).isFalse(); - assertThat(invokeBackendPreferenceContains(" ", "jline3")).isFalse(); - assertThat(invokeBackendPreferenceContains(" panama , jline3 ", "jline3")).isTrue(); - assertThat(invokeBackendPreferenceContains(" panama ", "jline3")).isFalse(); + assertThat(invokeResolveBackendPreference("jline3", "panama")).isEqualTo("jline3"); + assertThat(invokeResolveBackendPreference(" ", "ansi")).isEqualTo("ansi"); + assertThat(invokeResolveBackendPreference(null, null)).isEqualTo("panama"); } @Test @@ -223,27 +141,8 @@ void restoreTerminalBackendPreferenceRestoresPreviousValue() throws Exception { } @Test - void restoreJlineDumbTerminalPreferenceClearsPropertyWhenPreviousValueMissing() throws Exception { - systemProperties.set("org.jline.terminal.dumb", "false"); - - invokeRestoreJlineDumbTerminalPreference(null); - - assertThat(System.getProperty("org.jline.terminal.dumb")).isNull(); - } - - @Test - void restoreJlineDumbTerminalPreferenceRestoresPreviousValue() throws Exception { - systemProperties.set("org.jline.terminal.dumb", "false"); - - invokeRestoreJlineDumbTerminalPreference("true"); - - assertThat(System.getProperty("org.jline.terminal.dumb")).isEqualTo("true"); - } - - @Test - void runInteractiveSmokeRestoresJlineDumbTerminalPreferenceWhenBackendCreationFails() { + void runInteractiveSmokeRestoresBackendPreferenceWhenBackendCreationFails() { systemProperties.set("tamboui.backend", "missing-backend"); - systemProperties.set("org.jline.terminal.dumb", "true"); assertThatThrownBy( () -> @@ -253,13 +152,11 @@ void runInteractiveSmokeRestoresJlineDumbTerminalPreferenceWhenBackendCreationFa .hasMessageContaining("missing-backend"); assertThat(System.getProperty("tamboui.backend")).isEqualTo("missing-backend"); - assertThat(System.getProperty("org.jline.terminal.dumb")).isEqualTo("true"); } @Test - void runRestoresJlineDumbTerminalPreferenceWhenBackendCreationFails() { + void runRestoresBackendPreferenceWhenBackendCreationFails() { systemProperties.set("tamboui.backend", "missing-backend"); - systemProperties.set("org.jline.terminal.dumb", "true"); assertThatThrownBy( () -> @@ -269,7 +166,6 @@ void runRestoresJlineDumbTerminalPreferenceWhenBackendCreationFails() { .hasMessageContaining("missing-backend"); assertThat(System.getProperty("tamboui.backend")).isEqualTo("missing-backend"); - assertThat(System.getProperty("org.jline.terminal.dumb")).isEqualTo("true"); } @Test @@ -324,12 +220,12 @@ void runHeadlessSmokePropagatesCatalogLoadFailure() { } private static void invokeConfigureTerminalBackendPreference( - String propertyValue, String envValue, String osName) throws Exception { + String propertyValue, String envValue) throws Exception { Method method = TuiBootstrapService.class.getDeclaredMethod( - "configureTerminalBackendPreference", String.class, String.class, String.class); + "configureTerminalBackendPreference", String.class, String.class); method.setAccessible(true); - method.invoke(null, propertyValue, envValue, osName); + method.invoke(null, propertyValue, envValue); } private static void invokeConfigureTerminalBackendPreference() throws Exception { @@ -348,29 +244,13 @@ private static boolean invokeIsBackendPreferenceExplicitlyConfigured( return (boolean) method.invoke(null, propertyValue, envValue); } - private static String invokeDefaultBackendPreference(String osName) throws Exception { - Method method = - TuiBootstrapService.class.getDeclaredMethod("defaultBackendPreference", String.class); - method.setAccessible(true); - return (String) method.invoke(null, osName); - } - - private static String invokeResolveBackendPreference( - String propertyValue, String envValue, String osName) throws Exception { - Method method = - TuiBootstrapService.class.getDeclaredMethod( - "resolveBackendPreference", String.class, String.class, String.class); - method.setAccessible(true); - return (String) method.invoke(null, propertyValue, envValue, osName); - } - - private static boolean invokeBackendPreferenceContains( - String backendPreference, String backendName) throws Exception { + private static String invokeResolveBackendPreference(String propertyValue, String envValue) + throws Exception { Method method = TuiBootstrapService.class.getDeclaredMethod( - "backendPreferenceContains", String.class, String.class); + "resolveBackendPreference", String.class, String.class); method.setAccessible(true); - return (boolean) method.invoke(null, backendPreference, backendName); + return (String) method.invoke(null, propertyValue, envValue); } private static String invokeResolvePresetStreamKey( @@ -391,15 +271,6 @@ private static void invokeRestoreTerminalBackendPreference(String previousBacken method.invoke(null, previousBackendPreference); } - private static void invokeRestoreJlineDumbTerminalPreference(String previousJlineDumbPreference) - throws Exception { - Method method = - TuiBootstrapService.class.getDeclaredMethod( - "restoreJlineDumbTerminalPreference", String.class); - method.setAccessible(true); - method.invoke(null, previousJlineDumbPreference); - } - private RuntimeConfig runtimeConfigForTests() { return new RuntimeConfig( URI.create("http://127.0.0.1"), From 49135c5aaf8b2d459b26d7a349bb8083b4953ea6 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 25 Jun 2026 17:41:18 +0100 Subject: [PATCH 6/6] docs: document Vim motions, case-insensitive shortcuts, and validation jumps Cover the full TUI keybinding surface: Vim-style motion keys (j/k/h/l/g/G), case-insensitive letter shortcuts, Shift variants of every Ctrl/Alt shortcut, the selected-only filter (Alt+S), and invalid-field navigation (Alt+N/Alt+P). --- README.md | 4 +++- docs/modules/ROOT/pages/ui/keybindings.adoc | 10 +++++++++- docs/modules/ROOT/pages/usage/tui.adoc | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 14fcb64b..584d5842 100644 --- a/README.md +++ b/README.md @@ -111,14 +111,16 @@ $actual -eq $expected | `Ctrl+R` | Reload catalog | | `Ctrl+K` | Toggle favorites-only | | `Alt+S` | Toggle selected-only view | +| `Alt+N` / `Alt+P` | Jump to next/previous invalid field | | `v` | Cycle category filter | | `c` | Toggle current category | | `C` | Open all categories | | `x` | Clear selected extensions | +| `j` `k` `h` `l` `g` `G` | Vim-style list and hierarchy motion | | `Esc` | Unwind filter context / exit | | `Ctrl+C` | Quit immediately | -Full keybindings: [docs/modules/ROOT/pages/ui/keybindings.adoc](docs/modules/ROOT/pages/ui/keybindings.adoc) +Letter shortcuts are case-insensitive, and each `Ctrl`/`Alt` shortcut also accepts its `Shift` variant. Full keybindings: [docs/modules/ROOT/pages/ui/keybindings.adoc](docs/modules/ROOT/pages/ui/keybindings.adoc) ## Requirements diff --git a/docs/modules/ROOT/pages/ui/keybindings.adoc b/docs/modules/ROOT/pages/ui/keybindings.adoc index 54c3423e..59286cad 100644 --- a/docs/modules/ROOT/pages/ui/keybindings.adoc +++ b/docs/modules/ROOT/pages/ui/keybindings.adoc @@ -1,11 +1,13 @@ = TUI Keybindings +Bindings build on the Vim binding set, so motion keys work alongside the arrow keys. Letter shortcuts (`v`, `x`, `f`) are case-insensitive, and every `Ctrl`/`Alt` shortcut also responds to its `Shift` variant (for example `Ctrl+R` and `Ctrl+Shift+R` both reload the catalog). + == Navigation * `Tab` / `Shift+Tab`: move focus across form, search, list, and submit. * `Up` / `Down` (or `j` / `k`): list navigation. * `Left` / `Right` (or `h` / `l`): navigate category hierarchy. -* `Home` / `End`: first/last row. +* `Home` / `End` (or `g` / `G`): first/last row. * `PgUp` / `PgDn`: previous/next category. == Focus and Search @@ -24,8 +26,14 @@ * `f`: favorite focused extension. * `Ctrl+J`: jump to next favorite. * `Ctrl+K`: toggle favorites-only filter. +* `Alt+S`: toggle selected-only filter. * `Ctrl+Y`: cycle preset-only filter (presets loaded from `code.quarkus.io`). +== Form Validation + +* `Alt+N`: focus the next invalid field. +* `Alt+P`: focus the previous invalid field. + == Global Actions * `?`: open help overlay. diff --git a/docs/modules/ROOT/pages/usage/tui.adoc b/docs/modules/ROOT/pages/usage/tui.adoc index 32f230bc..54b4058d 100644 --- a/docs/modules/ROOT/pages/usage/tui.adoc +++ b/docs/modules/ROOT/pages/usage/tui.adoc @@ -46,7 +46,9 @@ NOTE: `--enable-native-access=ALL-UNNAMED` suppresses Panama FFM warnings from t * `Ctrl+P`: command palette. * `?`: help overlay. * `Ctrl+K`: toggle favorites-only mode. +* `Alt+S`: toggle selected-only mode. * `Ctrl+J`: jump to next favorite. +* `Alt+N` / `Alt+P`: jump to the next/previous invalid field. == Submit and Exit