// Copyright 2012 Google Inc. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.google.collide.client.editor.search; import com.google.collide.client.editor.search.SearchModel.MatchCountListener; import com.google.collide.client.editor.search.SearchTask.SearchTaskExecutor; import com.google.collide.client.editor.selection.SelectionModel; import com.google.collide.shared.document.Document; import com.google.collide.shared.document.DocumentMutator; import com.google.collide.shared.document.Line; import com.google.collide.shared.document.LineInfo; import com.google.collide.shared.document.Position; import com.google.collide.shared.util.ListenerManager; import com.google.collide.shared.util.ListenerRegistrar; import com.google.collide.shared.util.RegExpUtils; import com.google.collide.shared.util.ListenerManager.Dispatcher; import com.google.gwt.regexp.shared.MatchResult; import com.google.gwt.regexp.shared.RegExp; /** * Manages search matches and can be queried to determine the current match and * select a new match. */ /* * TODO: Consider making searching for matches asynchrounous if it * proves to be a bottleneck. Particularly revisit code that touches * totalMatches since it will no longer be valid and could lead to races. */ public class SearchMatchManager { private final Document document; private RegExp searchPattern; int totalMatches; private final SelectionModel selection; private final DocumentMutator editorDocumentMutator; private final SearchTask searchTask; private final ListenerManager<MatchCountListener> totalMatchesListenerManager = ListenerManager.create(); public SearchMatchManager(Document document, SelectionModel selection, DocumentMutator editorDocumentMutator, SearchTask searchTask) { this.document = document; this.selection = selection; this.editorDocumentMutator = editorDocumentMutator; this.searchTask = searchTask; } /** * Moves to the next match starting from the current cursor position. * * @returns Position of match or null if no matches are found. */ public Position selectNextMatch() { Position[] position = selection.getSelectionRange(false); return selectNextMatchFromPosition(position[1].getLineInfo(), position[1].getColumn()); } /** * Moves to the next match after the given position (inclusive). * * @returns Position of match or null if no matches are found. */ public Position selectNextMatchFromPosition(LineInfo lineInfo, int startColumn) { if (totalMatches == 0 || searchPattern == null || lineInfo == null) { return null; } /* * Basic Strategy: loop through lines until we find another match, if we hit * the end start at the top. Until we hit our own line then just select the * first match from index 0 (shouldn't be us). */ Line beginLine = lineInfo.line(); int column = startColumn; do { if (selectNextMatchOnLine(lineInfo, column, lineInfo.line().length())) { return new Position(lineInfo, selection.getCursorColumn()); } if (!lineInfo.moveToNext()) { lineInfo = document.getFirstLineInfo(); } // after first attempt, we always look at start of line column = 0; } while (lineInfo.line() != beginLine); // We check to ensure there wasn't another match to wrap to on our own line if (selectNextMatchOnLine(lineInfo, 0, startColumn)) { return new Position(lineInfo, selection.getCursorColumn()); } return null; } /** * Moves to the previous match starting at the current cursor position. * * @returns Position of match or null if no matches are found. */ public Position selectPreviousMatch() { Position[] position = selection.getSelectionRange(false); return selectPreviousMatchFromPosition(position[0].getLineInfo(), position[0].getColumn()); } /** * Moves to the previous match from the given position (inclusive). * * @returns Position of match or null if no matches are found. */ public Position selectPreviousMatchFromPosition(LineInfo lineInfo, int startColumn) { if (totalMatches == 0 || searchPattern == null || lineInfo == null) { return null; } /* * Basic Strategy: loop through lines going up, we have to go right to left * though so we use the line keys to determine how many matches should be in * a line and back out from that. */ Line beginLine = lineInfo.line(); int column = startColumn; do { if (selectPreviousMatchOnLine(lineInfo, 0, column)) { return new Position(lineInfo, selection.getCursorColumn()); } if (!lineInfo.moveToPrevious()) { lineInfo = document.getLastLineInfo(); } // after first attempt we want the last match in a line always column = lineInfo.line().getText().length(); } while (lineInfo.line() != beginLine); // We check to ensure there wasn't another match to wrap to on our own line if (selectPreviousMatchOnLine(lineInfo, startColumn, beginLine.length())) { return new Position(lineInfo, selection.getCursorColumn()); } return null; } /** * Increments the current total. If no match is currently selected this will * select the first match that is added automatically. */ public void addMatches(LineInfo lineInfo, int matches) { assert searchPattern != null; if (totalMatches == 0 && matches > 0) { selectNextMatchOnLine(lineInfo, 0, lineInfo.line().length()); } totalMatches += matches; dispatchTotalMatchesChanged(); } public int getTotalMatches() { return totalMatches; } ListenerRegistrar<MatchCountListener> getMatchCountChangedListenerRegistrar() { return totalMatchesListenerManager; } public void clearMatches() { totalMatches = 0; dispatchTotalMatchesChanged(); } /** * @return true if current selection is a match to the searchPattern. */ private boolean isSelectionRangeAMatch() { Position[] selectionRange = selection.getSelectionRange(false); if (searchPattern != null && totalMatches > 0 && selectionRange[0].getLine() == selectionRange[1].getLine()) { String text = document.getText(selectionRange[0].getLine(), selectionRange[0].getColumn(), selectionRange[1].getColumn() - selectionRange[0].getColumn()); return !text.isEmpty() && RegExpUtils.resetAndTest(searchPattern, text); } return false; } /** * Sets the search pattern used when finding matches. Also clears any existing * match count. */ public void setSearchPattern(RegExp searchPattern) { clearMatches(); this.searchPattern = searchPattern; } private void dispatchTotalMatchesChanged() { totalMatchesListenerManager.dispatch(new Dispatcher<MatchCountListener>() { @Override public void dispatch(MatchCountListener listener) { listener.onMatchCountChanged(totalMatches); } }); } /** * Selects the next match using the search pattern given line and startIndex. * * @param startIndex The boundary to find the next match after. * @param endIndex The boundary to find the next match before. * * @returns true if match is found */ private boolean selectNextMatchOnLine(LineInfo line, int startIndex, int endIndex) { searchPattern.setLastIndex(startIndex); MatchResult result = searchPattern.exec(line.line().getText()); if (result == null || result.getIndex() >= endIndex) { return false; } moveAndSelectMatch(line, result.getIndex(), result.getGroup(0).length()); return true; } /** * Selects the previous match using the search pattern given line and * startIndex. * * @param startIndex The boundary to find a previous match after. * @param endIndex The boundary to find a previous match before. * * @returns true if a match is found */ private boolean selectPreviousMatchOnLine(LineInfo line, int startIndex, int endIndex) { searchPattern.setLastIndex(0); // Find the last match without going over our startIndex MatchResult lastMatch = null; for (MatchResult result = searchPattern.exec(line.line().getText()); result != null && result.getIndex() < endIndex && result.getIndex() >= startIndex; result = searchPattern.exec(line.line().getText())) { lastMatch = result; } if (lastMatch == null) { return false; } moveAndSelectMatch(line, lastMatch.getIndex(), lastMatch.getGroup(0).length()); return true; } /** * Moves the editor selection to the specified line and column and selects * length characters. */ private void moveAndSelectMatch(LineInfo line, int column, int length) { selection.setSelection(line, column + length, line, column); } public void replaceAllMatches(final String replacement) { // TODO: There's an issue relying on the same SearchTask as // SearchModel, since they share the same scheduler the searchModel can // preempt a replaceAll before it is finish! searchTask.searchDocument(new SearchTaskExecutor() { @Override public boolean onSearchLine(Line line, int number, boolean shouldRenderLine) { searchPattern.setLastIndex(0); for (MatchResult result = searchPattern.exec(line.getText()); result != null && result.getGroup(0).length() != 0; result = searchPattern.exec(line.getText())) { int start = searchPattern.getLastIndex() - result.getGroup(0).length(); editorDocumentMutator.deleteText(line, number, start, result.getGroup(0).length()); editorDocumentMutator.insertText(line, number, start, replacement); int newIndex = result.getIndex() + replacement.length(); searchPattern.setLastIndex(newIndex); } return true; } }, null); } public boolean replaceMatch(String replacement) { if (!isSelectionRangeAMatch() && selectNextMatch() == null) { return false; } editorDocumentMutator.insertText(selection.getCursorLine(), selection.getCursorLineNumber(), selection.getCursorColumn(), replacement, true); selectNextMatch(); return true; } }