Step 176: smooth UI transitions
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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
141
editor/src/AnimationUtils.h
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] = {};
|
||||
|
||||
26
editor/src/state/UIAnimationState.h
Normal file
26
editor/src/state/UIAnimationState.h
Normal 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));
|
||||
}
|
||||
};
|
||||
20
editor/tests/step176_integration_test.cpp
Normal file
20
editor/tests/step176_integration_test.cpp
Normal 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;
|
||||
}
|
||||
41
editor/tests/step176_test.cpp
Normal file
41
editor/tests/step176_test.cpp
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user