// 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.document.linedimensions; import com.google.collide.shared.Pair; import com.google.collide.shared.document.Line; import com.google.collide.shared.util.SortedList; import javax.annotation.Nonnull; import javax.annotation.Nullable; /** * A cache object that is attached to each line which caches its offsets. * */ class ColumnOffsetCache { /** * An object which caches a map of a line column to its left edge pixel * position. This allows efficient lookup of column positions when variable * width characters are used. */ public static class ColumnOffset { /** * Orders a list of ColumnOffsetCache by their column order. */ private static final SortedList.Comparator<ColumnOffset> COLUMN_COMPARATOR = new SortedList.Comparator<ColumnOffset>() { @Override public int compare(ColumnOffset a, ColumnOffset b) { return a.column - b.column; } }; /** * Finds an object in the column offset list by its X value. */ private static final SortedList.OneWayDoubleComparator<ColumnOffset> X_ONE_WAY_COMPARATOR = new SortedList.OneWayDoubleComparator<ColumnOffset>() { @Override public int compareTo(ColumnOffset o) { return (int) (value - o.x); } }; /** * Finds an object in the column offset list by its Column value. */ private static final SortedList.OneWayIntComparator<ColumnOffset> COLUMN_ONE_WAY_COMPARATOR = new SortedList.OneWayIntComparator<ColumnOffset>() { @Override public int compareTo(ColumnOffset o) { return value - o.column; } }; public final int column; public final double x; /** Width of previous column's character */ public final double previousColumnWidth; public ColumnOffset(int column, double x, double previousColumnWidth) { this.column = column; this.x = x; this.previousColumnWidth = previousColumnWidth; } public boolean isZeroWidth() { return previousColumnWidth == 0; } } /** * Gets the {@link ColumnOffsetCache} stored on the given line. If it does not * already exist it will be created. If it already exists but with a different * zoomId, it will be cleared, removed and a new one created. * * @param line The line for this ColumnOffsetCache * @param zoomId A unique parameter which identifies the current page zoom * level. This id is checked before the cache is returned. If it does * not match the parameter stored inside the cache being retrieved then * the cache is considered out of date and a new one constructed. */ public static ColumnOffsetCache getOrCreate(Line line, double zoomId) { ColumnOffsetCache offsetCache = getUnsafe(line); if (offsetCache == null || offsetCache.zoomId != zoomId) { offsetCache = createCache(line, zoomId); } return offsetCache; } /** * Returns a column offset cache without checking its zoom level first. * * <p>If the zoom level has changed the data in this cache will be stale and * need to be rebuilt. Use this only if you aren't going to read it for * position data. If you can you should use {@link #getOrCreate}. * * @return {@code null} if cache doesn't exist */ @Nullable public static ColumnOffsetCache getUnsafe(@Nonnull Line line) { return line.getTag(LINE_TAG_COLUMN_OFFSET_CACHE); } /** * The internal key used to tag a line with its cache. */ private static final String LINE_TAG_COLUMN_OFFSET_CACHE = ColumnOffsetCache.class.getName() + "COLUMN_OFFSET_CACHE"; /** * An object which serves as a flag to indicate that the entire string has * been measured. This prevents us from duplicating measurements when the user * clicks past the end of a line. */ static final ColumnOffset FULLY_MEASURED = new ColumnOffset(Integer.MIN_VALUE, Double.MIN_VALUE, Double.MIN_VALUE); /** * A column offset which is used to early out when the user is requesting * column 0 or pixel 0. */ public static final ColumnOffset ZERO_OFFSET = new ColumnOffset(0, 0, 0); private final double zoomId; private SortedList<ColumnOffset> columnOffsets; ColumnOffset measuredOffset = ZERO_OFFSET; public ColumnOffsetCache(double zoomId) { this.zoomId = zoomId; } private SortedList<ColumnOffset> getCache() { if (columnOffsets == null) { columnOffsets = new SortedList<ColumnOffset>(ColumnOffset.COLUMN_COMPARATOR); } return columnOffsets; } /** * Returns the exact {@link ColumnOffset} or the nearest {@link ColumnOffset} * less than the requested column. Clients should check if measurements are * needed first by calling {@link #isColumnMeasurementNeeded(int)}. */ public ColumnOffset getColumnOffsetForColumn(int column) { assert !isColumnMeasurementNeeded(column) : "Measurement of Column was needed"; if (column == 0 || columnOffsets == null || columnOffsets.size() == 0) { return ZERO_OFFSET; } ColumnOffset.COLUMN_ONE_WAY_COMPARATOR.setValue(column); return getColumnOffset(ColumnOffset.COLUMN_ONE_WAY_COMPARATOR); } /** * Returns the exact {@link ColumnOffset} or the nearest {@link ColumnOffset} * less than the requested X. Clients should check if measurements are needed * first by calling {@link #isXMeasurementNeeded(double)}. * * @param defaultCharacterWidth The width that will be returned if the * character is not a special width. * @param x The x pixel value * * @return This function will return the offset which corresponds either to * the base character, or to the last zero-width mark following a base * character. It will also return the width of the current column * character so fractional columns can be determined. */ public Pair<ColumnOffset, Double> getColumnOffsetForX(double x, double defaultCharacterWidth) { assert !isXMeasurementNeeded(x) : "Measurement of X was needed"; if (x == 0 || columnOffsets == null || columnOffsets.size() == 0) { return Pair.of(ZERO_OFFSET, defaultCharacterWidth); } ColumnOffset.X_ONE_WAY_COMPARATOR.setValue(x); int index = getColumnOffsetIndex(ColumnOffset.X_ONE_WAY_COMPARATOR, true); if (index + 1 >= getCache().size()) { return Pair.of(getCache().get(index), defaultCharacterWidth); } ColumnOffset offset = index < 0 ? ZERO_OFFSET : getCache().get(index); ColumnOffset nextOffset = getCache().get(index < 0 ? 0 : index + 1); /* * So this is confusing but since a column offset always represents the * character before its column, we look to the next one to see if it is one * column more than us, if it is then we can grab the width of this * character from it since its special; otherwise, it's normal. */ return Pair.of(offset, nextOffset.column == offset.column + 1 ? nextOffset.previousColumnWidth : defaultCharacterWidth); } /** * Returns the last entry in the cache. If no entries are in the cache it will * return {@link #ZERO_OFFSET}. */ public ColumnOffset getLastColumnOffsetInCache() { if (columnOffsets == null || columnOffsets.size() == 0) { return ZERO_OFFSET; } return columnOffsets.get(columnOffsets.size() - 1); } /** * Returns a {@link ColumnOffset} using the logic in {@link * #getColumnOffset(com.google.collide.shared.util.SortedList.OneWayComparator)} * * @return {@link #ZERO_OFFSET} if there are no items in the cache, otherwise * either the matched {@link ColumnOffset} or one with less than the * requested value. */ private ColumnOffset getColumnOffset(SortedList.OneWayComparator<ColumnOffset> comparator) { int index = getColumnOffsetIndex(comparator, false); return index < 0 ? ZERO_OFFSET : getCache().get(index); } /** * Gets the index of the column offset based on the given comparator. * * @param comparator The comparator to use for ordering. * @param findLastOffsetIfZeroWidth if true then * {@link #findLastCombiningMarkIfItExistsFromCache(int)} will be * called so that we try to return a {@link ColumnOffset} which is the * last zero-width in a string of zero-width marks. -1 will be returned * if no applicable ColumnOffset is in the cache. */ private int getColumnOffsetIndex( SortedList.OneWayComparator<ColumnOffset> comparator, boolean findLastOffsetIfZeroWidth) { SortedList<ColumnOffset> cache = getCache(); int index = cache.findInsertionIndex(comparator, false); if (findLastOffsetIfZeroWidth) { index = findLastCombiningMarkIfItExistsFromCache(index); } if (index == 0) { ColumnOffset value = cache.get(index); /* * guards against a condition where the returned offset can be greater * than the requested value if there are only greater offsets in the cache * than what was requested. */ return comparator.compareTo(value) < 0 ? -1 : 0; } return index; } /** * Appends an {@link ColumnOffset} with the specified column, x, and * previousColumnWidth parameters. Also updates the internal values to note * how far we have measured. * * <p> * Note: We expect x to correspond to the left edge of column. * </p> */ public void appendOffset(int column, double x, double previousColumnWidth) { assert getLastColumnOffsetInCache().x <= x; assert getLastColumnOffsetInCache().column < column; SortedList<ColumnOffset> cache = getCache(); ColumnOffset cacheOffset = new ColumnOffset(column, x, previousColumnWidth); cache.add(cacheOffset); measuredOffset = cacheOffset; } /** * Marks a line's cache dirty starting at the specified column. A column of 0 * will completely clear the cache. */ public void markDirty(int column) { if (column <= 0 || columnOffsets == null || columnOffsets.size() == 0) { if (columnOffsets != null) { columnOffsets.clear(); } measuredOffset = ZERO_OFFSET; } else { ColumnOffset.COLUMN_ONE_WAY_COMPARATOR.setValue(column); int index = columnOffsets.findInsertionIndex(ColumnOffset.COLUMN_ONE_WAY_COMPARATOR, true); assert index != -1 && index <= columnOffsets.size(); /* * we can be in a state where index == size() since the column we want to * mark dirty is past the last entry in the cache. If thats the case we * only need to update our measuredOffset. */ if (index < columnOffsets.size()) { index = findBaseNonCombiningMarkIfItExistsFromCache(index); columnOffsets.removeThisAndFollowing(index); } measuredOffset = index == 0 ? ZERO_OFFSET : columnOffsets.get(index - 1); } } /** * Given an index in cache, walks backwards checking the * {@link ColumnOffset#isZeroWidth} to find the closest non-zero-width * character entry in the cache. If one does not exist (i.e. a` has only one * entry for the `) it will walk backwards to the offset entry of the first * combining mark (i.e. something like a````, it will return the entry of the * first `). */ /* * This makes it so when someone deletes a combining mark we find the closest * character to its left which is not a combining mark to it (unless its not a * special width in which case we return the combining mark offset) and delete * the appropriate cache entries from there forward. */ private int findBaseNonCombiningMarkIfItExistsFromCache(int index) { for (; index > 0; index--) { ColumnOffset currentOffset = columnOffsets.get(index); ColumnOffset previousOffset = columnOffsets.get(index - 1); /* * if I'm not zero width return me, otherwise if the offset before me in * cache is not the previous column then we may be something like a grave * accent. */ if (!currentOffset.isZeroWidth() || currentOffset.column - 1 != previousOffset.column) { return index; } } return index; } /** * Given an index walks forward checking {@link ColumnOffset#isZeroWidth()} to * find the last contiguous, zero-width character in the cache. If one does * not exist it will return index. */ private int findLastCombiningMarkIfItExistsFromCache(int index) { for (; index >= 0 && index < columnOffsets.size() - 1; index++) { ColumnOffset currentOffset = columnOffsets.get(index); ColumnOffset nextOffset = columnOffsets.get(index + 1); // if the next character is not zero-width return me, otherwise march on. if (!nextOffset.isZeroWidth() || currentOffset.column + 1 != nextOffset.column) { return index; } } return index; } /** * Checks if the column needs measurement before it can be retrieved by the * cache. */ public boolean isColumnMeasurementNeeded(int column) { return measuredOffset != FULLY_MEASURED && column > measuredOffset.column; } /** * Checks if the x pixel needs measurement before it can be retrieved by the * cache. */ public boolean isXMeasurementNeeded(double x) { return measuredOffset != FULLY_MEASURED && x >= measuredOffset.x; } /** * Removes the cache stored on a line, if any. */ public static void removeFrom(Line line) { line.removeTag(LINE_TAG_COLUMN_OFFSET_CACHE); } private static ColumnOffsetCache createCache(Line line, double zoomId) { ColumnOffsetCache offsetCache = new ColumnOffsetCache(zoomId); line.putTag(LINE_TAG_COLUMN_OFFSET_CACHE, offsetCache); return offsetCache; } }