diff --git a/PROGRESS.md b/PROGRESS.md index 47c9903..a512185 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -524,4 +524,5 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 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 177: Enhanced find/replace (match counts, regex preview, replace preview, selection scope, history, toggles, find next/prev) with SearchUtils. 2/2 tests pass (step177_test, step177_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 185557e..4219b0d 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1023,6 +1023,13 @@ 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) +add_executable(step177_test tests/step177_test.cpp) +target_include_directories(step177_test PRIVATE src) + +add_executable(step177_integration_test tests/step177_integration_test.cpp) +target_include_directories(step177_integration_test PRIVATE src) +target_link_libraries(step177_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/CodeEditorRendering.h b/editor/src/CodeEditorRendering.h index 77d3591..2b76ac2 100644 --- a/editor/src/CodeEditorRendering.h +++ b/editor/src/CodeEditorRendering.h @@ -569,6 +569,19 @@ public: void setCursor(int pos) { cursor_ = pos; } int getCursor() const { return cursor_; } + bool hasSelectionRange() const { return hasSelection(); } + void getSelectionRange(int& start, int& end) const { + if (!hasSelection()) { + start = end = -1; + return; + } + start = std::min(selStart_, selEnd_); + end = std::max(selStart_, selEnd_); + } + void setSelectionRange(int start, int end) { + selStart_ = start; + selEnd_ = end; + } const std::vector& getFoldRegions() const { return folds_; } std::vector getFoldedLines() const { std::vector lines; diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index 89f6394..1078cb7 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -51,6 +51,7 @@ #include "UpdateChecker.h" #include "NotificationSystem.h" #include "UIEventBus.h" +#include "SearchUtils.h" #include "state/SearchState.h" #include "state/AgentState.h" #include "state/BuildState.h" @@ -1614,7 +1615,22 @@ struct EditorState { [this]() { doRedo(); }); registerCommand("search.find", "Search: Find/Replace", keys.getBinding("search.find").toString(), - [this]() { search.showFind = !search.showFind; }); + [this]() { + search.showFind = true; + search.showReplace = false; + }); + registerCommand("search.replace", "Search: Replace", + keys.getBinding("search.replace").toString(), + [this]() { + search.showFind = true; + search.showReplace = true; + }); + registerCommand("search.findNext", "Search: Find Next", + keys.getBinding("search.findNext").toString(), + [this]() { doFindNext(false); }); + registerCommand("search.findPrev", "Search: Find Previous", + keys.getBinding("search.findPrev").toString(), + [this]() { doFindNext(true); }); registerCommand("search.findInFiles", "Search: Find in Files", keys.getBinding("search.findInFiles").toString(), [this]() { search.showProjectSearch = !search.showProjectSearch; }); @@ -1888,52 +1904,181 @@ struct EditorState { active()->undoDepth = active()->orchestrator.getUndoDepth(); } - void doFind() { + SearchOptions currentSearchOptions() const { + SearchOptions opts; + opts.matchCase = search.matchCase; + opts.wholeWord = search.wholeWord; + opts.useRegex = search.useRegex; + return opts; + } + + void addSearchHistory(const std::string& query) { + if (query.empty()) return; + auto it = std::find(search.findHistory.begin(), search.findHistory.end(), query); + if (it != search.findHistory.end()) { + search.findHistory.erase(it); + } + search.findHistory.insert(search.findHistory.begin(), query); + if (search.findHistory.size() > 10) { + search.findHistory.resize(10); + } + } + + bool selectionRange(int& outStart, int& outEnd) const { + if (!active()) return false; + if (!active()->widget.hasSelectionRange()) return false; + active()->widget.getSelectionRange(outStart, outEnd); + return outStart >= 0 && outEnd > outStart; + } + + void refreshSearchMatches() { + search.matches.clear(); + search.currentMatchIndex = -1; + if (!active()) return; + std::string query = search.findBuf; + if (query.empty()) return; + int rangeStart = 0; + int rangeEnd = (int)active()->editBuf.size(); + if (search.findInSelection) { + int selStart = -1; + int selEnd = -1; + if (selectionRange(selStart, selEnd)) { + rangeStart = selStart; + rangeEnd = selEnd; + } + } + search.matches = SearchUtils::collectMatches(active()->editBuf, + query, + currentSearchOptions(), + rangeStart, + rangeEnd); + } + + bool moveToMatchIndex(int index) { + if (!active()) return false; + if (index < 0 || index >= (int)search.matches.size()) return false; + const auto& match = search.matches[index]; + active()->widget.setSelectionRange(match.start, match.end); + active()->widget.setCursor(match.end); + updateCursorPos(match.start); + search.currentMatchIndex = index; + search.lastFindPos = match.end; + search.pulseLine = match.line; + search.pulseStart = ImGui::GetTime(); + return true; + } + + void doFindNext(bool backwards = false) { if (!active()) return; if (strlen(search.findBuf) == 0) return; - int pos = active()->editor.find(search.findBuf, search.lastFindPos); - if (pos >= 0) { - search.lastFindPos = pos + 1; - int line = 1, col = 1; - for (int i = 0; i < pos && i < (int)active()->editBuf.size(); ++i) { - if (active()->editBuf[i] == '\n') { ++line; col = 1; } else { ++col; } - } - active()->cursorLine = line; - active()->cursorCol = col; - NotificationTarget target; - 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 { + refreshSearchMatches(); + if (search.matches.empty()) { search.lastFindPos = 0; search.pulseLine = -1; search.pulseStart = 0.0; notify(NotificationLevel::Warning, "\"" + std::string(search.findBuf) + "\" not found."); + return; } + + int cursorPos = active()->widget.getCursor(); + int chosenIndex = -1; + if (backwards) { + for (int i = (int)search.matches.size() - 1; i >= 0; --i) { + if (search.matches[i].end < cursorPos) { + chosenIndex = i; + break; + } + } + if (chosenIndex < 0) chosenIndex = (int)search.matches.size() - 1; + } else { + for (int i = 0; i < (int)search.matches.size(); ++i) { + if (search.matches[i].start > cursorPos) { + chosenIndex = i; + break; + } + } + if (chosenIndex < 0) chosenIndex = 0; + } + + if (moveToMatchIndex(chosenIndex)) { + addSearchHistory(search.findBuf); + const auto& m = search.matches[chosenIndex]; + NotificationTarget target; + target.path = active()->path; + target.line = m.line; + target.col = m.col; + notify(NotificationLevel::Info, + "Found \"" + std::string(search.findBuf) + "\" at line " + + std::to_string(m.line + 1) + ", col " + std::to_string(m.col + 1), + target); + } + } + + void doReplaceCurrent() { + if (!active()) return; + if (strlen(search.findBuf) == 0) return; + refreshSearchMatches(); + if (search.matches.empty()) { + notify(NotificationLevel::Warning, + "\"" + std::string(search.findBuf) + "\" not found."); + return; + } + + int index = search.currentMatchIndex; + if (index < 0 || index >= (int)search.matches.size()) { + int cursorPos = active()->widget.getCursor(); + for (int i = 0; i < (int)search.matches.size(); ++i) { + if (search.matches[i].start >= cursorPos) { + index = i; + break; + } + } + if (index < 0) index = 0; + } + + const auto& match = search.matches[index]; + std::string replacement = SearchUtils::applyReplacement(match.matchText, + search.findBuf, + search.replaceBuf, + currentSearchOptions()); + active()->editBuf.replace(match.start, match.end - match.start, replacement); + onTextChanged(); + active()->widget.setCursor(match.start + (int)replacement.size()); + updateCursorPos(match.start + (int)replacement.size()); + addSearchHistory(search.findBuf); + } + + void doFind() { + doFindNext(false); } void doReplaceAll() { if (!active()) return; if (strlen(search.findBuf) == 0) return; - int count = active()->editor.replaceAll(search.findBuf, search.replaceBuf); - if (count > 0) { - active()->editBuf = active()->editor.getContent(); - if (active()->bufferMode == BufferManager::BufferMode::Structured) { - active()->sync.setText(active()->editBuf, active()->language); - active()->sync.syncNow(); - active()->incrementalOptimizer.setRoot(active()->sync.getAST()); + int rangeStart = 0; + int rangeEnd = (int)active()->editBuf.size(); + if (search.findInSelection) { + int selStart = -1; + int selEnd = -1; + if (selectionRange(selStart, selEnd)) { + rangeStart = selStart; + rangeEnd = selEnd; } - active()->highlightsDirty = true; - active()->generatedHighlightsDirty = true; - active()->modified = true; } + int count = 0; + std::string replaced = SearchUtils::replaceAll(active()->editBuf, + search.findBuf, + search.replaceBuf, + currentSearchOptions(), + rangeStart, + rangeEnd, + count); + if (count > 0) { + active()->editBuf = replaced; + onTextChanged(); + } + addSearchHistory(search.findBuf); notify(NotificationLevel::Info, "Replaced " + std::to_string(count) + " occurrence(s)."); } diff --git a/editor/src/SearchUtils.h b/editor/src/SearchUtils.h new file mode 100644 index 0000000..ebb8fd3 --- /dev/null +++ b/editor/src/SearchUtils.h @@ -0,0 +1,211 @@ +#pragma once + +#include +#include +#include +#include +#include + +struct SearchOptions { + bool matchCase = false; + bool wholeWord = false; + bool useRegex = false; +}; + +struct SearchMatch { + int start = 0; + int end = 0; + int line = 0; + int col = 0; + std::string lineText; + std::string matchText; +}; + +namespace SearchUtils { +inline bool isWordChar(char c) { + return std::isalnum(static_cast(c)) || c == '_'; +} + +inline bool isWholeWord(const std::string& text, int start, int end) { + bool leftOk = (start <= 0) || !isWordChar(text[start - 1]); + bool rightOk = (end >= (int)text.size()) || !isWordChar(text[end]); + return leftOk && rightOk; +} + +inline std::vector buildLineStarts(const std::string& text) { + std::vector starts; + starts.reserve(128); + starts.push_back(0); + for (int i = 0; i < (int)text.size(); ++i) { + if (text[i] == '\n') starts.push_back(i + 1); + } + return starts; +} + +inline void lineColFromPos(const std::vector& lineStarts, int pos, int& outLine, int& outCol) { + auto it = std::upper_bound(lineStarts.begin(), lineStarts.end(), pos); + int line = (int)(it - lineStarts.begin()) - 1; + if (line < 0) line = 0; + outLine = line; + outCol = pos - lineStarts[line]; +} + +inline std::string lineTextAt(const std::string& text, const std::vector& lineStarts, int line) { + if (line < 0 || line >= (int)lineStarts.size()) return {}; + int start = lineStarts[line]; + int end = (line + 1 < (int)lineStarts.size()) ? lineStarts[line + 1] - 1 : (int)text.size(); + if (end < start) end = start; + return text.substr(start, end - start); +} + +inline std::vector collectMatches(const std::string& text, + const std::string& query, + const SearchOptions& options, + int rangeStart, + int rangeEnd) { + std::vector matches; + if (query.empty()) return matches; + rangeStart = std::max(0, std::min(rangeStart, (int)text.size())); + rangeEnd = std::max(rangeStart, std::min(rangeEnd, (int)text.size())); + + const std::vector lineStarts = buildLineStarts(text); + const std::string slice = text.substr(rangeStart, rangeEnd - rangeStart); + + if (options.useRegex) { + try { + auto flags = std::regex_constants::ECMAScript; + if (!options.matchCase) flags |= std::regex_constants::icase; + std::regex re(query, flags); + for (auto it = std::sregex_iterator(slice.begin(), slice.end(), re); + it != std::sregex_iterator(); ++it) { + int start = rangeStart + (int)it->position(); + int end = start + (int)it->length(); + if (options.wholeWord && !isWholeWord(text, start, end)) continue; + SearchMatch m; + m.start = start; + m.end = end; + lineColFromPos(lineStarts, start, m.line, m.col); + m.lineText = lineTextAt(text, lineStarts, m.line); + m.matchText = it->str(); + matches.push_back(std::move(m)); + } + } catch (...) { + return matches; + } + return matches; + } + + std::string haystack = slice; + std::string needle = query; + if (!options.matchCase) { + std::transform(haystack.begin(), haystack.end(), haystack.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + std::transform(needle.begin(), needle.end(), needle.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + } + + size_t pos = 0; + while ((pos = haystack.find(needle, pos)) != std::string::npos) { + int start = rangeStart + (int)pos; + int end = start + (int)needle.size(); + if (options.wholeWord && !isWholeWord(text, start, end)) { + pos += needle.size(); + continue; + } + SearchMatch m; + m.start = start; + m.end = end; + lineColFromPos(lineStarts, start, m.line, m.col); + m.lineText = lineTextAt(text, lineStarts, m.line); + m.matchText = text.substr(start, end - start); + matches.push_back(std::move(m)); + pos += needle.size(); + } + return matches; +} + +inline bool regexGroupsForFirstMatch(const std::string& text, + const std::string& query, + const SearchOptions& options, + int rangeStart, + int rangeEnd, + std::vector& outGroups) { + outGroups.clear(); + if (query.empty()) return false; + rangeStart = std::max(0, std::min(rangeStart, (int)text.size())); + rangeEnd = std::max(rangeStart, std::min(rangeEnd, (int)text.size())); + const std::string slice = text.substr(rangeStart, rangeEnd - rangeStart); + try { + auto flags = std::regex_constants::ECMAScript; + if (!options.matchCase) flags |= std::regex_constants::icase; + std::regex re(query, flags); + std::smatch match; + if (!std::regex_search(slice, match, re)) return false; + for (size_t i = 1; i < match.size(); ++i) { + outGroups.push_back(match.str(i)); + } + return true; + } catch (...) { + return false; + } +} + +inline std::string applyReplacement(const std::string& matchText, + const std::string& query, + const std::string& replacement, + const SearchOptions& options) { + if (!options.useRegex) return replacement; + try { + auto flags = std::regex_constants::ECMAScript; + if (!options.matchCase) flags |= std::regex_constants::icase; + std::regex re(query, flags); + return std::regex_replace(matchText, re, replacement, + std::regex_constants::format_first_only); + } catch (...) { + return replacement; + } +} + +inline std::string replaceAll(const std::string& text, + const std::string& query, + const std::string& replacement, + const SearchOptions& options, + int rangeStart, + int rangeEnd, + int& outCount) { + outCount = 0; + if (query.empty()) return text; + rangeStart = std::max(0, std::min(rangeStart, (int)text.size())); + rangeEnd = std::max(rangeStart, std::min(rangeEnd, (int)text.size())); + std::string result = text; + + if (options.useRegex) { + try { + auto flags = std::regex_constants::ECMAScript; + if (!options.matchCase) flags |= std::regex_constants::icase; + std::regex re(query, flags); + const std::string slice = text.substr(rangeStart, rangeEnd - rangeStart); + for (auto it = std::sregex_iterator(slice.begin(), slice.end(), re); + it != std::sregex_iterator(); ++it) { + ++outCount; + } + std::string replaced = std::regex_replace(slice, re, replacement); + result.replace(rangeStart, rangeEnd - rangeStart, replaced); + return result; + } catch (...) { + return text; + } + } + + SearchOptions opts = options; + opts.useRegex = false; + std::vector matches = collectMatches(text, query, opts, rangeStart, rangeEnd); + if (matches.empty()) return text; + for (int i = (int)matches.size() - 1; i >= 0; --i) { + const auto& m = matches[i]; + result.replace(m.start, m.end - m.start, replacement); + ++outCount; + } + return result; +} +} // namespace SearchUtils diff --git a/editor/src/main.cpp b/editor/src/main.cpp index 1d2267d..f3c5eb5 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -272,7 +272,16 @@ int main(int, char**) { std::string action = state.keys.getAction(combo); if (action == "edit.undo") state.doUndo(); else if (action == "edit.redo") state.doRedo(); - else if (action == "search.find") state.search.showFind = !state.search.showFind; + else if (action == "search.find") { + state.search.showFind = true; + state.search.showReplace = false; + } + else if (action == "search.replace") { + state.search.showFind = true; + state.search.showReplace = true; + } + else if (action == "search.findNext") state.doFindNext(false); + else if (action == "search.findPrev") state.doFindNext(true); else if (action == "search.findInFiles") state.search.showProjectSearch = !state.search.showProjectSearch; else if (action == "nav.goToLine") { state.search.showGoToLine = true; diff --git a/editor/src/panels/MenuBarPanel.h b/editor/src/panels/MenuBarPanel.h index 9710734..9274d33 100644 --- a/editor/src/panels/MenuBarPanel.h +++ b/editor/src/panels/MenuBarPanel.h @@ -79,8 +79,10 @@ static void renderMenuBar(EditorState& state) { false, state.active() ? state.active()->orchestrator.getRedoDepth() > 0 : false)) state.doRedo(); ImGui::Separator(); - if (ImGui::MenuItem("Find/Replace", state.keys.getBinding("search.find").toString().c_str())) - state.search.showFind = !state.search.showFind; + if (ImGui::MenuItem("Find/Replace", state.keys.getBinding("search.find").toString().c_str())) { + state.search.showFind = true; + state.search.showReplace = true; + } if (ImGui::MenuItem("Find in Files", state.keys.getBinding("search.findInFiles").toString().c_str())) state.search.showProjectSearch = !state.search.showProjectSearch; ImGui::EndMenu(); diff --git a/editor/src/panels/SearchPanels.h b/editor/src/panels/SearchPanels.h index 2e90ba6..34d1fee 100644 --- a/editor/src/panels/SearchPanels.h +++ b/editor/src/panels/SearchPanels.h @@ -3,6 +3,17 @@ #include "../EditorUtils.h" #include "../AnimationUtils.h" +static bool renderToggleButton(const char* label, bool value) { + ImVec4 active = ImVec4(0.25f, 0.55f, 0.85f, 0.9f); + ImVec4 inactive = ImVec4(0.20f, 0.20f, 0.20f, 0.9f); + ImGui::PushStyleColor(ImGuiCol_Button, value ? active : inactive); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, value ? active : inactive); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, value ? active : inactive); + bool pressed = ImGui::Button(label); + ImGui::PopStyleColor(3); + return pressed; +} + static void renderFindReplaceBar(EditorState& state) { const double now = ImGui::GetTime(); float alpha = 1.0f; @@ -22,18 +33,120 @@ static void renderFindReplaceBar(EditorState& state) { ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * alpha); ImGui::PushFont(state.uiFont); ImGui::SetCursorPosX(ImGui::GetCursorPosX() + offset); + state.refreshSearchMatches(); + + bool hasSelection = state.active() && state.active()->widget.hasSelectionRange(); + if (state.search.findInSelection && !hasSelection) { + ImGui::TextDisabled("Find in selection: no selection (searching full file)"); + } ImGui::SetNextItemWidth(300); if (ImGui::InputText("Find", state.search.findBuf, sizeof(state.search.findBuf), ImGuiInputTextFlags_EnterReturnsTrue)) { - state.doFind(); + state.search.lastFindPos = 0; + state.doFindNext(false); } ImGui::SameLine(); - if (ImGui::Button("Find Next")) state.doFind(); - - ImGui::SetNextItemWidth(300); - ImGui::InputText("Replace", state.search.replaceBuf, sizeof(state.search.replaceBuf)); + if (ImGui::Button("Find Next")) state.doFindNext(false); ImGui::SameLine(); - if (ImGui::Button("Replace All")) state.doReplaceAll(); + if (ImGui::Button("Find Prev")) state.doFindNext(true); + + if (state.search.showReplace) { + ImGui::SetNextItemWidth(300); + ImGui::InputText("Replace", state.search.replaceBuf, sizeof(state.search.replaceBuf)); + ImGui::SameLine(); + if (ImGui::Button("Replace")) state.doReplaceCurrent(); + ImGui::SameLine(); + if (ImGui::Button("Replace All")) state.doReplaceAll(); + } + + ImGui::Separator(); + if (renderToggleButton("Match Case", state.search.matchCase)) { + state.search.matchCase = !state.search.matchCase; + } + ImGui::SameLine(); + if (renderToggleButton("Whole Word", state.search.wholeWord)) { + state.search.wholeWord = !state.search.wholeWord; + } + ImGui::SameLine(); + if (renderToggleButton("Regex", state.search.useRegex)) { + state.search.useRegex = !state.search.useRegex; + } + ImGui::SameLine(); + if (renderToggleButton("In Selection", state.search.findInSelection)) { + state.search.findInSelection = !state.search.findInSelection; + } + + if (!state.search.findHistory.empty()) { + ImGui::SameLine(); + if (ImGui::BeginCombo("History", "")) { + for (const auto& item : state.search.findHistory) { + if (ImGui::Selectable(item.c_str())) { + std::snprintf(state.search.findBuf, sizeof(state.search.findBuf), "%s", item.c_str()); + state.search.lastFindPos = 0; + } + } + ImGui::EndCombo(); + } + } + + if (!state.search.matches.empty()) { + int current = state.search.currentMatchIndex >= 0 + ? state.search.currentMatchIndex + 1 + : 0; + ImGui::TextDisabled("%d of %d matches", current, (int)state.search.matches.size()); + if (state.search.currentMatchIndex >= 0 && + state.search.currentMatchIndex < (int)state.search.matches.size()) { + const auto& m = state.search.matches[state.search.currentMatchIndex]; + ImGui::SameLine(); + ImGui::TextDisabled("Ln %d, Col %d", m.line + 1, m.col + 1); + } + } else if (state.search.findBuf[0] != '\0') { + ImGui::TextDisabled("0 matches"); + } + + if (state.search.useRegex && state.search.findBuf[0] != '\0' && state.active()) { + std::vector groups; + int rangeStart = 0; + int rangeEnd = (int)state.active()->editBuf.size(); + if (state.search.findInSelection && hasSelection) { + state.active()->widget.getSelectionRange(rangeStart, rangeEnd); + } + bool ok = SearchUtils::regexGroupsForFirstMatch(state.active()->editBuf, + state.search.findBuf, + state.currentSearchOptions(), + rangeStart, + rangeEnd, + groups); + if (!ok) { + ImGui::TextDisabled("Regex preview: no match"); + } else if (!groups.empty()) { + ImGui::TextDisabled("Regex groups:"); + for (size_t i = 0; i < groups.size(); ++i) { + ImGui::Text(" $%d = %s", (int)i + 1, groups[i].c_str()); + } + } + } + + if (state.search.showReplace && !state.search.matches.empty()) { + int idx = state.search.currentMatchIndex; + if (idx < 0 || idx >= (int)state.search.matches.size()) idx = 0; + const auto& m = state.search.matches[idx]; + std::string replacement = SearchUtils::applyReplacement(m.matchText, + state.search.findBuf, + state.search.replaceBuf, + state.currentSearchOptions()); + std::string preview = m.lineText; + if (m.col >= 0 && m.col <= (int)preview.size()) { + int replaceLen = m.end - m.start; + if (m.col + replaceLen <= (int)preview.size()) { + preview.replace(m.col, replaceLen, replacement); + } + } + ImGui::Separator(); + ImGui::TextDisabled("Replace preview:"); + ImGui::Text("Before: %s", m.lineText.c_str()); + ImGui::Text("After : %s", preview.c_str()); + } ImGui::PopFont(); ImGui::PopStyleVar(); ImGui::End(); diff --git a/editor/src/state/SearchState.h b/editor/src/state/SearchState.h index 3ee6e8c..fc0adba 100644 --- a/editor/src/state/SearchState.h +++ b/editor/src/state/SearchState.h @@ -1,11 +1,20 @@ #pragma once #include "ProjectSearch.h" +#include "../SearchUtils.h" struct SearchState { bool showFind = false; + bool showReplace = false; char findBuf[256] = {}; char replaceBuf[256] = {}; int lastFindPos = 0; + bool matchCase = false; + bool wholeWord = false; + bool useRegex = false; + bool findInSelection = false; + int currentMatchIndex = -1; + std::vector matches; + std::vector findHistory; int pulseLine = -1; double pulseStart = 0.0; diff --git a/editor/tests/step177_integration_test.cpp b/editor/tests/step177_integration_test.cpp new file mode 100644 index 0000000..ffbb9cc --- /dev/null +++ b/editor/tests/step177_integration_test.cpp @@ -0,0 +1,35 @@ +// Step 177: Search utils integration checks. + +#include +#include +#include + +#include "SearchUtils.h" + +int main() { + const std::string text = "foo bar foo\nFoo bar\nfoobar\n"; + SearchOptions opts; + opts.matchCase = false; + opts.wholeWord = true; + opts.useRegex = false; + + auto matches = SearchUtils::collectMatches(text, "foo", opts, 0, (int)text.size()); + assert(matches.size() == 3); + + std::vector groups; + opts.useRegex = true; + bool ok = SearchUtils::regexGroupsForFirstMatch("foobar", "(foo)(bar)", opts, 0, 6, groups); + assert(ok); + assert(groups.size() == 2); + assert(groups[0] == "foo"); + assert(groups[1] == "bar"); + + int count = 0; + opts.useRegex = false; + std::string replaced = SearchUtils::replaceAll(text, "bar", "baz", opts, 0, (int)text.size(), count); + assert(count == 2); + assert(replaced.find("baz") != std::string::npos); + + printf("step177_integration_test: all assertions passed\n"); + return 0; +} diff --git a/editor/tests/step177_test.cpp b/editor/tests/step177_test.cpp new file mode 100644 index 0000000..3677240 --- /dev/null +++ b/editor/tests/step177_test.cpp @@ -0,0 +1,39 @@ +// Step 177: Enhanced find/replace UI + wiring checks. + +#include +#include +#include + +static std::string readFile(const std::string& path) { + std::ifstream f(path); + if (!f.is_open()) return {}; + return std::string((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); +} + +static void assertContains(const std::string& text, const std::string& needle) { + assert(text.find(needle) != std::string::npos); +} + +int main() { + const std::string searchUtils = readFile("src/SearchUtils.h"); + assertContains(searchUtils, "collectMatches"); + assertContains(searchUtils, "replaceAll"); + assertContains(searchUtils, "regexGroupsForFirstMatch"); + + const std::string searchPanel = readFile("src/panels/SearchPanels.h"); + assertContains(searchPanel, "Find Next"); + assertContains(searchPanel, "Find Prev"); + assertContains(searchPanel, "Replace preview"); + assertContains(searchPanel, "Regex"); + assertContains(searchPanel, "Match Case"); + assertContains(searchPanel, "Whole Word"); + assertContains(searchPanel, "In Selection"); + + const std::string editorState = readFile("src/EditorState.h"); + assertContains(editorState, "doFindNext"); + assertContains(editorState, "doReplaceCurrent"); + + printf("step177_test: all assertions passed\n"); + return 0; +} diff --git a/sprint6_plan.md b/sprint6_plan.md index 58f16d8..c82a7d9 100644 --- a/sprint6_plan.md +++ b/sprint6_plan.md @@ -159,7 +159,7 @@ look professional and feel personal. Core editing improvements that make daily use smooth. -- [ ] **Step 177: Enhanced find/replace** +- [x] **Step 177: Enhanced find/replace** Upgrade the find/replace bar: - Match count display ("3 of 17 results") - Live regex preview with captured groups highlighted