Step 177: enhanced find/replace
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<FoldRegion>& getFoldRegions() const { return folds_; }
|
||||
std::vector<int> getFoldedLines() const {
|
||||
std::vector<int> lines;
|
||||
|
||||
@@ -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).");
|
||||
}
|
||||
|
||||
211
editor/src/SearchUtils.h
Normal file
211
editor/src/SearchUtils.h
Normal file
@@ -0,0 +1,211 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <regex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<unsigned char>(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<int> buildLineStarts(const std::string& text) {
|
||||
std::vector<int> 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<int>& 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<int>& 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<SearchMatch> collectMatches(const std::string& text,
|
||||
const std::string& query,
|
||||
const SearchOptions& options,
|
||||
int rangeStart,
|
||||
int rangeEnd) {
|
||||
std::vector<SearchMatch> 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<int> 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<std::string>& 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<SearchMatch> 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<std::string> 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();
|
||||
|
||||
@@ -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<SearchMatch> matches;
|
||||
std::vector<std::string> findHistory;
|
||||
int pulseLine = -1;
|
||||
double pulseStart = 0.0;
|
||||
|
||||
|
||||
35
editor/tests/step177_integration_test.cpp
Normal file
35
editor/tests/step177_integration_test.cpp
Normal file
@@ -0,0 +1,35 @@
|
||||
// Step 177: Search utils integration checks.
|
||||
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<std::string> 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;
|
||||
}
|
||||
39
editor/tests/step177_test.cpp
Normal file
39
editor/tests/step177_test.cpp
Normal file
@@ -0,0 +1,39 @@
|
||||
// Step 177: Enhanced find/replace UI + wiring checks.
|
||||
|
||||
#include <cassert>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
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 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user