Step 176: smooth UI transitions

This commit is contained in:
Bill
2026-02-09 22:18:11 -07:00
parent 6f5005b326
commit 1693fd08d6
17 changed files with 480 additions and 39 deletions

View File

@@ -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. |

View File

@@ -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)

141
editor/src/AnimationUtils.h Normal file
View File

@@ -0,0 +1,141 @@
#pragma once
#include "imgui.h"
#include <algorithm>
#include <cmath>
#include <string>
#include <unordered_map>
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<std::string, TooltipState>& tooltipStates() {
static std::unordered_map<std::string, TooltipState> 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<std::string, PanelState>& panelStates() {
static std::unordered_map<std::string, PanelState> 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

View File

@@ -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<int> 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);
}
}

View File

@@ -9,6 +9,7 @@
#include "imgui.h"
#include "SyntaxHighlighter.h"
#include "ThemeEngine.h"
#include "AnimationUtils.h"
#include "EditorMode.h"
#include <string>
#include <vector>
@@ -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<int>* errorLines = nullptr;
const std::vector<int>* warningLines = nullptr;
@@ -36,6 +40,8 @@ struct CodeEditorOptions {
const std::vector<struct AnnotationConflictMarker>* conflicts = nullptr;
int highlightLine = -1;
const std::vector<int>* highlightLines = nullptr;
int searchPulseLine = -1;
double searchPulseStart = 0.0;
ImU32 highlightLineColor = IM_COL32(90, 70, 30, 120);
float* syncScrollX = nullptr;
float* syncScrollY = nullptr;

View File

@@ -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.");
}

View File

@@ -1,6 +1,7 @@
#pragma once
#include "imgui.h"
#include "AnimationUtils.h"
#include <string>
#include <vector>
@@ -93,13 +94,17 @@ public:
return history;
}
void renderToasts(const std::function<void(const Notification&)>& onNavigate = {}) {
void renderToasts(const std::function<void(const Notification&)>& 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;

View File

@@ -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";

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;
}
}

View File

@@ -2,6 +2,7 @@
#include "../EditorState.h"
#include "../EditorUtils.h"
#include "../ThemeEngine.h"
#include "../AnimationUtils.h"
#include <cstdlib>
#include <filesystem>
#include <vector>
@@ -67,9 +68,26 @@ static int findFontIndex(const std::vector<FontOption>& 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;
}
}

View File

@@ -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] = {};

View File

@@ -0,0 +1,26 @@
#pragma once
#include <string>
#include <algorithm>
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));
}
};

View File

@@ -0,0 +1,20 @@
// Step 176: Animation settings integration checks.
#include <cassert>
#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;
}

View File

@@ -0,0 +1,41 @@
// Step 176: Smooth UI transitions checks.
#include <cassert>
#include <filesystem>
#include <fstream>
#include <string>
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<char>(f)),
std::istreambuf_iterator<char>());
}
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;
}

View File

@@ -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