// 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.code.parenmatch; import com.google.collide.client.editor.Editor; import com.google.collide.client.editor.ViewportModel; import com.google.collide.client.editor.renderer.LineRenderer; import com.google.collide.client.editor.renderer.Renderer; import com.google.collide.client.editor.renderer.SingleChunkLineRenderer; import com.google.collide.client.editor.search.SearchTask; import com.google.collide.client.editor.search.SearchTask.SearchDirection; import com.google.collide.client.editor.selection.SelectionModel; import com.google.collide.client.editor.selection.SelectionModel.CursorListener; import com.google.collide.client.util.BasicIncrementalScheduler; import com.google.collide.client.util.IncrementalScheduler; import com.google.collide.json.shared.JsonArray; import com.google.collide.shared.document.Document; import com.google.collide.shared.document.Line; import com.google.collide.shared.document.LineInfo; import com.google.collide.shared.document.anchor.Anchor; import com.google.collide.shared.document.anchor.AnchorManager; import com.google.collide.shared.document.anchor.AnchorType; import com.google.collide.shared.util.JsonCollections; import com.google.collide.shared.util.ListenerRegistrar; import com.google.collide.shared.util.RegExpUtils; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.gwt.regexp.shared.MatchResult; import com.google.gwt.regexp.shared.RegExp; import com.google.gwt.resources.client.ClientBundle; import com.google.gwt.user.client.Timer; /* * TODO : Make this language specific and utilize code * understanding. */ /** * Highlights matching character for (), {}, [], and <> when the cursor is next * to one of them. */ public class ParenMatchHighlighter { /** Opening paren characters. */ public static final String OPEN_PARENS = "(<[{"; /** * Closing paren characters. {@link #CLOSE_PARENS}[i] must be the closing * match for {@link #OPEN_PARENS}[i]. */ public static final String CLOSE_PARENS = ")>]}"; static final AnchorType MATCH_ANCHOR_TYPE = AnchorType.create(ParenMatchHighlighter.class, "matchAnchor"); /** * Paren match highlighting CSS. */ public interface Css extends Editor.EditorSharedCss { String match(); } /** * Paren match highlighting resources. */ public interface Resources extends ClientBundle { @Source({"ParenMatchHighlighter.css", "com/google/collide/client/common/constants.css"}) Css parenMatchHighlighterCss(); } /** * Handler for processing each line during the search. */ private class SearchTaskHandler implements SearchTask.SearchTaskExecutor { private Line startLine; private int cursorColumn; private SearchDirection direction; private char searchChar; private char cancelChar; private int matchCount; private RegExp regExp; /** * Initialize the search parameters. * * @param startLine the line the search will start at. * @param cursorColumn the column where the paren character was found. * @param direction the direction to search, depending on whether we're * looking for the closing or opening paren. * @param searchChar the char we are looking for that opens or closes the * found paren * @param cancelChar the char we found. If found again, we must find it's * match before we find the original paren's match. */ public void initialize(Line startLine, int cursorColumn, SearchDirection direction, char searchChar, char cancelChar) { this.startLine = startLine; this.cursorColumn = cursorColumn; this.direction = direction; this.searchChar = searchChar; this.cancelChar = cancelChar; if (searchChar == '[' || searchChar == ']') { this.regExp = RegExp.compile("\\[|\\]", "g"); } else { // searching ( or ) -> [(]|[)] this.regExp = RegExp.compile("[" + this.searchChar + "]|[" + this.cancelChar + "]", "g"); } // we start at 1 and try to get down to 0 by finding the actual match. this.matchCount = 1; } @Override public boolean onSearchLine(Line line, int number, boolean shouldRenderLine) { String lineText= line.getText(); /* * Set match to -1 since we call getNextMatch with match + 1 to exclude a * found match from the next round. */ int match = -1; if (direction == SearchDirection.DOWN) { if (line == startLine) { // the - 1 is to make sure we include the character at the cursor. match = cursorColumn - 1; } MatchResult result; while ((result = RegExpUtils.findMatchAfterIndex(regExp, lineText, match)) != null) { match = result.getIndex(); if (checkForMatch(line, number, match, shouldRenderLine)) { return false; } } } else { int endColumn = line.length() - 1; if (line == startLine) { endColumn = cursorColumn - 2; } // first get all matches JsonArray<Integer> matches = JsonCollections.createArray(); MatchResult result; while ((result = RegExpUtils.findMatchAfterIndex(regExp, lineText, match)) != null) { match = result.getIndex(); if (match <= endColumn) { matches.add(match); } else { break; } } // then iterate backwards through them /** * TODO : look for a faster way to do this such that we * don't have to iterate back through them */ for (int i = matches.size() - 1; i >= 0; i--) { if (checkForMatch(line, number, matches.get(i), shouldRenderLine)) { return false; } } } return true; } /** * Check if this character is the match we are looking for. */ private boolean checkForMatch(Line line, int number, int column, boolean shouldRenderLine) { char nextChar = line.getText().charAt(column); if (nextChar == searchChar) { matchCount--; } else if (nextChar == cancelChar) { matchCount++; } if (matchCount == 0) { matchAnchor = anchorManager.createAnchor(MATCH_ANCHOR_TYPE, line, number, column); // when testing, css is null matchRenderer = SingleChunkLineRenderer.create(matchAnchor, matchAnchor, css.match()); renderer.addLineRenderer(matchRenderer); if (shouldRenderLine) { renderer.requestRenderLine(line); } return true; } return false; } } /** * A helper class to handle client events and listeners. This allows all client and GWT * functionality to be mocked out in the tests by hiding the implementation details of the * {@link Timer}. */ static class ParenMatchHelper implements ListenerRegistrar<CursorListener> { CursorListener cursorListener; Remover remover; final SelectionModel selectionModel; final Timer timer = new Timer() { @Override public void run() { if (cursorListener == null) { return; } LineInfo cursorLine = new LineInfo(selectionModel.getCursorLine(), selectionModel.getCursorLineNumber()); int cursorColumn = selectionModel.getCursorColumn(); cursorListener.onCursorChange(cursorLine, cursorColumn, true); } }; public ParenMatchHelper(SelectionModel model) { this.selectionModel = model; } void register() { remover = selectionModel.getCursorListenerRegistrar().add(new CursorListener() { @Override public void onCursorChange(LineInfo lineInfo, int column, boolean isExplicitChange) { timer.schedule(50); } }); } void cancelTimer() { timer.cancel(); } @Override public ListenerRegistrar.Remover add(CursorListener listener) { Preconditions.checkArgument(this.cursorListener == null, "Can't register two listeners"); this.cursorListener = listener; register(); return new Remover() { @Override public void remove() { remover.remove(); cursorListener = null; } }; } @Override public void remove(CursorListener listener) { throw new UnsupportedOperationException("The remover must be used to remove the listener"); } } public static ParenMatchHighlighter create( Document document, ViewportModel viewportModel, AnchorManager anchorManager, Resources res, Renderer renderer, final SelectionModel selection) { final IncrementalScheduler scheduler = new BasicIncrementalScheduler(100, 5000); ParenMatchHelper helper = new ParenMatchHelper(selection); return new ParenMatchHighlighter( document, viewportModel, anchorManager, res, renderer, scheduler, helper); } private final AnchorManager anchorManager; private final Renderer renderer; private final IncrementalScheduler scheduler; private final SearchTask searchTask; private final SearchTaskHandler searchTaskHandler; private final Css css; private final ListenerRegistrar<CursorListener> listenerRegistrar; private final CursorListener cursorListener; private ListenerRegistrar.Remover cursorListenerRemover; private Anchor matchAnchor; private LineRenderer matchRenderer; @VisibleForTesting ParenMatchHighlighter(Document document, ViewportModel viewportModel, AnchorManager anchorManager, Resources res, Renderer renderer, IncrementalScheduler scheduler, ListenerRegistrar<CursorListener> listenerRegistrar) { this.anchorManager = anchorManager; this.renderer = renderer; this.scheduler = scheduler; this.listenerRegistrar = listenerRegistrar; searchTask = new SearchTask(document, viewportModel, scheduler); searchTaskHandler = new SearchTaskHandler(); css = res.parenMatchHighlighterCss(); cursorListener = new CursorListener() { @Override public void onCursorChange(LineInfo lineInfo, int column, boolean isExplicitChange) { cancel(); maybeSearch(lineInfo, column); } }; cursorListenerRemover = this.listenerRegistrar.add(cursorListener); } /** * Enable or disable the match highlighter. By default it's enabled. */ public void setEnabled(boolean enabled) { Preconditions.checkNotNull( cursorListenerRemover, "can't enable when cursorListenerRemover is null"); if (enabled) { cursorListenerRemover = listenerRegistrar.add(cursorListener); } else { cancel(); cursorListenerRemover.remove(); } } /** * Cancel the current matching - both the search and any displayed matches. */ public void cancel() { scheduler.cancel(); searchTask.cancelTask(); if (matchRenderer != null) { renderer.removeLineRenderer(matchRenderer); renderer.requestRenderLine(matchAnchor.getLine()); anchorManager.removeAnchor(matchAnchor); matchRenderer = null; } } public void teardown() { cancel(); cursorListenerRemover.remove(); } /** * Checks if there is a paren to match at the cursor and starts a search if * so. * * @param cursorLine * @param cursorColumn */ private void maybeSearch(LineInfo cursorLine, int cursorColumn) { if (cursorColumn > 0) { char cancelChar = cursorLine.line().getText().charAt(cursorColumn - 1); int openIndex = OPEN_PARENS.indexOf(cancelChar); if (openIndex >= 0) { search(SearchDirection.DOWN, CLOSE_PARENS.charAt(openIndex), cancelChar, cursorLine, cursorColumn); return; } int closeIndex = CLOSE_PARENS.indexOf(cancelChar); if (closeIndex >= 0) { search(SearchDirection.UP, OPEN_PARENS.charAt(closeIndex), cancelChar, cursorLine, cursorColumn); return; } } } /** * Starts the search for the matching paren. * * @param direction the direction to search in * @param searchChar the character we want to match * @param cancelChar the character we found, which if we find again we need to * find its match first. * @param cursorLine the line where the to-be-matched character was found * @param column the column to the right of the to-be-matched character */ @VisibleForTesting protected void search(final SearchDirection direction, final char searchChar, final char cancelChar, final LineInfo cursorLine, final int column) { searchTaskHandler.initialize(cursorLine.line(), column, direction, searchChar, cancelChar); searchTask.searchDocumentStartingAtLine(searchTaskHandler, null, direction, cursorLine); } }