diff --git a/PROGRESS.md b/PROGRESS.md index 800cc28..47c9903 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -523,4 +523,5 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-10 | Codex | Step 173: Theme gallery UI with hover preview, apply/reset, and swatches. 2/2 tests pass (step173_test, step173_integration_test). file_limits_test 4/4 passes. | | 2026-02-10 | Codex | Step 174: Added IconSet system with theme-aware, zoom-scaled icons + tests. 2/2 tests pass (step174_test, step174_integration_test). file_limits_test 4/4 passes. | | 2026-02-10 | Codex | Step 175: Typography controls (fonts, line height, letter spacing) + theme layout spacing. 2/2 tests pass (step175_test, step175_integration_test). file_limits_test 4/4 passes. | +| 2026-02-10 | Codex | Step 176: Smooth UI transitions (panel slides, tab fade, tooltip fade, toast animations, search pulse, cursor blink) + reduce-motion/cursor blink settings. 2/2 tests pass (step176_test, step176_integration_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 a04e555..185557e 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1016,6 +1016,13 @@ add_executable(step175_integration_test tests/step175_integration_test.cpp) target_include_directories(step175_integration_test PRIVATE src) target_link_libraries(step175_integration_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step176_test tests/step176_test.cpp) +target_include_directories(step176_test PRIVATE src) + +add_executable(step176_integration_test tests/step176_integration_test.cpp) +target_include_directories(step176_integration_test PRIVATE src) +target_link_libraries(step176_integration_test PRIVATE nlohmann_json::nlohmann_json) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/AnimationUtils.h b/editor/src/AnimationUtils.h new file mode 100644 index 0000000..bb93bbd --- /dev/null +++ b/editor/src/AnimationUtils.h @@ -0,0 +1,141 @@ +#pragma once + +#include "imgui.h" + +#include +#include +#include +#include + +namespace AnimationUtils { +inline float clamp01(float v) { + return std::max(0.0f, std::min(1.0f, v)); +} + +inline float lerp(float a, float b, float t) { + return a + (b - a) * t; +} + +inline float easeOutCubic(float t) { + t = clamp01(t); + float inv = 1.0f - t; + return 1.0f - inv * inv * inv; +} + +inline float easeInOut(float t) { + t = clamp01(t); + return t < 0.5f ? 2.0f * t * t : 1.0f - std::pow(-2.0f * t + 2.0f, 2.0f) / 2.0f; +} + +inline float approach(float current, float target, float delta) { + if (current < target) return std::min(target, current + delta); + return std::max(target, current - delta); +} + +inline float blinkAlpha(double nowSeconds, float rateHz, bool reduceMotion) { + if (reduceMotion || rateHz <= 0.0f) return 1.0f; + const float phase = std::fmod((float)nowSeconds * rateHz, 1.0f); + const float wave = 0.5f + 0.5f * std::sin(phase * 6.2831853f); + return clamp01(wave); +} + +inline float pulseAlpha(double nowSeconds, double startSeconds, float duration, bool reduceMotion) { + if (reduceMotion) return 1.0f; + if (startSeconds <= 0.0) return 0.0f; + float t = (float)((nowSeconds - startSeconds) / std::max(0.001f, duration)); + if (t < 0.0f || t > 1.0f) return 0.0f; + float eased = easeOutCubic(t); + return 1.0f - eased; +} + +struct TooltipState { + double hoverStart = -1.0; + double lastUpdate = 0.0; + float alpha = 0.0f; + bool hovered = false; +}; + +inline std::unordered_map& tooltipStates() { + static std::unordered_map states; + return states; +} + +inline float tooltipAlpha(const std::string& id, + bool hovered, + double nowSeconds, + bool reduceMotion, + float delay = 0.45f, + float fadeIn = 0.12f, + float fadeOut = 0.12f) { + if (reduceMotion) return hovered ? 1.0f : 0.0f; + auto& state = tooltipStates()[id]; + if (state.lastUpdate <= 0.0) state.lastUpdate = nowSeconds; + const float dt = (float)(nowSeconds - state.lastUpdate); + state.lastUpdate = nowSeconds; + + if (hovered) { + if (!state.hovered) { + state.hovered = true; + state.hoverStart = nowSeconds; + } + float target = (nowSeconds - state.hoverStart) >= delay ? 1.0f : 0.0f; + float step = (target > state.alpha) + ? (dt / std::max(0.001f, fadeIn)) + : (dt / std::max(0.001f, fadeOut)); + state.alpha = approach(state.alpha, target, step); + } else { + if (state.hovered) { + state.hovered = false; + } + float step = dt / std::max(0.001f, fadeOut); + state.alpha = approach(state.alpha, 0.0f, step); + } + return clamp01(state.alpha); +} + +struct PanelState { + bool visible = false; + double toggleTime = 0.0; + bool animating = false; +}; + +inline std::unordered_map& panelStates() { + static std::unordered_map states; + return states; +} + +inline bool panelTransition(const std::string& id, + bool targetVisible, + double nowSeconds, + bool reduceMotion, + float duration, + float slideDistance, + float& outAlpha, + float& outOffset) { + auto& state = panelStates()[id]; + if (state.visible != targetVisible) { + state.visible = targetVisible; + state.toggleTime = nowSeconds; + state.animating = true; + } + if (reduceMotion) { + outAlpha = targetVisible ? 1.0f : 0.0f; + outOffset = 0.0f; + state.animating = false; + return targetVisible; + } + float t = (float)((nowSeconds - state.toggleTime) / std::max(0.001f, duration)); + if (t >= 1.0f) { + t = 1.0f; + state.animating = false; + } + if (state.visible) { + outAlpha = easeOutCubic(t); + outOffset = (1.0f - outAlpha) * slideDistance; + } else { + outAlpha = 1.0f - easeOutCubic(t); + outOffset = (1.0f - outAlpha) * slideDistance; + } + return state.visible || state.animating; +} +} // namespace AnimationUtils diff --git a/editor/src/CodeEditorRendering.h b/editor/src/CodeEditorRendering.h index 536b727..77d3591 100644 --- a/editor/src/CodeEditorRendering.h +++ b/editor/src/CodeEditorRendering.h @@ -11,6 +11,15 @@ public: if (!font) font = ImGui::GetFont(); ImGuiIO& io = ImGui::GetIO(); + const double nowSeconds = options.nowSeconds > 0.0 ? options.nowSeconds : ImGui::GetTime(); + auto beginFadingTooltip = [&](const std::string& id, bool hovered) -> bool { + float alpha = AnimationUtils::tooltipAlpha(id, hovered, nowSeconds, options.reduceMotion); + if (alpha <= 0.01f) return false; + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * alpha); + ImGui::BeginTooltip(); + return true; + }; + static std::string lastDiagHoverMessage; // Build line start offsets std::vector lineStarts; lineStarts.reserve(128); @@ -233,10 +242,13 @@ public: std::string msg = diagnosticMessageAtLine(*options.diagnostics, ln); ImVec2 gutterA(origin.x, y); ImVec2 gutterB(origin.x + gutterWidth, y + lineHeight); - if (!msg.empty() && ImGui::IsMouseHoveringRect(gutterA, gutterB)) { - ImGui::BeginTooltip(); - ImGui::TextUnformatted(msg.c_str()); - ImGui::EndTooltip(); + if (!msg.empty()) { + bool hover = ImGui::IsMouseHoveringRect(gutterA, gutterB); + if (beginFadingTooltip("diag_gutter_" + std::to_string(ln), hover)) { + ImGui::TextUnformatted(msg.c_str()); + ImGui::EndTooltip(); + ImGui::PopStyleVar(); + } } } @@ -248,14 +260,17 @@ public: drawList->AddCircleFilled(center, 3.0f, marker.color); ImVec2 a(center.x - 4.0f, center.y - 4.0f); ImVec2 b(center.x + 4.0f, center.y + 4.0f); - if (ImGui::IsMouseHoveringRect(a, b)) { + bool hoverAnno = ImGui::IsMouseHoveringRect(a, b); + if (hoverAnno) { if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { annotationPopupMessage_ = marker.message; ImGui::OpenPopup(annoPopupId.c_str()); } - ImGui::BeginTooltip(); + } + if (beginFadingTooltip("anno_marker_" + std::to_string(ln), hoverAnno)) { ImGui::TextUnformatted(marker.message.c_str()); ImGui::EndTooltip(); + ImGui::PopStyleVar(); } } } @@ -270,16 +285,19 @@ public: drawList->AddCircleFilled(center, 3.0f, color); ImVec2 a(center.x - 4.0f, center.y - 4.0f); ImVec2 b(center.x + 4.0f, center.y + 4.0f); - if (ImGui::IsMouseHoveringRect(a, b)) { + bool hoverSuggestion = ImGui::IsMouseHoveringRect(a, b); + if (hoverSuggestion) { if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { result.suggestionClicked = true; result.clickedSuggestion = suggestion; } - ImGui::BeginTooltip(); + } + if (beginFadingTooltip("suggestion_marker_" + std::to_string(ln), hoverSuggestion)) { ImGui::Text("%s (%.2f)", suggestion.label.c_str(), suggestion.confidence); ImGui::Separator(); ImGui::TextUnformatted(suggestion.reason.c_str()); ImGui::EndTooltip(); + ImGui::PopStyleVar(); } } } @@ -305,10 +323,11 @@ public: } ImVec2 a(center.x - 5.0f, center.y - 5.0f); ImVec2 b(center.x + 5.0f, center.y + 5.0f); - if (ImGui::IsMouseHoveringRect(a, b)) { - ImGui::BeginTooltip(); + bool hoverConflict = ImGui::IsMouseHoveringRect(a, b); + if (beginFadingTooltip("conflict_marker_" + std::to_string(ln), hoverConflict)) { ImGui::TextUnformatted(conflict.message.c_str()); ImGui::EndTooltip(); + ImGui::PopStyleVar(); } } } @@ -351,6 +370,22 @@ public: drawList->AddRectFilled(hlA, hlB, options.highlightLineColor); } } + if (options.searchPulseLine >= 0 && ln == options.searchPulseLine) { + float pulse = AnimationUtils::pulseAlpha(nowSeconds, + options.searchPulseStart, + 0.6f, + options.reduceMotion); + if (pulse > 0.0f) { + ImU32 base = ThemeEngine::instance().editorColor("search_pulse", + IM_COL32(240, 200, 80, 160)); + ImVec4 c = ImGui::ColorConvertU32ToFloat4(base); + c.w *= pulse; + ImU32 col = ImGui::ColorConvertFloat4ToU32(c); + ImVec2 hlA(textBase.x, y); + ImVec2 hlB(textBase.x + textAreaWidth, y + lineHeight); + drawList->AddRectFilled(hlA, hlB, col); + } + } // Selection background if (hasSelection()) { @@ -439,10 +474,16 @@ public: ImVec2 mouse = ImGui::GetMousePos(); std::string msg = diagnosticMessageAtPoint(*options.diagnostics, ln, mouse, y, lineHeight, charAdvance, textBase.x); - if (!msg.empty()) { - ImGui::BeginTooltip(); - ImGui::TextUnformatted(msg.c_str()); + bool hoverDiag = !msg.empty(); + if (hoverDiag) { + lastDiagHoverMessage = msg; + } + if (beginFadingTooltip("diag_point", hoverDiag)) { + if (!lastDiagHoverMessage.empty()) { + ImGui::TextUnformatted(lastDiagHoverMessage.c_str()); + } ImGui::EndTooltip(); + ImGui::PopStyleVar(); } } } @@ -457,18 +498,21 @@ public: // Cursor if (focused) { - const double t = ImGui::GetTime(); - const bool blinkOn = ((int)(t * 2.0)) % 2 == 0; - if (blinkOn) { + float blinkAlpha = AnimationUtils::blinkAlpha(nowSeconds, + options.cursorBlinkRate, + options.reduceMotion); + if (blinkAlpha > 0.02f) { int curLine = currentLine; int lineStart = lineStarts[curLine]; int col = cursor_ - lineStart; float x = textBase.x + col * charAdvance; float y = textBase.y + curLine * lineHeight; - drawList->AddLine(ImVec2(x, y), ImVec2(x, y + lineHeight), - ThemeEngine::instance().editorColor("caret", - IM_COL32(240, 240, 240, 255)), - 1.0f); + ImU32 base = ThemeEngine::instance().editorColor("caret", + IM_COL32(240, 240, 240, 255)); + ImVec4 c = ImGui::ColorConvertU32ToFloat4(base); + c.w *= blinkAlpha; + ImU32 col32 = ImGui::ColorConvertFloat4ToU32(c); + drawList->AddLine(ImVec2(x, y), ImVec2(x, y + lineHeight), col32, 1.0f); } } diff --git a/editor/src/CodeEditorWidget.h b/editor/src/CodeEditorWidget.h index 2ab7416..5145cbc 100644 --- a/editor/src/CodeEditorWidget.h +++ b/editor/src/CodeEditorWidget.h @@ -9,6 +9,7 @@ #include "imgui.h" #include "SyntaxHighlighter.h" #include "ThemeEngine.h" +#include "AnimationUtils.h" #include "EditorMode.h" #include #include @@ -27,6 +28,9 @@ struct CodeEditorOptions { bool showCurrentLine = true; float lineHeightScale = 1.0f; float letterSpacing = 0.0f; + float cursorBlinkRate = 2.0f; + bool reduceMotion = false; + double nowSeconds = 0.0; int annotationLayout = 0; // 0=above, 1=margin, 2=beside const std::vector* errorLines = nullptr; const std::vector* warningLines = nullptr; @@ -36,6 +40,8 @@ struct CodeEditorOptions { const std::vector* conflicts = nullptr; int highlightLine = -1; const std::vector* highlightLines = nullptr; + int searchPulseLine = -1; + double searchPulseStart = 0.0; ImU32 highlightLineColor = IM_COL32(90, 70, 30, 120); float* syncScrollX = nullptr; float* syncScrollY = nullptr; diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index 85d24a3..89f6394 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -57,6 +57,7 @@ #include "state/LibraryState.h" #include "state/EmacsState.h" #include "state/UIFlags.h" +#include "state/UIAnimationState.h" #include "DependencyPanel.h" #include "LibraryIndexer.h" #include "LibraryBrowserPanel.h" @@ -174,6 +175,7 @@ struct EditorState { LibraryState library; EmacsState emacsState; UIFlags ui; + UIAnimationState uiAnimations; NotificationSystem notifications; UIEventBus events; @@ -459,6 +461,7 @@ struct EditorState { lsp->didOpen(toFileUri(path), language, content, activeBuffer->lspVersion); } events.publish(UIEventType::BufferSwitched, path, {}, ImGui::GetTime()); + uiAnimations.recordTabSwitch(path, ImGui::GetTime()); } bool jumpToDefinitionLocation(const LSPClient::DefinitionLocation& loc) { @@ -540,6 +543,7 @@ struct EditorState { activeBuffer = bufferStates[path].get(); if (active()) active()->orchestratorDirty = true; events.publish(UIEventType::BufferSwitched, path, {}, ImGui::GetTime()); + uiAnimations.recordTabSwitch(path, ImGui::GetTime()); } std::filesystem::path configDir() const { @@ -1900,12 +1904,16 @@ struct EditorState { target.path = active()->path; target.line = std::max(0, line - 1); target.col = std::max(0, col - 1); + search.pulseLine = target.line; + search.pulseStart = ImGui::GetTime(); notify(NotificationLevel::Info, "Found \"" + std::string(search.findBuf) + "\" at line " + std::to_string(line) + ", col " + std::to_string(col), target); } else { search.lastFindPos = 0; + search.pulseLine = -1; + search.pulseStart = 0.0; notify(NotificationLevel::Warning, "\"" + std::string(search.findBuf) + "\" not found."); } diff --git a/editor/src/NotificationSystem.h b/editor/src/NotificationSystem.h index 0772112..5492311 100644 --- a/editor/src/NotificationSystem.h +++ b/editor/src/NotificationSystem.h @@ -1,6 +1,7 @@ #pragma once #include "imgui.h" +#include "AnimationUtils.h" #include #include @@ -93,13 +94,17 @@ public: return history; } - void renderToasts(const std::function& onNavigate = {}) { + void renderToasts(const std::function& onNavigate = {}, + bool reduceMotion = false) { const double now = ImGui::GetTime(); const ImGuiViewport* viewport = ImGui::GetMainViewport(); const ImVec2 base(viewport->WorkPos.x + viewport->WorkSize.x, viewport->WorkPos.y + viewport->WorkSize.y); const float padding = 12.0f; const float toastWidth = 360.0f; + const float fadeIn = 0.18f; + const float fadeOut = 0.25f; + const float slidePixels = 28.0f; float y = base.y - padding; for (int i = (int)history.size() - 1; i >= 0; --i) { @@ -113,9 +118,24 @@ public: ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoSavedSettings; - ImGui::SetNextWindowPos(ImVec2(base.x - padding, y), ImGuiCond_Always, ImVec2(1.0f, 1.0f)); + float age = (float)(now - n.timestamp); + float alpha = 1.0f; + float slide = 0.0f; + if (!reduceMotion) { + float inT = AnimationUtils::clamp01(age / fadeIn); + float outT = AnimationUtils::clamp01((age - (n.durationSeconds - fadeOut)) / fadeOut); + alpha = AnimationUtils::easeOutCubic(inT); + if (age > n.durationSeconds - fadeOut) { + alpha *= (1.0f - AnimationUtils::easeOutCubic(outT)); + } + slide = (1.0f - AnimationUtils::easeOutCubic(inT)) * slidePixels; + } + ImGui::SetNextWindowPos(ImVec2(base.x - padding + slide, y), + ImGuiCond_Always, + ImVec2(1.0f, 1.0f)); ImGui::SetNextWindowSize(ImVec2(toastWidth, 0.0f), ImGuiCond_Always); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12, 10)); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * alpha); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.08f, 0.92f)); ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.22f, 0.22f, 0.22f, 0.9f)); ImGui::Begin(("##toast_" + std::to_string(n.id)).c_str(), nullptr, flags); @@ -138,7 +158,7 @@ public: ImVec2 size = ImGui::GetWindowSize(); ImGui::End(); ImGui::PopStyleColor(2); - ImGui::PopStyleVar(); + ImGui::PopStyleVar(2); if (clicked) { n.read = true; diff --git a/editor/src/SettingsManager.h b/editor/src/SettingsManager.h index e178665..9ed7f47 100644 --- a/editor/src/SettingsManager.h +++ b/editor/src/SettingsManager.h @@ -38,6 +38,8 @@ public: void setLineHeightScale(float value) { lineHeightScale_ = value; } float getLetterSpacing() const { return letterSpacing_; } void setLetterSpacing(float value) { letterSpacing_ = value; } + float getCursorBlinkRate() const { return cursorBlinkRate_; } + void setCursorBlinkRate(float value) { cursorBlinkRate_ = value; } int getTabSize() const { return tabSize_; } void setTabSize(int size) { tabSize_ = size; } const std::string& getTheme() const { return theme_; } @@ -54,6 +56,8 @@ public: void setShowMinimap(bool value) { showMinimap_ = value; } bool getShowLineNumbers() const { return showLineNumbers_; } void setShowLineNumbers(bool value) { showLineNumbers_ = value; } + bool getReduceMotion() const { return reduceMotion_; } + void setReduceMotion(bool value) { reduceMotion_ = value; } const std::string& getLayoutPreset() const { return layoutPreset_; } void setLayoutPreset(const std::string& name) { layoutPreset_ = name; } const std::string& getKeybindingProfile() const { return keybindingProfile_; } @@ -70,6 +74,7 @@ public: uiFontPath_ = j.value("uiFont", uiFontPath_); lineHeightScale_ = j.value("lineHeight", lineHeightScale_); letterSpacing_ = j.value("letterSpacing", letterSpacing_); + cursorBlinkRate_ = j.value("cursorBlinkRate", cursorBlinkRate_); tabSize_ = j.value("tabSize", tabSize_); theme_ = j.value("theme", theme_); telemetryOptIn_ = j.value("telemetryOptIn", telemetryOptIn_); @@ -77,6 +82,7 @@ public: autoSaveSeconds_ = j.value("autoSaveSeconds", autoSaveSeconds_); showMinimap_ = j.value("showMinimap", showMinimap_); showLineNumbers_ = j.value("showLineNumbers", showLineNumbers_); + reduceMotion_ = j.value("reduceMotion", reduceMotion_); layoutPreset_ = j.value("layoutPreset", layoutPreset_); keybindingProfile_ = j.value("keybindingProfile", keybindingProfile_); emacsConfigPath_ = j.value("emacsConfigPath", emacsConfigPath_); @@ -106,6 +112,7 @@ public: j["uiFont"] = uiFontPath_; j["lineHeight"] = lineHeightScale_; j["letterSpacing"] = letterSpacing_; + j["cursorBlinkRate"] = cursorBlinkRate_; j["tabSize"] = tabSize_; j["theme"] = theme_; j["telemetryOptIn"] = telemetryOptIn_; @@ -113,6 +120,7 @@ public: j["autoSaveSeconds"] = autoSaveSeconds_; j["showMinimap"] = showMinimap_; j["showLineNumbers"] = showLineNumbers_; + j["reduceMotion"] = reduceMotion_; j["layoutPreset"] = layoutPreset_; j["keybindingProfile"] = keybindingProfile_; j["emacsConfigPath"] = emacsConfigPath_; @@ -179,6 +187,7 @@ private: std::string uiFontPath_; float lineHeightScale_ = 1.0f; float letterSpacing_ = 0.0f; + float cursorBlinkRate_ = 2.0f; int tabSize_ = 4; std::string theme_ = "Whetstone Dark"; bool telemetryOptIn_ = false; @@ -186,6 +195,7 @@ private: int autoSaveSeconds_ = 0; bool showMinimap_ = false; bool showLineNumbers_ = true; + bool reduceMotion_ = false; std::string layoutPreset_ = "VSCode"; std::string keybindingProfile_ = "VSCode"; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index aacc6e3..1d2267d 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -366,7 +366,7 @@ int main(int, char**) { }); state.notifications.renderToasts([&](const Notification& note) { if (note.hasTarget) state.navigateToTarget(note.target); - }); + }, state.settings.getReduceMotion()); // Check for exit request from menu if (state.exitRequested) done = true; diff --git a/editor/src/panels/EditorPanel.h b/editor/src/panels/EditorPanel.h index f663b5b..6fda419 100644 --- a/editor/src/panels/EditorPanel.h +++ b/editor/src/panels/EditorPanel.h @@ -3,6 +3,7 @@ #include "../EditorState.h" #include "../EditorUtils.h" #include "../CompletionUtils.h" +#include "../AnimationUtils.h" static void renderEditorPanel(EditorState& state) { // --------------------------------------------------------------- @@ -69,6 +70,12 @@ static void renderEditorPanel(EditorState& state) { if (!state.active() || state.active()->path != path) { state.switchToBuffer(path); } + const double nowSeconds = ImGui::GetTime(); + float tabAlpha = state.uiAnimations.tabFadeAlpha(path, + nowSeconds, + state.settings.getReduceMotion()); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, + ImGui::GetStyle().Alpha * tabAlpha); // Editable text area fills available space ImVec2 avail = ImGui::GetContentRegionAvail(); avail.y -= 4; // small margin @@ -177,6 +184,9 @@ static void renderEditorPanel(EditorState& state) { opts.showLineNumbers = state.ui.showLineNumbers; opts.lineHeightScale = state.settings.getLineHeightScale(); opts.letterSpacing = state.settings.getLetterSpacing(); + opts.cursorBlinkRate = state.settings.getCursorBlinkRate(); + opts.reduceMotion = state.settings.getReduceMotion(); + opts.nowSeconds = nowSeconds; if (state.ui.layoutPreset == LayoutPreset::Emacs) opts.annotationLayout = 1; else if (state.ui.layoutPreset == LayoutPreset::JetBrains) opts.annotationLayout = 2; else opts.annotationLayout = 0; @@ -186,6 +196,8 @@ static void renderEditorPanel(EditorState& state) { opts.annotations = &annoMarkers; opts.suggestions = &suggestionMarkers; opts.conflicts = &conflictMarkers; + opts.searchPulseLine = state.search.pulseLine; + opts.searchPulseStart = state.search.pulseStart; if (buf->bufferMode == BufferManager::BufferMode::Structured) { opts.syncScrollX = &buf->splitScrollX; @@ -232,6 +244,9 @@ static void renderEditorPanel(EditorState& state) { genOpts.showCurrentLine = false; genOpts.lineHeightScale = state.settings.getLineHeightScale(); genOpts.letterSpacing = state.settings.getLetterSpacing(); + genOpts.cursorBlinkRate = state.settings.getCursorBlinkRate(); + genOpts.reduceMotion = state.settings.getReduceMotion(); + genOpts.nowSeconds = nowSeconds; genOpts.highlightLine = buf->generatedHighlightLine; genOpts.syncScrollX = &buf->splitScrollX; genOpts.syncScrollY = &buf->splitScrollY; @@ -494,10 +509,20 @@ static void renderEditorPanel(EditorState& state) { state.hoverPending = false; } std::string hover = state.lsp->getHoverContents(); - if (!hover.empty()) { + static std::string lastHoverText; + bool hoverActive = !hover.empty(); + if (hoverActive) lastHoverText = hover; + float alpha = AnimationUtils::tooltipAlpha("lsp_hover", + hoverActive, + nowSeconds, + state.settings.getReduceMotion()); + if (alpha > 0.01f && !lastHoverText.empty()) { + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, + ImGui::GetStyle().Alpha * alpha); ImGui::BeginTooltip(); - ImGui::TextUnformatted(hover.c_str()); + ImGui::TextUnformatted(lastHoverText.c_str()); ImGui::EndTooltip(); + ImGui::PopStyleVar(); } } @@ -529,16 +554,34 @@ static void renderEditorPanel(EditorState& state) { if (res.hoverValid && (ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper)) { std::string preview; if (state.previewDefinitionAt(res.hoverLine, res.hoverCol, preview)) { - ImGui::BeginTooltip(); - ImGui::TextUnformatted(preview.c_str()); - ImGui::EndTooltip(); + float alpha = AnimationUtils::tooltipAlpha("definition_preview", + true, + nowSeconds, + state.settings.getReduceMotion()); + if (alpha > 0.01f) { + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, + ImGui::GetStyle().Alpha * alpha); + ImGui::BeginTooltip(); + ImGui::TextUnformatted(preview.c_str()); + ImGui::EndTooltip(); + ImGui::PopStyleVar(); + } } else if (state.definitionPreviewValid) { std::string path = EditorState::fromFileUri(state.definitionPreview.uri); std::string label = path + ":" + std::to_string(state.definitionPreview.range.start.line + 1); - ImGui::BeginTooltip(); - ImGui::TextUnformatted(label.c_str()); - ImGui::EndTooltip(); + float alpha = AnimationUtils::tooltipAlpha("definition_preview_fallback", + true, + nowSeconds, + state.settings.getReduceMotion()); + if (alpha > 0.01f) { + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, + ImGui::GetStyle().Alpha * alpha); + ImGui::BeginTooltip(); + ImGui::TextUnformatted(label.c_str()); + ImGui::EndTooltip(); + ImGui::PopStyleVar(); + } } } @@ -709,6 +752,7 @@ static void renderEditorPanel(EditorState& state) { ImGui::EndPopup(); } + ImGui::PopStyleVar(); ImGui::EndTabItem(); } if (!open) { diff --git a/editor/src/panels/SearchPanels.h b/editor/src/panels/SearchPanels.h index cf648fd..2e90ba6 100644 --- a/editor/src/panels/SearchPanels.h +++ b/editor/src/panels/SearchPanels.h @@ -1,11 +1,27 @@ #pragma once #include "../EditorState.h" #include "../EditorUtils.h" +#include "../AnimationUtils.h" static void renderFindReplaceBar(EditorState& state) { - if (!state.search.showFind) return; - ImGui::Begin("Find & Replace", &state.search.showFind, ImGuiWindowFlags_AlwaysAutoResize); + const double now = ImGui::GetTime(); + float alpha = 1.0f; + float offset = 0.0f; + bool render = AnimationUtils::panelTransition("FindReplacePanel", + state.search.showFind, + now, + state.settings.getReduceMotion(), + 0.14f, + 12.0f, + alpha, + offset); + if (!render) return; + bool open = state.search.showFind; + ImGui::SetNextWindowBgAlpha(alpha); + ImGui::Begin("Find & Replace", &open, ImGuiWindowFlags_AlwaysAutoResize); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * alpha); ImGui::PushFont(state.uiFont); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + offset); ImGui::SetNextItemWidth(300); if (ImGui::InputText("Find", state.search.findBuf, sizeof(state.search.findBuf), ImGuiInputTextFlags_EnterReturnsTrue)) { @@ -19,13 +35,32 @@ static void renderFindReplaceBar(EditorState& state) { ImGui::SameLine(); if (ImGui::Button("Replace All")) state.doReplaceAll(); ImGui::PopFont(); + ImGui::PopStyleVar(); ImGui::End(); + if (!open) { + state.search.showFind = false; + } } static void renderProjectSearchPanel(EditorState& state) { - if (!state.search.showProjectSearch) return; - ImGui::Begin("Search", &state.search.showProjectSearch); + const double now = ImGui::GetTime(); + float alpha = 1.0f; + float offset = 0.0f; + bool render = AnimationUtils::panelTransition("ProjectSearchPanel", + state.search.showProjectSearch, + now, + state.settings.getReduceMotion(), + 0.16f, + 16.0f, + alpha, + offset); + if (!render) return; + bool open = state.search.showProjectSearch; + ImGui::SetNextWindowBgAlpha(alpha); + ImGui::Begin("Search", &open); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * alpha); ImGui::PushFont(state.uiFont); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + offset); bool doSearch = false; ImGui::SetNextItemWidth(420); if (ImGui::InputText("Query", state.search.searchQuery, sizeof(state.search.searchQuery), @@ -76,5 +111,9 @@ static void renderProjectSearchPanel(EditorState& state) { } ImGui::EndChild(); ImGui::PopFont(); + ImGui::PopStyleVar(); ImGui::End(); + if (!open) { + state.search.showProjectSearch = false; + } } diff --git a/editor/src/panels/SettingsPanel.h b/editor/src/panels/SettingsPanel.h index 7ebc363..25decbb 100644 --- a/editor/src/panels/SettingsPanel.h +++ b/editor/src/panels/SettingsPanel.h @@ -2,6 +2,7 @@ #include "../EditorState.h" #include "../EditorUtils.h" #include "../ThemeEngine.h" +#include "../AnimationUtils.h" #include #include #include @@ -67,9 +68,26 @@ static int findFontIndex(const std::vector& options, const std::stri } static void renderSettingsPanel(EditorState& state) { - if (!state.ui.showSettingsPanel) return; - ImGui::Begin("Settings", &state.ui.showSettingsPanel); + const double now = ImGui::GetTime(); + const bool reduceMotion = state.settings.getReduceMotion(); + float alpha = 1.0f; + float offset = 0.0f; + const bool render = AnimationUtils::panelTransition("SettingsPanel", + state.ui.showSettingsPanel, + now, + reduceMotion, + 0.18f, + 18.0f, + alpha, + offset); + if (!render) return; + bool open = state.ui.showSettingsPanel; + ImGui::SetNextWindowBgAlpha(alpha); + ImGui::Begin("Settings", &open); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * alpha); ImGui::PushFont(state.uiFont); + float startX = ImGui::GetCursorPosX(); + ImGui::SetCursorPosX(startX + offset); bool settingsChanged = false; bool emacsConfigChanged = false; bool themeChanged = false; @@ -132,6 +150,11 @@ static void renderSettingsPanel(EditorState& state) { state.settings.setLetterSpacing(letterSpacing); settingsChanged = true; } + float blinkRate = state.settings.getCursorBlinkRate(); + if (ImGui::SliderFloat("Cursor Blink Rate (Hz)", &blinkRate, 0.0f, 4.0f, "%.1f")) { + state.settings.setCursorBlinkRate(blinkRate); + settingsChanged = true; + } int tabSize = state.settings.getTabSize(); const int tabSizes[] = {2, 4, 8}; @@ -321,6 +344,11 @@ static void renderSettingsPanel(EditorState& state) { state.ui.showLineNumbers = showLineNumbers; settingsChanged = true; } + bool reduceMotionSetting = state.settings.getReduceMotion(); + if (ImGui::Checkbox("Reduce Motion", &reduceMotionSetting)) { + state.settings.setReduceMotion(reduceMotionSetting); + settingsChanged = true; + } LayoutPreset preset = state.ui.layoutPreset; int presetIndex = 0; @@ -386,5 +414,9 @@ static void renderSettingsPanel(EditorState& state) { state.events.publish(UIEventType::ThemeChanged, {}, {}, ImGui::GetTime()); } ImGui::PopFont(); + ImGui::PopStyleVar(); ImGui::End(); + if (!open) { + state.ui.showSettingsPanel = false; + } } diff --git a/editor/src/state/SearchState.h b/editor/src/state/SearchState.h index 0da229f..3ee6e8c 100644 --- a/editor/src/state/SearchState.h +++ b/editor/src/state/SearchState.h @@ -6,6 +6,8 @@ struct SearchState { char findBuf[256] = {}; char replaceBuf[256] = {}; int lastFindPos = 0; + int pulseLine = -1; + double pulseStart = 0.0; bool showProjectSearch = false; char searchQuery[256] = {}; diff --git a/editor/src/state/UIAnimationState.h b/editor/src/state/UIAnimationState.h new file mode 100644 index 0000000..cb32a72 --- /dev/null +++ b/editor/src/state/UIAnimationState.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +struct UIAnimationState { + std::string activeTabPath; + double tabSwitchTime = 0.0; + + void recordTabSwitch(const std::string& path, double nowSeconds) { + if (path == activeTabPath) return; + activeTabPath = path; + tabSwitchTime = nowSeconds; + } + + float tabFadeAlpha(const std::string& path, + double nowSeconds, + bool reduceMotion, + float durationSeconds = 0.15f) const { + if (reduceMotion) return 1.0f; + if (path.empty() || path != activeTabPath) return 1.0f; + double elapsed = nowSeconds - tabSwitchTime; + float t = (durationSeconds <= 0.0f) ? 1.0f : (float)(elapsed / durationSeconds); + return std::max(0.0f, std::min(1.0f, t)); + } +}; diff --git a/editor/tests/step176_integration_test.cpp b/editor/tests/step176_integration_test.cpp new file mode 100644 index 0000000..9fe5409 --- /dev/null +++ b/editor/tests/step176_integration_test.cpp @@ -0,0 +1,20 @@ +// Step 176: Animation settings integration checks. + +#include + +#include "SettingsManager.h" +#include "AnimationUtils.h" + +int main() { + SettingsManager settings; + settings.setReduceMotion(true); + settings.setCursorBlinkRate(1.5f); + assert(settings.getReduceMotion() == true); + assert(settings.getCursorBlinkRate() == 1.5f); + + float eased = AnimationUtils::easeOutCubic(0.5f); + assert(eased >= 0.0f && eased <= 1.0f); + + printf("step176_integration_test: all assertions passed\n"); + return 0; +} diff --git a/editor/tests/step176_test.cpp b/editor/tests/step176_test.cpp new file mode 100644 index 0000000..47df69b --- /dev/null +++ b/editor/tests/step176_test.cpp @@ -0,0 +1,41 @@ +// Step 176: Smooth UI transitions checks. + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +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); +} + +int main() { + const std::string animUtils = readFile("src/AnimationUtils.h"); + assertContains(animUtils, "panelTransition"); + assertContains(animUtils, "tooltipAlpha"); + assertContains(animUtils, "blinkAlpha"); + + const std::string settings = readFile("src/SettingsManager.h"); + assertContains(settings, "reduceMotion"); + assertContains(settings, "cursorBlinkRate"); + + const std::string codeEditor = readFile("src/CodeEditorWidget.h"); + assertContains(codeEditor, "searchPulseLine"); + assertContains(codeEditor, "cursorBlinkRate"); + + const std::string notifications = readFile("src/NotificationSystem.h"); + assertContains(notifications, "renderToasts"); + assertContains(notifications, "reduceMotion"); + + printf("step176_test: all assertions passed\n"); + return 0; +} diff --git a/sprint6_plan.md b/sprint6_plan.md index ee355bc..58f16d8 100644 --- a/sprint6_plan.md +++ b/sprint6_plan.md @@ -142,7 +142,7 @@ look professional and feel personal. - Settings UI for all typography options *Modifies:* `SettingsManager.h`, `CodeEditorWidget.h`, `ThemeEngine.h` -- [ ] **Step 176: Smooth UI transitions** +- [x] **Step 176: Smooth UI transitions** Subtle animations that make the editor feel polished: - Panel open/close: smooth slide (not instant pop) - Tab switch: brief cross-fade on editor content