From eb49e2a3a782158d1b71ae6fdc0e981348bc66ad Mon Sep 17 00:00:00 2001 From: Bill Date: Tue, 10 Feb 2026 05:08:02 -0700 Subject: [PATCH] Step 197: keyboard navigation audit --- PROGRESS.md | 1 + editor/CMakeLists.txt | 4 +++ editor/src/EditorState.h | 32 +++++++++++++++++++ editor/src/ThemeEngine.h | 8 +++++ editor/src/main.cpp | 8 +++++ editor/src/panels/BottomPanel.h | 7 +++++ editor/src/panels/EditorPanel.h | 7 +++++ editor/src/panels/ExplorerPanel.h | 7 +++++ editor/src/panels/SidePanels.h | 51 ++++++++++++++++++++++++++++++- editor/src/state/UIFlags.h | 10 ++++++ editor/tests/step197_test.cpp | 48 +++++++++++++++++++++++++++++ sprint6_plan.md | 2 +- 12 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 editor/tests/step197_test.cpp diff --git a/PROGRESS.md b/PROGRESS.md index 3325daf..326486c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -544,4 +544,5 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-10 | Codex | Step 194: Semantic-filtered library browser (tag filter bar, active tag chips, tag badges, context-aware completion boosting, agent tag context). 1/1 tests pass (step194_test). | | 2026-02-10 | Codex | Step 195: Security & semantic UX tests (vulnerability badges/advisories, security diagnostics severity mapping, semantic tag auto-assign, library tag filtering, vulnerable import deprioritization, safe upgrade writeback). 1/1 tests pass (step195_test). | | 2026-02-10 | Codex | Step 196: High contrast + colorblind modes (high contrast theme, annotation shapes toggle, diagnostic pattern underlines). 1/1 tests pass (step196_test). | +| 2026-02-10 | Codex | Step 197: Keyboard navigation audit (F6 panel cycle, Esc focus return, focus ring defaults, nav focus enabled on panels). 1/1 tests pass (step197_test). | | 2026-02-10 | Codex | Step 166: Extract main.cpp into panel headers marked complete (already implemented). step166_test passes; file_limits_test 4/4 passes. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 3476da8..00b7b86 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1128,6 +1128,10 @@ target_include_directories(step195_test PRIVATE src) add_executable(step196_test tests/step196_test.cpp) target_include_directories(step196_test PRIVATE src) +add_executable(step197_test tests/step197_test.cpp) +target_include_directories(step197_test PRIVATE src) + + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index 68f963b..2b60072 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -932,6 +932,38 @@ struct EditorState { } } + bool hasSidePanels() const { + if (ui.showOutline) return true; + if (library.showDependencyPanel) return true; + if (library.showLibraryBrowserPanel) return true; + if (library.showCompositionPanel) return true; + if (emacsState.showEmacsPackagesPanel) return true; + if (emacsState.showEmacsBridgePanel) return true; + return true; // Memory Strategies panel is always present. + } + + void cyclePanelFocus() { + FocusRegion order[] = { + FocusRegion::Editor, + FocusRegion::Explorer, + FocusRegion::Side, + FocusRegion::Bottom + }; + int current = 0; + for (int i = 0; i < 4; ++i) { + if (ui.focusedRegion == order[i]) { + current = i; + break; + } + } + for (int step = 1; step <= 4; ++step) { + FocusRegion next = order[(current + step) % 4]; + if (next == FocusRegion::Side && !hasSidePanels()) continue; + ui.focusTarget = next; + return; + } + } + void applySettingsToState() { ui.showMinimap = settings.getShowMinimap(); ui.showLineNumbers = settings.getShowLineNumbers(); diff --git a/editor/src/ThemeEngine.h b/editor/src/ThemeEngine.h index 1bd77d1..3401f38 100644 --- a/editor/src/ThemeEngine.h +++ b/editor/src/ThemeEngine.h @@ -274,6 +274,8 @@ private: if (name == "Separator") return ImGuiCol_Separator; if (name == "SeparatorHovered") return ImGuiCol_SeparatorHovered; if (name == "SeparatorActive") return ImGuiCol_SeparatorActive; + if (name == "NavHighlight") return ImGuiCol_NavHighlight; + if (name == "NavWindowingHighlight") return ImGuiCol_NavWindowingHighlight; return -1; } @@ -345,6 +347,12 @@ private: style.Colors[colId] = color; } } + if (theme.imguiColors.find(ImGuiCol_NavHighlight) == theme.imguiColors.end()) { + style.Colors[ImGuiCol_NavHighlight] = ImVec4(1.0f, 0.85f, 0.2f, 1.0f); + } + if (theme.imguiColors.find(ImGuiCol_NavWindowingHighlight) == theme.imguiColors.end()) { + style.Colors[ImGuiCol_NavWindowingHighlight] = ImVec4(1.0f, 0.85f, 0.2f, 1.0f); + } style.WindowPadding = theme.panelPadding; style.ItemSpacing = theme.panelSpacing; style.WindowBorderSize = theme.windowBorderSize; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index 88ff182..0145df5 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -258,6 +258,14 @@ int main(int, char**) { if (sdlMod & KMOD_SHIFT) mods |= WMOD_SHIFT; if (sdlMod & KMOD_ALT) mods |= WMOD_ALT; + if (sym == SDLK_ESCAPE && mods == WMOD_NONE) { + state.ui.focusTarget = FocusRegion::Editor; + } + + if (sym == SDLK_F6 && mods == WMOD_NONE) { + state.cyclePanelFocus(); + } + if ((sdlMod & KMOD_CTRL) && (sdlMod & KMOD_SHIFT) && sym == SDLK_p) { state.showCommandPalette = true; state.commandQuery[0] = '\0'; diff --git a/editor/src/panels/BottomPanel.h b/editor/src/panels/BottomPanel.h index 6f4ecdf..ebcb220 100644 --- a/editor/src/panels/BottomPanel.h +++ b/editor/src/panels/BottomPanel.h @@ -52,10 +52,17 @@ static std::vector collectDiagnostics(const EditorState& state } static void renderBottomPanel(EditorState& state) { + if (state.ui.focusTarget == FocusRegion::Bottom) { + ImGui::SetNextWindowFocus(); + state.ui.focusTarget = FocusRegion::None; + } // --------------------------------------------------------------- // Bottom panel — Output / AST / Highlighted Preview / Terminal // --------------------------------------------------------------- ImGui::Begin("Panel"); + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { + state.ui.focusedRegion = FocusRegion::Bottom; + } if (ImGui::BeginTabBar("PanelTabs")) { // Output log diff --git a/editor/src/panels/EditorPanel.h b/editor/src/panels/EditorPanel.h index f821135..7c2b1df 100644 --- a/editor/src/panels/EditorPanel.h +++ b/editor/src/panels/EditorPanel.h @@ -9,6 +9,10 @@ #include static void renderEditorPanel(EditorState& state) { + if (state.ui.focusTarget == FocusRegion::Editor) { + ImGui::SetNextWindowFocus(); + state.ui.focusTarget = FocusRegion::None; + } if (state.ui.showAnnotations) { queueFeatureHint(state.featureHints, "hint.annotations", @@ -18,6 +22,9 @@ static void renderEditorPanel(EditorState& state) { // Editor (center) -- editable text area // --------------------------------------------------------------- ImGui::Begin("Editor"); + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { + state.ui.focusedRegion = FocusRegion::Editor; + } ImGui::PushFont(state.uiFont); ImGui::BeginChild("##breadcrumbs", ImVec2(0, 26.0f), false, ImGuiWindowFlags_NoScrollbar); if (!state.active()) { diff --git a/editor/src/panels/ExplorerPanel.h b/editor/src/panels/ExplorerPanel.h index 5f793fb..373780b 100644 --- a/editor/src/panels/ExplorerPanel.h +++ b/editor/src/panels/ExplorerPanel.h @@ -3,7 +3,14 @@ #include "../EditorUtils.h" static void renderExplorerPanel(EditorState& state) { + if (state.ui.focusTarget == FocusRegion::Explorer) { + ImGui::SetNextWindowFocus(); + state.ui.focusTarget = FocusRegion::None; + } ImGui::Begin("Explorer"); + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { + state.ui.focusedRegion = FocusRegion::Explorer; + } ImGui::PushFont(state.uiFont); ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "OPEN EDITORS"); ImGui::Separator(); diff --git a/editor/src/panels/SidePanels.h b/editor/src/panels/SidePanels.h index 0dd39bd..1ee55f8 100644 --- a/editor/src/panels/SidePanels.h +++ b/editor/src/panels/SidePanels.h @@ -4,7 +4,14 @@ static void renderOutlinePanel(EditorState& state) { if (!state.ui.showOutline) return; + if (state.ui.focusTarget == FocusRegion::Side) { + ImGui::SetNextWindowFocus(); + state.ui.focusTarget = FocusRegion::None; + } ImGui::Begin("Outline", &state.ui.showOutline); + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { + state.ui.focusedRegion = FocusRegion::Side; + } queueFeatureHint(state.featureHints, "hint.outline", "Tip: Use the Outline to jump between symbols quickly."); @@ -81,7 +88,14 @@ static void renderOutlinePanel(EditorState& state) { static void renderDependenciesPanel(EditorState& state) { if (!state.library.showDependencyPanel) return; + if (state.ui.focusTarget == FocusRegion::Side) { + ImGui::SetNextWindowFocus(); + state.ui.focusTarget = FocusRegion::None; + } ImGui::Begin("Dependencies", &state.library.showDependencyPanel); + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { + state.ui.focusedRegion = FocusRegion::Side; + } ImGui::PushFont(state.uiFont); renderDependencyPanel(state.library.dependencyPanel, state.workspaceRoot, @@ -99,7 +113,14 @@ static void renderDependenciesPanel(EditorState& state) { static void renderLibrariesPanel(EditorState& state) { if (!state.library.showLibraryBrowserPanel) return; + if (state.ui.focusTarget == FocusRegion::Side) { + ImGui::SetNextWindowFocus(); + state.ui.focusTarget = FocusRegion::None; + } ImGui::Begin("Libraries", &state.library.showLibraryBrowserPanel); + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { + state.ui.focusedRegion = FocusRegion::Side; + } ImGui::PushFont(state.uiFont); std::string insertText; std::string insertLibrary; @@ -118,7 +139,14 @@ static void renderLibrariesPanel(EditorState& state) { static void renderCompositionPanel(EditorState& state) { if (!state.library.showCompositionPanel) return; + if (state.ui.focusTarget == FocusRegion::Side) { + ImGui::SetNextWindowFocus(); + state.ui.focusTarget = FocusRegion::None; + } ImGui::Begin("Compose", &state.library.showCompositionPanel); + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { + state.ui.focusedRegion = FocusRegion::Side; + } ImGui::PushFont(state.uiFont); std::string nodeId; if (state.activeAST()) { @@ -140,7 +168,14 @@ static void renderCompositionPanel(EditorState& state) { static void renderEmacsPackagesPanel(EditorState& state) { if (!state.emacsState.showEmacsPackagesPanel) return; + if (state.ui.focusTarget == FocusRegion::Side) { + ImGui::SetNextWindowFocus(); + state.ui.focusTarget = FocusRegion::None; + } ImGui::Begin("Emacs Packages", &state.emacsState.showEmacsPackagesPanel); + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { + state.ui.focusedRegion = FocusRegion::Side; + } ImGui::PushFont(state.uiFont); if (renderEmacsPackageBrowser(state.emacsState.emacsPackages, state.emacsState.emacs, @@ -153,7 +188,14 @@ static void renderEmacsPackagesPanel(EditorState& state) { static void renderEmacsBridgePanel(EditorState& state) { if (!state.emacsState.showEmacsBridgePanel) return; + if (state.ui.focusTarget == FocusRegion::Side) { + ImGui::SetNextWindowFocus(); + state.ui.focusTarget = FocusRegion::None; + } ImGui::Begin("Emacs Bridge", &state.emacsState.showEmacsBridgePanel); + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { + state.ui.focusedRegion = FocusRegion::Side; + } ImGui::PushFont(state.uiFont); if (state.active()) { ImGui::Text("Active file: %s", state.active()->path.c_str()); @@ -182,7 +224,7 @@ static void renderMinibuffer(EditorState& state) { const ImGuiViewport* viewport = ImGui::GetMainViewport(); ImGuiWindowFlags mbFlags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoNavFocus; + ImGuiWindowFlags_NoScrollbar; float mbHeight = ImGui::GetFrameHeight() + 10; ImVec2 mbPos(viewport->WorkPos.x, viewport->WorkPos.y + viewport->WorkSize.y - mbHeight - 24.0f); @@ -207,7 +249,14 @@ static void renderMinibuffer(EditorState& state) { } static void renderMemoryStrategiesPanel(EditorState& state) { + if (state.ui.focusTarget == FocusRegion::Side) { + ImGui::SetNextWindowFocus(); + state.ui.focusTarget = FocusRegion::None; + } ImGui::Begin("Memory Strategies"); + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { + state.ui.focusedRegion = FocusRegion::Side; + } ImGui::PushFont(state.uiFont); Module* ast = state.active() ? state.active()->sync.getAST() : nullptr; if (state.active() && state.active()->bufferMode == BufferManager::BufferMode::Text) { diff --git a/editor/src/state/UIFlags.h b/editor/src/state/UIFlags.h index 7056b56..d7f2e8f 100644 --- a/editor/src/state/UIFlags.h +++ b/editor/src/state/UIFlags.h @@ -1,6 +1,14 @@ #pragma once #include "LayoutManager.h" +enum class FocusRegion { + None, + Editor, + Explorer, + Side, + Bottom +}; + struct UIFlags { bool showWhitespace = false; bool showMinimap = false; @@ -17,4 +25,6 @@ struct UIFlags { bool showAgentWizard = false; int bottomTab = 0; // 0=Output,1=AST,2=Highlighted LayoutPreset layoutPreset = LayoutPreset::VSCode; + FocusRegion focusTarget = FocusRegion::None; + FocusRegion focusedRegion = FocusRegion::Editor; }; diff --git a/editor/tests/step197_test.cpp b/editor/tests/step197_test.cpp new file mode 100644 index 0000000..a02ca64 --- /dev/null +++ b/editor/tests/step197_test.cpp @@ -0,0 +1,48 @@ +// Step 197: Keyboard navigation completeness audit. + +#include +#include +#include + +static std::string readFile(const std::string& path) { + std::ifstream f(path); + if (!f.is_open()) return {}; + return std::string((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); +} + +static void assertContains(const std::string& text, const std::string& needle) { + assert(text.find(needle) != std::string::npos); +} + +static void assertNotContains(const std::string& text, const std::string& needle) { + assert(text.find(needle) == std::string::npos); +} + +int main() { + const std::string uiFlags = readFile("src/state/UIFlags.h"); + const std::string editorState = readFile("src/EditorState.h"); + const std::string mainSrc = readFile("src/main.cpp"); + const std::string theme = readFile("src/ThemeEngine.h"); + const std::string explorer = readFile("src/panels/ExplorerPanel.h"); + const std::string editor = readFile("src/panels/EditorPanel.h"); + const std::string bottom = readFile("src/panels/BottomPanel.h"); + const std::string side = readFile("src/panels/SidePanels.h"); + + assertContains(uiFlags, "FocusRegion"); + assertContains(uiFlags, "focusTarget"); + assertContains(editorState, "cyclePanelFocus"); + assertContains(mainSrc, "SDLK_F6"); + assertContains(mainSrc, "SDLK_ESCAPE"); + assertContains(theme, "NavHighlight"); + assertContains(theme, "NavWindowingHighlight"); + + assertContains(explorer, "SetNextWindowFocus"); + assertContains(editor, "SetNextWindowFocus"); + assertContains(bottom, "SetNextWindowFocus"); + assertContains(side, "SetNextWindowFocus"); + assertNotContains(side, "NoNavFocus"); + + printf("step197_test: all assertions passed\n"); + return 0; +} diff --git a/sprint6_plan.md b/sprint6_plan.md index de3e006..111d3f9 100644 --- a/sprint6_plan.md +++ b/sprint6_plan.md @@ -393,7 +393,7 @@ Make Whetstone usable by everyone and fast with large files. - Test: all information conveyed by color also conveyed by shape/pattern *New:* High contrast theme. *Modifies:* annotation/diagnostic rendering -- [ ] **Step 197: Keyboard navigation completeness audit** +- [x] **Step 197: Keyboard navigation completeness audit** Ensure every feature is keyboard-accessible: - All panels navigable with Tab/Shift+Tab - Focus ring indicator on active element (visible in all themes)