Java tutorial
// Copyright (c) 2014-2017 Akop Karapetyan // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. package org.akop.ararat.view; import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PointF; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.Typeface; import android.os.AsyncTask; import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import android.os.SystemClock; import android.support.annotation.ColorInt; import android.support.annotation.NonNull; import android.support.v4.view.ViewCompat; import android.text.InputType; import android.text.TextUtils; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.util.Log; import android.view.GestureDetector; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.ScaleGestureDetector; import android.view.View; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputMethodManager; import android.widget.Scroller; import org.akop.ararat.R; import org.akop.ararat.core.Crossword; import org.akop.ararat.core.CrosswordState; import org.akop.ararat.view.inputmethod.CrosswordInputConnection; import org.akop.ararat.widget.Zoomer; import java.util.Stack; @SuppressWarnings("unused") public class CrosswordView extends View implements View.OnKeyListener { private static final String LOG_TAG = CrosswordView.class.getSimpleName(); public static final int MARKER_CUSTOM = 1; public static final int MARKER_CHEAT = 1 << 1; public static final int MARKER_ERROR = 1 << 2; public static final int UNDO_NONE = 0; public static final int UNDO_SMART = 1; public static final int INPUT_MODE_NONE = 0; public static final int INPUT_MODE_KEYBOARD = 1; public interface OnStateChangeListener { void onCrosswordChanged(CrosswordView view); void onCrosswordSolved(CrosswordView view); void onCrosswordUnsolved(CrosswordView view); } public interface OnSelectionChangeListener { void onSelectionChanged(CrosswordView view, Crossword.Word word, int position); } public interface OnLongPressListener { void onCellLongPressed(CrosswordView view, Crossword.Word word, int cell); } private static final Cell[][] EMPTY_CELLS = new Cell[0][0]; private static final char[] EMPTY_CHARS = new char[0]; private static final int NAVIGATION_SCROLL_DURATION_MS = 500; private static final int DEFAULT_MAX_BITMAP_DIMENSION = 2048; // largest allowed bitmap width or height private static final float MARKER_TRIANGLE_LENGTH_FRACTION = 0.3f; private static final float FLING_VELOCITY_DOWNSCALE = 2.0f; private static final float CELL_SIZE = 10; private static final float NUMBER_TEXT_PADDING = 1; private static final float NUMBER_TEXT_SIZE = 3; private static final float ANSWER_TEXT_PADDING = 2; private static final float ANSWER_TEXT_SIZE = 7; private static final float NUMBER_TEXT_STROKE_WIDTH = 1; private static final int NORMAL_CELL_FILL_COLOR = Color.parseColor("#ffffff"); private static final int CHEATED_CELL_FILL_COLOR = Color.parseColor("#ff8b85"); private static final int MISTAKE_CELL_FILL_COLOR = Color.parseColor("#ff0000"); private static final int SELECTED_WORD_FILL_COLOR = Color.parseColor("#faeace"); private static final int SELECTED_CELL_FILL_COLOR = Color.parseColor("#ecae44"); private static final int MARKED_CELL_FILL_COLOR = Color.parseColor("#cedefa"); private static final int NUMBER_TEXT_COLOR = Color.parseColor("#000000"); private static final int ANSWER_TEXT_COLOR = Color.parseColor("#0041b7"); private static final int CELL_STROKE_COLOR = Color.parseColor("#000000"); private static final int CIRCLE_STROKE_COLOR = Color.parseColor("#555555"); private RectF mContentRect; // Content rectangle - bounds of the view private RectF mPuzzleRect; // Puzzle rectangle - basically the output size of the bitmap private float mScaledCellSize; private Crossword mCrossword; private Paint mCellStrokePaint; private Paint mCircleStrokePaint; private Paint mCellFillPaint; private Paint mCheatedCellFillPaint; private Paint mMistakeCellFillPaint; private Paint mMarkedCellFillPaint; private Paint mSelectedWordFillPaint; private Paint mSelectedCellFillPaint; private Paint mNumberTextPaint; private Paint mNumberStrokePaint; private Paint mAnswerTextPaint; private float mCellSize; private float mMarkerSideLength; private float mCircleRadius; private float mNumberTextPadding; private float mNumberTextHeight; private float mScaledDensity; private float mAnswerTextSize; private Stack<UndoItem> mUndoBuffer; private int mPuzzleWidth; // Total number of cells across private int mPuzzleHeight; // Total number of cells down private Cell[][] mPuzzleCells; // Map of the cells private Selectable mSelection; private char[] mAllowedChars; private float mMinScaleFactor; // Scale at which the puzzle takes up the entire screen private float mFitWidthScaleFactor; // Scale at which the puzzle fits in horizontally private float mMaxScaleFactor; // Scale beyond which the puzzle shouldn't be enlarged private PointF mCenteredOffset; // Offset at which the puzzle appears exactly in the middle private float mRenderScale; // This is the scale at which the bitmap is rendered private float mBitmapScale; // This is the scaling applied to the bitmap in touch resize mode private float mScaleStart; // The value of mRenderScale when touch resizing begins private PointF mBitmapOffset; // Offset of the rendered bitmap private RectF mTranslationBounds; // Bitmap translation limits private ScaleGestureDetector mScaleDetector; private GestureDetector mGestureDetector; private RenderTask mAsyncTask; private Canvas mPuzzleCanvas; private Bitmap mPuzzleBitmap; private Paint mBitmapPaint; private boolean mIsZooming; private boolean mIgnoreZoom; private Zoomer mZoomer; private Scroller mScroller; private boolean mIsSolved; private int mInputMode; private boolean mIsEditable; private boolean mSkipOccupiedOnType; private boolean mSelectFirstUnoccupiedOnNav; private int mUndoMode; private boolean mRevealSetsCheatFlag; private int mMarkerDisplayMode; private int mMaxBitmapSize; private Rect mTempRect = new Rect(); private RectF mAnswerTextRect = new RectF(); private final Object mRendererLock = new Object(); private final Renderer mInPlaceRenderer = new Renderer(); private OnSelectionChangeListener mSelectionChangeListener; private OnStateChangeListener mStateChangeListener; private OnLongPressListener mLongpressListener; private CrosswordInputConnection.OnInputEventListener mInputEventListener = new CrosswordInputConnection.OnInputEventListener() { @Override public void onWordEntered(CharSequence text) { if (text != null && mSelection != null) { // Words like "ain't" contain punctuation marks that ain't // valid, but may appear in a crossword in punctuation-less // form. For this reason, we strip out invalid characters // before considering whether we want to fill them into the // selection. char[] chars = text.toString().toCharArray(); // Copy all acceptable chars to a separate array String[] filtered = new String[chars.length]; int k = 0; for (char ch : chars) { if (isAcceptableChar(ch)) { filtered[k++] = String.valueOf(ch); } } if (k == 0) { return; // No valid chars } String[][] matrix; if (mSelection.getDirection() == Crossword.Word.DIR_ACROSS) { matrix = new String[1][k]; System.arraycopy(filtered, 0, matrix[0], 0, k); } else { matrix = new String[k][1]; for (int i = 0; i < k; i++) { matrix[i][0] = filtered[i]; } } Selectable s = null; if (mSelection.isCellWithinBounds(mSelection.mCell + k - 1)) { // If there's enough room at the current position, add the new word setChars(mSelection.getRow(), mSelection.getColumn(), matrix, false); s = new Selectable(mSelection.mWord, Math.min(mSelection.mCell + k, mSelection.mWord.getLength() - 1)); } else if (k == mSelection.mWord.getLength()) { // Not enough room from the current, but perfect fit for the entire row/col setChars(mSelection.getRow(0), mSelection.getColumn(0), matrix, false); s = new Selectable(mSelection.mWord, k - 1); } if (s != null) { resetSelection(s); } } } @Override public void onWordCancelled() { handleBackspace(); } @Override public void onEditorAction(int actionCode) { if (actionCode == EditorInfo.IME_ACTION_NEXT) { selectNextWord(); } } }; public CrosswordView(Context context, AttributeSet attrs) { super(context, attrs); if (!isInEditMode()) { setLayerType(View.LAYER_TYPE_HARDWARE, null); } // Set drawing defaults Resources r = context.getResources(); DisplayMetrics dm = r.getDisplayMetrics(); int cellFillColor = NORMAL_CELL_FILL_COLOR; int cheatedCellFillColor = CHEATED_CELL_FILL_COLOR; int mistakeCellFillColor = MISTAKE_CELL_FILL_COLOR; int selectedWordFillColor = SELECTED_WORD_FILL_COLOR; int selectedCellFillColor = SELECTED_CELL_FILL_COLOR; int markedCellFillColor = MARKED_CELL_FILL_COLOR; int numberTextColor = NUMBER_TEXT_COLOR; int cellStrokeColor = CELL_STROKE_COLOR; int circleStrokeColor = CIRCLE_STROKE_COLOR; int answerTextColor = ANSWER_TEXT_COLOR; mScaledDensity = dm.scaledDensity; float numberTextSize = NUMBER_TEXT_SIZE * mScaledDensity; mAnswerTextSize = ANSWER_TEXT_SIZE * mScaledDensity; mCellSize = CELL_SIZE * dm.density; mNumberTextPadding = NUMBER_TEXT_PADDING * dm.density; mIsEditable = true; mInputMode = INPUT_MODE_KEYBOARD; mSkipOccupiedOnType = false; mSelectFirstUnoccupiedOnNav = true; mMaxBitmapSize = DEFAULT_MAX_BITMAP_DIMENSION; // Read supplied attributes if (attrs != null) { Resources.Theme theme = context.getTheme(); TypedArray a = theme.obtainStyledAttributes(attrs, R.styleable.CrosswordView, 0, 0); mCellSize = a.getDimension(R.styleable.CrosswordView_cellSize, mCellSize); mNumberTextPadding = a.getDimension(R.styleable.CrosswordView_numberTextPadding, mNumberTextPadding); numberTextSize = a.getDimension(R.styleable.CrosswordView_numberTextSize, numberTextSize); mAnswerTextSize = a.getDimension(R.styleable.CrosswordView_answerTextSize, mAnswerTextSize); answerTextColor = a.getColor(R.styleable.CrosswordView_answerTextColor, answerTextColor); cellFillColor = a.getColor(R.styleable.CrosswordView_defaultCellFillColor, cellFillColor); cheatedCellFillColor = a.getColor(R.styleable.CrosswordView_cheatedCellFillColor, cheatedCellFillColor); mistakeCellFillColor = a.getColor(R.styleable.CrosswordView_mistakeCellFillColor, mistakeCellFillColor); selectedWordFillColor = a.getColor(R.styleable.CrosswordView_selectedWordFillColor, selectedWordFillColor); selectedCellFillColor = a.getColor(R.styleable.CrosswordView_selectedCellFillColor, selectedCellFillColor); markedCellFillColor = a.getColor(R.styleable.CrosswordView_markedCellFillColor, markedCellFillColor); cellStrokeColor = a.getColor(R.styleable.CrosswordView_cellStrokeColor, cellStrokeColor); circleStrokeColor = a.getColor(R.styleable.CrosswordView_circleStrokeColor, circleStrokeColor); numberTextColor = a.getColor(R.styleable.CrosswordView_numberTextColor, numberTextColor); mIsEditable = a.getBoolean(R.styleable.CrosswordView_editable, mIsEditable); mSkipOccupiedOnType = a.getBoolean(R.styleable.CrosswordView_skipOccupiedOnType, mSkipOccupiedOnType); mSelectFirstUnoccupiedOnNav = a.getBoolean(R.styleable.CrosswordView_selectFirstUnoccupiedOnNav, mSelectFirstUnoccupiedOnNav); a.recycle(); } mRevealSetsCheatFlag = true; mMarkerSideLength = mCellSize * MARKER_TRIANGLE_LENGTH_FRACTION; // Init paints mCellFillPaint = new Paint(); mCellFillPaint.setColor(cellFillColor); mCellFillPaint.setStyle(Paint.Style.FILL); mCheatedCellFillPaint = new Paint(); mCheatedCellFillPaint.setColor(cheatedCellFillColor); mCheatedCellFillPaint.setStyle(Paint.Style.FILL); mMistakeCellFillPaint = new Paint(); mMistakeCellFillPaint.setColor(mistakeCellFillColor); mMistakeCellFillPaint.setStyle(Paint.Style.FILL); mSelectedWordFillPaint = new Paint(); mSelectedWordFillPaint.setColor(selectedWordFillColor); mSelectedWordFillPaint.setStyle(Paint.Style.FILL); mSelectedCellFillPaint = new Paint(); mSelectedCellFillPaint.setColor(selectedCellFillColor); mSelectedCellFillPaint.setStyle(Paint.Style.FILL); mMarkedCellFillPaint = new Paint(); mMarkedCellFillPaint.setColor(markedCellFillColor); mMarkedCellFillPaint.setStyle(Paint.Style.FILL); mCellStrokePaint = new Paint(); mCellStrokePaint.setColor(cellStrokeColor); mCellStrokePaint.setStyle(Paint.Style.STROKE); // mCellStrokePaint.setStrokeWidth(1); mCircleStrokePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mCircleStrokePaint.setColor(circleStrokeColor); mCircleStrokePaint.setStyle(Paint.Style.STROKE); mCircleStrokePaint.setStrokeWidth(1); mNumberTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mNumberTextPaint.setColor(numberTextColor); mNumberTextPaint.setTextAlign(Paint.Align.CENTER); mNumberTextPaint.setTextSize(numberTextSize); // Compute number height mNumberTextPaint.getTextBounds("0", 0, "0".length(), mTempRect); mNumberTextHeight = mTempRect.height(); mNumberStrokePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mNumberStrokePaint.setColor(cellFillColor); mNumberStrokePaint.setTextAlign(Paint.Align.CENTER); mNumberStrokePaint.setTextSize(numberTextSize); mNumberStrokePaint.setStyle(Paint.Style.STROKE); mNumberStrokePaint.setStrokeWidth(NUMBER_TEXT_STROKE_WIDTH * mScaledDensity); mAnswerTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mAnswerTextPaint.setColor(answerTextColor); mAnswerTextPaint.setTextSize(mAnswerTextSize); // Init rest of the values mCircleRadius = (mCellSize / 2) - mCircleStrokePaint.getStrokeWidth(); mPuzzleCells = EMPTY_CELLS; mPuzzleWidth = 0; mPuzzleHeight = 0; mAllowedChars = EMPTY_CHARS; mRenderScale = 0; mBitmapOffset = new PointF(); mCenteredOffset = new PointF(); mTranslationBounds = new RectF(); mScaleDetector = new ScaleGestureDetector(context, new ScaleListener()); mGestureDetector = new GestureDetector(context, new GestureListener()); mContentRect = new RectF(); mPuzzleRect = new RectF(); mBitmapPaint = new Paint(Paint.FILTER_BITMAP_FLAG); mScroller = new Scroller(context, null, true); mZoomer = new Zoomer(context); mUndoBuffer = new Stack<>(); setFocusableInTouchMode(mIsEditable && mInputMode != INPUT_MODE_NONE); setOnKeyListener(this); } @Override protected Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); SavedState savedState = new SavedState(superState); savedState.mBitmapOffset = mBitmapOffset; savedState.mRenderScale = mRenderScale; return savedState; } @Override protected void onRestoreInstanceState(Parcelable state) { if (!(state instanceof SavedState)) { super.onRestoreInstanceState(state); return; } SavedState savedState = (SavedState) state; super.onRestoreInstanceState(savedState.getSuperState()); mBitmapOffset = savedState.mBitmapOffset; mRenderScale = savedState.mRenderScale; } @Override public boolean onTouchEvent(@NonNull MotionEvent ev) { boolean retVal = mScaleDetector.onTouchEvent(ev); retVal = mGestureDetector.onTouchEvent(ev) || retVal; return retVal || super.onTouchEvent(ev); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); constrainTranslation(); canvas.save(); canvas.clipRect(mContentRect); canvas.translate(mBitmapOffset.x, mBitmapOffset.y); canvas.scale(mBitmapScale, mBitmapScale); if (mPuzzleBitmap != null) { canvas.drawBitmap(mPuzzleBitmap, 0, 0, mBitmapPaint); } else { // Perform a fast, barebones render so that the screen doesn't // look completely empty mInPlaceRenderer.renderPuzzle(canvas, mRenderScale, true); } canvas.restore(); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); // Get the content rect mContentRect.set(getPaddingLeft(), getPaddingTop(), w - getPaddingRight(), h - getPaddingBottom()); resetConstraintsAndRedraw(false); if (mSelection != null) { bringIntoView(mSelection); } } @Override public InputConnection onCreateInputConnection(EditorInfo outAttrs) { Log.v(LOG_TAG, "onCreateInputConnection()"); CrosswordInputConnection inputConnection = null; if (mInputMode != INPUT_MODE_NONE) { outAttrs.actionLabel = null; outAttrs.inputType = InputType.TYPE_NULL; outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_FULLSCREEN; outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_EXTRACT_UI; outAttrs.imeOptions &= ~EditorInfo.IME_MASK_ACTION; outAttrs.imeOptions |= EditorInfo.IME_ACTION_NEXT; outAttrs.packageName = getContext().getPackageName(); inputConnection = new CrosswordInputConnection(this); inputConnection.setOnInputEventListener(mInputEventListener); } return inputConnection; } @Override public boolean onCheckIsTextEditor() { return mIsEditable && mInputMode != INPUT_MODE_NONE; } @Override public void computeScroll() { super.computeScroll(); boolean invalidate = false; if (mZoomer.computeZoom()) { mRenderScale = mScaleStart + mZoomer.getCurrZoom(); recomputePuzzleRect(); if (constrainScaling()) { recomputePuzzleRect(); } mBitmapScale = mRenderScale / mScaleStart; invalidate = true; } else { if (mIsZooming) { regenerateBitmaps(); mIsZooming = false; } } if (mScroller.computeScrollOffset()) { mBitmapOffset.set(mScroller.getCurrX(), mScroller.getCurrY()); invalidate = true; } if (invalidate) { ViewCompat.postInvalidateOnAnimation(this); } } @Override public boolean onKey(View v, int keyCode, KeyEvent event) { boolean handled = false; if (event.getAction() == KeyEvent.ACTION_UP) { if (keyCode == KeyEvent.KEYCODE_SPACE) { switchWordDirection(); handled = true; } else if (keyCode == KeyEvent.KEYCODE_DEL) { handleBackspace(); handled = true; } else { int uniChar = event.getUnicodeChar(); if (uniChar != 0) { handleInput((char) event.getUnicodeChar()); handled = true; } } } return handled; } public void solveWord(Crossword.Word word) { String matrix[][]; int wordLen = word.getLength(); if (word.getDirection() == Crossword.Word.DIR_ACROSS) { matrix = new String[1][wordLen]; for (int i = 0; i < wordLen; i++) { matrix[0][i] = word.cellAt(i).chars(); } } else if (word.getDirection() == Crossword.Word.DIR_DOWN) { matrix = new String[wordLen][1]; for (int i = 0; i < wordLen; i++) { matrix[i][0] = word.cellAt(i).chars(); } } else { throw new IllegalArgumentException("Word direction not valid"); } setChars(word.getStartRow(), word.getStartColumn(), matrix, true); } public boolean isSquareMarked(Crossword.Word word, int square) { Selectable s = new Selectable(word, square); Cell cell = mPuzzleCells[s.getRow()][s.getColumn()]; return cell.isFlagSet(Cell.FLAG_MARKED); } public void toggleSquareMark(Crossword.Word word, int square, boolean mark) { Selectable s = new Selectable(word, square); Cell cell = mPuzzleCells[s.getRow()][s.getColumn()]; if (cell != null && cell.setFlag(Cell.FLAG_MARKED, mark)) { if (mStateChangeListener != null) { mStateChangeListener.onCrosswordChanged(this); } redrawInPlace(); } } public void solveChar(Crossword.Word word, int charIndex) { int row = word.getStartRow(); int column = word.getStartColumn(); if (word.getDirection() == Crossword.Word.DIR_ACROSS) { column += charIndex; } else if (word.getDirection() == Crossword.Word.DIR_DOWN) { row += charIndex; } String ch = word.cellAt(charIndex).chars(); setChars(row, column, new String[][] { { ch } }, true); } public void solveCrossword() { String[][] matrix = new String[mPuzzleHeight][mPuzzleWidth]; for (Crossword.Word word : mCrossword.getWordsAcross()) { int row = word.getStartRow(); int startCol = word.getStartColumn(); for (int i = 0, n = word.getLength(); i < n; i++) { matrix[row][startCol + i] = word.cellAt(i).chars(); } } for (Crossword.Word word : mCrossword.getWordsDown()) { int startRow = word.getStartRow(); int col = word.getStartColumn(); for (int i = 0, n = word.getLength(); i < n; i++) { matrix[startRow + i][col] = word.cellAt(i).chars(); } } setChars(0, 0, matrix, true); } public void reset() { for (int i = 0; i < mPuzzleHeight; i++) { for (int j = 0; j < mPuzzleWidth; j++) { Cell cell = mPuzzleCells[i][j]; if (cell != null) { cell.reset(); } } } onBoardChanged(); redrawInPlace(); } public Crossword.Word getSelectedWord() { return mSelection != null ? mSelection.mWord : null; } public int getSelectedCell() { return mSelection != null ? mSelection.mCell : -1; } private void setChars(int startRow, int startColumn, String charMatrix[][], boolean setCheatFlag) { setChars(startRow, startColumn, charMatrix, setCheatFlag, false); } private void setChars(int startRow, int startColumn, String charMatrix[][], boolean setCheatFlag, boolean bypassUndoBuffer) { // Check startRow/startColumn if (startRow < 0 || startColumn < 0) { throw new IllegalArgumentException("Invalid startRow/startColumn"); } // Check dimensions if (charMatrix.length < 1 || charMatrix[0].length < 1) { throw new IllegalArgumentException("Invalid matrix size"); } int charHeight = charMatrix.length; int charWidth = charMatrix[0].length; int endRow = startRow + charHeight - 1; int endColumn = startColumn + charWidth - 1; // Check bounds if (endRow >= mPuzzleHeight || endColumn >= mPuzzleWidth) { throw new IllegalArgumentException("Chars out of bounds"); } // Set up undo buffer String[][] undoBuf = null; if (!bypassUndoBuffer && mUndoMode != UNDO_NONE) { undoBuf = new String[charHeight][charWidth]; } // Fill in the char array boolean cwChanged = false; Crossword.Cell[][] map = mCrossword.getCellMap(); for (int i = startRow, k = 0; i <= endRow; i++, k++) { for (int j = startColumn, l = 0; j <= endColumn; j++, l++) { Cell vwCell = mPuzzleCells[i][j]; if (vwCell != null) { if (undoBuf != null) { undoBuf[k][l] = vwCell.mChar; } String ch = Cell.canonicalize(charMatrix[k][l]); if (!TextUtils.equals(ch, vwCell.mChar) && isAcceptableChar(ch)) { boolean cellChanged = !TextUtils.equals(vwCell.mChar, ch); if (cellChanged) { vwCell.setChar(ch); cwChanged = true; } if (setCheatFlag) { vwCell.setFlag(Cell.FLAG_CHEATED, true); } if ((mMarkerDisplayMode & MARKER_ERROR) != 0) { vwCell.markError(map[i][j], mRevealSetsCheatFlag); } } } } } // Redraw selection redrawInPlace(); if (cwChanged) { if (undoBuf != null) { clearUndoBufferIfNeeded(mSelection); UndoItem item = new UndoItem(undoBuf, startRow, startColumn); if (mSelection != null) { item.setSelectable(mSelection); } mUndoBuffer.push(item); } onBoardChanged(); } } public Crossword getCrossword() { return mCrossword; } public void setCrossword(Crossword crossword) { mPuzzleHeight = 0; mPuzzleWidth = 0; mPuzzleCells = EMPTY_CELLS; mAllowedChars = EMPTY_CHARS; mCrossword = crossword; if (crossword != null) { initializeCrossword(crossword); selectNextWord(); } mRenderScale = 0; // Will recompute when reset resetConstraintsAndRedraw(true); } public CrosswordState getState() { if (mCrossword == null) { return null; } CrosswordState state = mCrossword.newState(); if (mSelection != null) { state.setSelection(mSelection.getDirection(), mSelection.mWord.getNumber(), mSelection.mCell); } for (int i = 0; i < mPuzzleHeight; i++) { for (int j = 0; j < mPuzzleWidth; j++) { Cell cell = mPuzzleCells[i][j]; if (cell != null) { if (!cell.isEmpty()) { state.setCharAt(i, j, cell.mChar); } state.setFlagAt(CrosswordState.FLAG_CHEATED, i, j, cell.isFlagSet(Cell.FLAG_CHEATED)); state.setFlagAt(CrosswordState.FLAG_MARKED, i, j, cell.isFlagSet(Cell.FLAG_MARKED)); } } } mCrossword.updateStateStatistics(state); return state; } public void restoreState(CrosswordState state) { if (state.getHeight() != mPuzzleHeight || state.getWidth() != mPuzzleWidth) { throw new RuntimeException("Dimensions for puzzle and state don't match"); } Crossword.Cell[][] map = mCrossword.getCellMap(); for (int i = 0; i < mPuzzleHeight; i++) { for (int j = 0; j < mPuzzleWidth; j++) { Cell cell = mPuzzleCells[i][j]; if (cell != null) { cell.setFlag(Cell.FLAG_CHEATED, state.isFlagSet(CrosswordState.FLAG_CHEATED, i, j)); cell.setFlag(Cell.FLAG_MARKED, state.isFlagSet(CrosswordState.FLAG_MARKED, i, j)); cell.setChar(state.charAt(i, j)); if ((mMarkerDisplayMode & MARKER_ERROR) != 0) { cell.markError(map[i][j], mRevealSetsCheatFlag); } } } } if (state.hasSelection()) { Crossword.Word word = mCrossword.findWord(state.getSelectedDirection(), state.getSelectedNumber()); int cell = state.getSelectedCell(); if (word != null && cell < word.getLength()) { resetSelection(new Selectable(word, cell), false); } } resetConstraintsAndRedraw(true); onBoardChanged(); } public void setOnSelectionChangeListener(OnSelectionChangeListener listener) { mSelectionChangeListener = listener; } public void setOnStateChangeListener(OnStateChangeListener listener) { mStateChangeListener = listener; } public void setOnLongPressListener(OnLongPressListener listener) { mLongpressListener = listener; } public void selectPreviousWord() { if (mCrossword != null) { selectWord(mCrossword.previousWord(mSelection != null ? mSelection.mWord : null)); } } public void selectNextWord() { if (mCrossword != null) { selectWord(mCrossword.nextWord(mSelection != null ? mSelection.mWord : null)); } } public void selectWord(int direction, int number) { Crossword.Word word = mCrossword.findWord(direction, number); if (word != null) { selectWord(word); } } public void selectWord(Crossword.Word word) { if (mCrossword != null) { int cell = 0; if (mSelectFirstUnoccupiedOnNav && word != null) { cell = Math.max(firstFreeCell(word, 0), 0); } resetSelection(word != null ? new Selectable(word, cell) : null); } } public Rect getCellRect(Crossword.Word word, int cell) { return getCellRect(new Selectable(word, cell)); } public String getCellContents(Crossword.Word word, int charIndex) { int row = word.getStartRow(); int column = word.getStartColumn(); if (word.getDirection() == Crossword.Word.DIR_ACROSS) { column += charIndex; } else if (word.getDirection() == Crossword.Word.DIR_DOWN) { row += charIndex; } Cell cell = mPuzzleCells[row][column]; if (cell == null) { return null; } return cell.mChar; } public void setCellContents(Crossword.Word word, int charIndex, String sol, boolean markAsCheated) { int row = word.getStartRow(); int column = word.getStartColumn(); if (word.getDirection() == Crossword.Word.DIR_ACROSS) { column += charIndex; } else if (word.getDirection() == Crossword.Word.DIR_DOWN) { row += charIndex; } setChars(row, column, new String[][] { { sol } }, markAsCheated); } public Typeface getAnswerTypeface() { return mAnswerTextPaint.getTypeface(); } public void setAnswerTypeface(Typeface typeface) { mAnswerTextPaint.setTypeface(typeface); redrawInPlace(); } public void setAnswerColor(@ColorInt int color) { mAnswerTextPaint.setColor(color); redrawInPlace(); } public int getMarkerDisplayMode() { return mMarkerDisplayMode; } public void setMarkerDisplayMode(int newMode) { if (mMarkerDisplayMode != newMode) { int oldMode = mMarkerDisplayMode; mMarkerDisplayMode = newMode; if ((oldMode & MARKER_ERROR) != (newMode & MARKER_ERROR)) { resetErrorMarkers(); } redrawInPlace(); } } public boolean isEditable() { return mIsEditable; } public void setEditable(boolean editable) { mIsEditable = editable; resetInputMode(); } public int getInputMode() { return mInputMode; } public void setInputMode(int mode) { mInputMode = mode; resetInputMode(); } public boolean skipOccupiedOnType() { return mSkipOccupiedOnType; } public void setSkipOccupiedOnType(boolean skip) { mSkipOccupiedOnType = skip; } public boolean selectFirstUnoccupiedOnNav() { return mSelectFirstUnoccupiedOnNav; } public void setSelectFirstUnoccupiedOnNav(boolean selectFirst) { mSelectFirstUnoccupiedOnNav = selectFirst; } public int getMaxBitmapSize() { return mMaxBitmapSize; } public void setMaxBitmapSize(int maxBitmapSize) { if (mMaxBitmapSize != maxBitmapSize) { mMaxBitmapSize = maxBitmapSize; resetConstraintsAndRedraw(true); } } public int getUndoMode() { return mUndoMode; } public void setUndoMode(int mode) { mUndoMode = mode; } private Rect getCellRect(Selectable sel) { Rect cellRect = null; if (sel != null) { float left = sel.getColumn() * mScaledCellSize + mBitmapOffset.x; float top = sel.getRow() * mScaledCellSize + mBitmapOffset.y; cellRect = new Rect(); cellRect.left = (int) (left); cellRect.top = (int) (top); cellRect.right = (int) (left + mScaledCellSize); cellRect.bottom = (int) (top + mScaledCellSize); } return cellRect; } private void clearUndoBufferIfNeeded(Selectable selectable) { if (mUndoMode != UNDO_SMART || mUndoBuffer.size() < 1) { return; } // Check the top item in the undo buffer. If it belongs to a different word, clear // the buffer UndoItem top = mUndoBuffer.peek(); if (top.mSelectable != null && !Crossword.Word.equals(selectable.mWord, top.mSelectable.mWord)) { mUndoBuffer.clear(); } } private void resetInputMode() { setFocusableInTouchMode(mIsEditable && mInputMode != INPUT_MODE_NONE); InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); if (imm != null) { if (imm.isActive(this)) { imm.hideSoftInputFromWindow(getWindowToken(), 0); } imm.restartInput(this); } } protected void handleInput(char ch) { if (!mIsEditable) { return; } String sch = String.valueOf(ch); if (mSelection != null && isAcceptableChar(ch)) { clearUndoBufferIfNeeded(mSelection); int row = mSelection.getRow(); int col = mSelection.getColumn(); Cell cell = mPuzzleCells[row][col]; boolean changed = !TextUtils.equals(cell.mChar, sch); if (changed) { mUndoBuffer.push(new UndoItem(cell.mChar, row, col).setSelectable(mSelection)); cell.setChar(sch); } if ((mMarkerDisplayMode & MARKER_ERROR) != 0) { Crossword.Cell[][] map = mCrossword.getCellMap(); cell.markError(map[row][col], mRevealSetsCheatFlag); } resetSelection(nextSelectable(mSelection)); if (changed) { onBoardChanged(); } } } private Selectable nextSelectable(Selectable selected) { int cell = selected.mCell; Crossword.Word word = selected.mWord; int nextCell = -1; if (mSkipOccupiedOnType) { nextCell = firstFreeCell(word, cell + 1); if (nextCell == -1 && cell + 1 < word.getLength()) { // No more free cells from this point, but we're still not // at the end nextCell = cell + 1; } } else { if (cell + 1 < word.getLength()) { nextCell = cell + 1; } } if (nextCell == -1) { word = mCrossword.nextWord(word); if (mSelectFirstUnoccupiedOnNav) { nextCell = Math.max(firstFreeCell(word, 0), 0); } else { nextCell = 0; } } return new Selectable(word, nextCell); } protected void handleBackspace() { if (mSelection == null || !mIsEditable) { return; } // If the undo buffer contains items, perform an undo action if (!mUndoBuffer.isEmpty()) { UndoItem item = mUndoBuffer.pop(); setChars(item.mStartRow, item.mStartCol, item.mChars, false, true); Selectable sel = item.mSelectable; if (sel != null) { Crossword.Word word = mCrossword.findWord(sel.getDirection(), sel.mWord.getNumber()); resetSelection(new Selectable(word, sel.mCell)); } return; } // Otherwise, act as a simple backspace Crossword.Word selectedWord = mSelection.mWord; int selectedCell = mSelection.mCell; Selectable s = new Selectable(mSelection); if (mPuzzleCells[s.getRow()][s.getColumn()].isEmpty()) { if (selectedCell > 0) { // Go back one cell and remove the char s.mCell = --selectedCell; } else { // At the first letter of a word. Select the previous word and do // what we did if (mSelectedCell > 0) selectedWord = mCrossword.previousWord(selectedWord); selectedCell = selectedWord.getLength() - 1; s.mWord = selectedWord; s.mCell = selectedCell; } } int row = s.getRow(); int col = s.getColumn(); boolean changed = mPuzzleCells[row][col].clearChar(); if ((mMarkerDisplayMode & MARKER_ERROR) != 0) { mPuzzleCells[row][col].setFlag(Cell.FLAG_ERROR, false); } resetSelection(new Selectable(selectedWord, selectedCell)); if (changed) { onBoardChanged(); } } public void switchWordDirection() { if (mCrossword == null) { return; } Selectable ortho = null; if (mSelection == null) { ortho = new Selectable(mCrossword.nextWord(null), 0); } else { Cell cell = mPuzzleCells[mSelection.getRow()][mSelection.getColumn()]; if (mSelection.getDirection() == Crossword.Word.DIR_ACROSS) { if (cell.mWordDownNumber != Cell.WORD_NUMBER_NONE) { Crossword.Word word = mCrossword.findWord(Crossword.Word.DIR_DOWN, cell.mWordDownNumber); if (word != null) { ortho = new Selectable(word, mSelection.getStartRow() - word.getStartRow()); } } } else if (mSelection.getDirection() == Crossword.Word.DIR_DOWN) { if (cell.mWordAcrossNumber != Cell.WORD_NUMBER_NONE) { Crossword.Word word = mCrossword.findWord(Crossword.Word.DIR_ACROSS, cell.mWordAcrossNumber); if (word != null) { ortho = new Selectable(word, mSelection.getStartColumn() - word.getStartColumn()); } } } } if (ortho != null) { resetSelection(ortho); clearUndoBufferIfNeeded(ortho); } } protected boolean zoomTo(float finalRenderScale) { if (Math.abs(finalRenderScale - mRenderScale) < .01f) { return false; } mZoomer.forceFinished(true); mIsZooming = true; mScaleStart = mRenderScale; mZoomer.startZoom(finalRenderScale - mScaleStart); ViewCompat.postInvalidateOnAnimation(CrosswordView.this); return true; } protected void showKeyboard() { if (mInputMode != INPUT_MODE_NONE) { InputMethodManager imm = (InputMethodManager) getContext() .getSystemService(Context.INPUT_METHOD_SERVICE); imm.showSoftInput(CrosswordView.this, InputMethodManager.SHOW_IMPLICIT); } } private void resetErrorMarkers() { Crossword.Cell[][] map = mCrossword.getCellMap(); for (int i = 0; i < map.length; i++) { for (int j = 0; j < map[i].length; j++) { Cell vwCell = mPuzzleCells[i][j]; if (vwCell != null) { if ((mMarkerDisplayMode & MARKER_ERROR) != 0) { vwCell.markError(map[i][j], mRevealSetsCheatFlag); } else { vwCell.setFlag(Cell.FLAG_ERROR, false); } } } } } private void initializeCrossword(Crossword crossword) { mPuzzleWidth = crossword.getWidth(); mPuzzleHeight = crossword.getHeight(); mPuzzleCells = new Cell[mPuzzleHeight][mPuzzleWidth]; // Copy allowed characters char allowedChars[] = crossword.getAlphabet(); mAllowedChars = new char[allowedChars.length]; for (int i = 0, n = allowedChars.length; i < n; i++) { mAllowedChars[i] = Character.toUpperCase(allowedChars[i]); } // Copy across for (Crossword.Word word : crossword.getWordsAcross()) { int row = word.getStartRow(); int startColumn = word.getStartColumn(); // Chars for (int column = startColumn, p = 0, n = word.getLength(); p < n; column++, p++) { Cell cell = new Cell(); cell.mWordAcrossNumber = word.getNumber(); cell.setFlag(Cell.FLAG_CIRCLED, word.cellAt(p).isCircled()); mPuzzleCells[row][column] = cell; } // Number mPuzzleCells[row][startColumn].mNumber = word.getNumber() + ""; } // Copy down for (Crossword.Word word : crossword.getWordsDown()) { int startRow = word.getStartRow(); int column = word.getStartColumn(); // Chars for (int row = startRow, p = 0, n = word.getLength(); p < n; row++, p++) { // It's possible that we already have a cell in that position from 'Across' // Check before creating a new cell Cell cell = mPuzzleCells[row][column]; if (cell == null) { cell = new Cell(); if (word.cellAt(p).isCircled()) { cell.setFlag(Cell.FLAG_CIRCLED, true); } mPuzzleCells[row][column] = cell; } cell.mWordDownNumber = word.getNumber(); } // Number if (mPuzzleCells[startRow][column].mNumber == null) { mPuzzleCells[startRow][column].mNumber = word.getNumber() + ""; } } } private void resetConstraintsAndRedraw(boolean forceBitmapRegen) { boolean regenBitmaps = mPuzzleBitmap == null && (mAsyncTask == null || mAsyncTask.isCancelled()); // Determine the scale at which the puzzle takes up the entire width float unscaledWidth = mPuzzleWidth * mCellSize + 1; // +1px for stroke brush mFitWidthScaleFactor = mContentRect.width() / unscaledWidth; // Set the default scale to be "fit to width" if (mRenderScale < .01) { mRenderScale = mFitWidthScaleFactor; } // Determine the smallest scale factor if (mContentRect.width() < mContentRect.height()) { mMinScaleFactor = mFitWidthScaleFactor; } else { float unscaledHeight = mPuzzleHeight * mCellSize + 1; mMinScaleFactor = mContentRect.height() / unscaledHeight; // +1px for stroke brush } int largestDimension = Math.max(mPuzzleWidth, mPuzzleHeight); float maxAvailableDimension = (float) (mMaxBitmapSize - 1); // stroke brush again mMaxScaleFactor = maxAvailableDimension / (largestDimension * mCellSize); mMaxScaleFactor = Math.max(mMaxScaleFactor, mMinScaleFactor); mBitmapScale = 1.0f; mIsZooming = false; // Recompute scaled puzzle size recomputePuzzleRect(); if (regenBitmaps || forceBitmapRegen) { regenerateBitmaps(); } } private void regenerateBitmaps() { synchronized (mRendererLock) { if (mAsyncTask != null) { mAsyncTask.cancel(false); } // A 1px size line is always present, so it's not enough to just // check for zero if (mPuzzleRect.width() > 1 && mPuzzleRect.height() > 1) { mAsyncTask = new RenderTask(mRenderScale); mAsyncTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } } } private void recomputePuzzleRect() { // Compute scaled puzzle rect mScaledCellSize = mRenderScale * mCellSize; mPuzzleRect.set(0, 0, mPuzzleWidth * mScaledCellSize + 1, // w/h get an extra pixel due to the mPuzzleHeight * mScaledCellSize + 1); // hairline-wide stroke of the cell // Determine center locations mCenteredOffset.set(mContentRect.left + (mContentRect.width() - mPuzzleRect.width()) / 2.0f, mContentRect.top + (mContentRect.height() - mPuzzleRect.height()) / 2.0f); // Compute translation bounds mTranslationBounds.set(mContentRect.right - mPuzzleRect.right, mContentRect.bottom - mPuzzleRect.bottom, mContentRect.left - mPuzzleRect.left, mContentRect.top - mPuzzleRect.top); constrainTranslation(); } private void constrainTranslation() { // Clamp the offset to fit within the view clampPointF(mBitmapOffset, mTranslationBounds); if (mPuzzleRect.width() < mContentRect.width()) { // Puzzle is narrower than the available width - center it horizontally mBitmapOffset.x = mCenteredOffset.x; } // Vertical if (mPuzzleRect.height() < mContentRect.height()) { // Puzzle is shorter than the available height - center it vertically mBitmapOffset.y = mCenteredOffset.y; } } private boolean constrainScaling() { if (mPuzzleRect.width() < mContentRect.width() && mPuzzleRect.height() < mContentRect.height()) { if (mRenderScale < mMinScaleFactor) { mRenderScale = mMinScaleFactor; return true; } } if (mRenderScale > mMaxScaleFactor) { mRenderScale = mMaxScaleFactor; return true; } return false; } private void bringIntoView(Selectable sel) { if (sel == null) { return; } RectF wordRect = new RectF(); wordRect.left = sel.getStartColumn() * mScaledCellSize - mContentRect.left; wordRect.top = sel.getStartRow() * mScaledCellSize - mContentRect.top; if (sel.getDirection() == Crossword.Word.DIR_ACROSS) { wordRect.right = wordRect.left + sel.mWord.getLength() * mScaledCellSize; wordRect.bottom = wordRect.top + mScaledCellSize; } else if (sel.getDirection() == Crossword.Word.DIR_DOWN) { wordRect.right = wordRect.left + mScaledCellSize; wordRect.bottom = wordRect.top + sel.mWord.getLength() * mScaledCellSize; } RectF objectRect = new RectF(wordRect); RectF visibleArea = new RectF(-mBitmapOffset.x, -mBitmapOffset.y, -mBitmapOffset.x + mContentRect.width(), -mBitmapOffset.y + mContentRect.height()); if (visibleArea.contains(objectRect)) { return; // Already visible } if (objectRect.width() > visibleArea.width() || objectRect.height() > visibleArea.height()) { // Available area isn't large enough to fit the entire word // Is the selected cell visible? If not, bring it into view RectF cellRect = new RectF(); cellRect.left = sel.getColumn() * mScaledCellSize; cellRect.top = sel.getRow() * mScaledCellSize; cellRect.right = cellRect.left + mScaledCellSize; cellRect.bottom = cellRect.top + mScaledCellSize; if (visibleArea.contains(cellRect)) { return; // Already visible } objectRect.set(cellRect); } // Compute view that includes the object in the center PointF end = new PointF((visibleArea.width() - objectRect.width()) / 2.0f - objectRect.left, (visibleArea.height() - objectRect.height()) / 2.0f - objectRect.top); // Clamp the values clampPointF(end, mTranslationBounds); // Compute the distance to travel from current location float distanceX = end.x - mBitmapOffset.x; float distanceY = end.y - mBitmapOffset.y; // Scroll the point into view mScroller.startScroll((int) mBitmapOffset.x, (int) mBitmapOffset.y, (int) distanceX, (int) distanceY, NAVIGATION_SCROLL_DURATION_MS); } private static void clampPointF(PointF point, RectF rect) { // Clamp the values if (point.x < rect.left) { point.x = rect.left; } else if (point.x > rect.right) { point.x = rect.right; } if (point.y < rect.top) { point.y = rect.top; } else if (point.y > rect.bottom) { point.y = rect.bottom; } } private void redrawInPlace() { if (mPuzzleCanvas != null) { synchronized (mRendererLock) { if (mAsyncTask != null) { mAsyncTask.cancel(false); } } mInPlaceRenderer.renderPuzzle(mPuzzleCanvas, mRenderScale, false); ViewCompat.postInvalidateOnAnimation(this); } } private void resetSelection(Selectable selection) { resetSelection(selection, true); } private void resetSelection(Selectable selection, boolean bringIntoView) { boolean selectionChanged = !Selectable.equals(selection, mSelection); if (mPuzzleCanvas != null) { // Create a canvas on top of the existing bitmap if (mSelection != null && selectionChanged) { // Clear the selection from the deselected word mInPlaceRenderer.renderSelection(mPuzzleCanvas, true); } } // Set new selection mSelection = selection; if (mPuzzleCanvas != null) { // Bring new selection into view, if requested if (bringIntoView) { bringIntoView(mSelection); } // Render the new selection mInPlaceRenderer.renderSelection(mPuzzleCanvas, false); } // Notify the listener of the change in selection if (selectionChanged && mSelectionChangeListener != null) { mSelectionChangeListener.onSelectionChanged(this, mSelection != null ? mSelection.mWord : null, mSelection != null ? mSelection.mCell : -1); } // Invalidate the view invalidate(); } private boolean isAcceptableChar(String ch) { if (ch == null) { return true; } String upper = ch.toUpperCase(); for (int i = 0, n = ch.length(); i < n; i++) { if (!isAcceptableChar(upper.charAt(i))) { return false; } } return true; } private boolean isAcceptableChar(char ch) { char upper = Character.toUpperCase(ch); for (char allowedChar : mAllowedChars) { if (upper == allowedChar) { return true; } } return false; } private boolean getCellOffset(float viewX, float viewY, CellOffset offset) { viewX -= mBitmapOffset.x; viewY -= mBitmapOffset.y; int column = (int) (viewX / mScaledCellSize); int row = (int) (viewY / mScaledCellSize); if (row >= 0 && row < mPuzzleHeight && column >= 0 && column < mPuzzleWidth) { offset.mRow = row; offset.mColumn = column; return true; } return false; } private void handleCellTap(CellOffset offset) { Cell cell = mPuzzleCells[offset.mRow][offset.mColumn]; if (cell == null) { return; } int preferredDir = Crossword.Word.DIR_ACROSS; if (mSelection != null) { if (offset.mRow == mSelection.getRow() && offset.mColumn == mSelection.getColumn()) { // Same cell tapped - flip direction switchWordDirection(); if (mIsEditable) { showKeyboard(); } return; } // Select a word in the same direction preferredDir = mSelection.getDirection(); } Selectable sel = getSelectable(offset, preferredDir); if (sel != null) { resetSelection(sel); // Undo buffer is always reset whenever a user switches to a different word. // We also want to reset the buffer if the user taps a different cell in the same word mUndoBuffer.clear(); } if (mIsEditable) { showKeyboard(); } } private void handleCellLongPress(CellOffset offset) { Cell cell = mPuzzleCells[offset.mRow][offset.mColumn]; if (cell == null) { return; } int preferredDir = Crossword.Word.DIR_ACROSS; if (mSelection != null) { preferredDir = mSelection.getDirection(); } Selectable sel = getSelectable(offset, preferredDir); if (sel != null) { resetSelection(sel, false); if (mLongpressListener != null) { // Notify the listener mLongpressListener.onCellLongPressed(this, mSelection.mWord, sel.mCell); } } } private int firstFreeCell(Crossword.Word word, int start) { int firstFree = -1; if (word != null) { for (int i = start, n = word.getLength(); i < n; i++) { int row = word.getStartRow(); int col = word.getStartColumn(); if (word.getDirection() == Crossword.Word.DIR_ACROSS) { col += i; } else if (word.getDirection() == Crossword.Word.DIR_DOWN) { row += i; } Cell cell = mPuzzleCells[row][col]; if (cell.isEmpty()) { firstFree = i; break; } } } return firstFree; } public boolean isSolved() { if ((mCrossword.getFlags() & Crossword.FLAG_NO_SOLUTION) != 0) { return false; } boolean solved = true; Crossword.Cell[][] map = mCrossword.getCellMap(); for (int i = 0; i < map.length; i++) { for (int j = 0; j < map[i].length; j++) { Cell vwCell = mPuzzleCells[i][j]; if (vwCell != null) { if (!vwCell.isSolved(map[i][j])) { solved = false; break; } } } } return solved; } private void onBoardChanged() { boolean solved = isSolved(); if (mStateChangeListener != null) { mStateChangeListener.onCrosswordChanged(this); if (solved != mIsSolved) { if (solved) { mStateChangeListener.onCrosswordSolved(this); } else { mStateChangeListener.onCrosswordUnsolved(this); } } } mIsSolved = solved; } private Selectable getSelectable(CellOffset co, int preferredDir) { Cell cell = mPuzzleCells[co.mRow][co.mColumn]; Selectable sel = null; if (cell != null) { int across = cell.mWordAcrossNumber; int down = cell.mWordDownNumber; // Select an Across word if we're currently selecting Across words, // or if we're selecting Down words, but no Down words are // available at that cell if (across != Cell.WORD_NUMBER_NONE) { if (preferredDir == Crossword.Word.DIR_ACROSS || (preferredDir == Crossword.Word.DIR_DOWN && down == Cell.WORD_NUMBER_NONE)) { Crossword.Word word = mCrossword.findWord(Crossword.Word.DIR_ACROSS, across); sel = new Selectable(word, co.mColumn - word.getStartColumn()); } } // Select a Down word if we're currently selecting Down words, // or if we're selecting Across words, but no Across words are // available at that cell if (down != Cell.WORD_NUMBER_NONE) { if (preferredDir == Crossword.Word.DIR_DOWN || (preferredDir == Crossword.Word.DIR_ACROSS && across == Cell.WORD_NUMBER_NONE)) { Crossword.Word word = mCrossword.findWord(Crossword.Word.DIR_DOWN, down); sel = new Selectable(word, co.mRow - word.getStartRow()); } } } return sel; } static class SavedState extends BaseSavedState { public static final Creator<SavedState> CREATOR = new Creator<SavedState>() { @Override public SavedState createFromParcel(Parcel source) { return new SavedState(source); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }; private float mRenderScale; private PointF mBitmapOffset; SavedState(Parcelable superState) { super(superState); } private SavedState(Parcel in) { super(in); mRenderScale = in.readFloat(); mBitmapOffset = in.readParcelable(PointF.class.getClassLoader()); } @Override public void writeToParcel(@NonNull Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeFloat(mRenderScale); dest.writeParcelable(mBitmapOffset, 0); } } private static class UndoItem { short mStartRow; short mStartCol; String[][] mChars; Selectable mSelectable; UndoItem(String ch, int row, int col) { this(new String[][] { { ch } }, row, col); } UndoItem(String[][] chars, int row, int col) { mChars = chars; mStartRow = (short) row; mStartCol = (short) col; mSelectable = null; } UndoItem setSelectable(Selectable selectable) { mSelectable = selectable; return this; } } private static class Selectable implements Parcelable { Crossword.Word mWord; int mCell; Selectable(Selectable s) { mWord = s.mWord; mCell = s.mCell; } Selectable(Crossword.Word word, int cell) { mWord = word; mCell = cell; } int getDirection() { return mWord.getDirection(); } int getStartRow() { return mWord.getStartRow(); } int getStartColumn() { return mWord.getStartColumn(); } int getRow() { return getRow(mCell); } int getColumn() { return getColumn(mCell); } int getEndRow() { return getRow(mWord.getLength() - 1); } int getEndColumn() { return getColumn(mWord.getLength() - 1); } int getRow(int cell) { int v = mWord.getStartRow(); if (mWord.getDirection() == Crossword.Word.DIR_DOWN) { v += Math.min(cell, mWord.getLength() - 1); } return v; } int getColumn(int cell) { int v = mWord.getStartColumn(); if (mWord.getDirection() == Crossword.Word.DIR_ACROSS) { v += Math.min(cell, mWord.getLength() - 1); } return v; } boolean isCellWithinBounds(int cell) { return cell >= 0 && cell < mWord.getLength(); } public static boolean equals(Selectable s1, Selectable s2) { if (s1 == null || s2 == null) { return s1 == s2; } return Crossword.Word.equals(s1.mWord, s2.mWord) && s1.mCell == s2.mCell; } @Override public boolean equals(Object o) { if (o instanceof Selectable) { return equals(this, (Selectable) o); } return super.equals(o); } public static final Creator<Selectable> CREATOR = new Creator<Selectable>() { public Selectable createFromParcel(Parcel in) { return new Selectable(in); } public Selectable[] newArray(int size) { return new Selectable[size]; } }; private Selectable(Parcel in) { mWord = in.readParcelable(Crossword.Word.class.getClassLoader()); mCell = in.readInt(); } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel dest, int flags) { dest.writeParcelable(mWord, 0); dest.writeInt(mCell); } } private static class CellOffset { int mRow; int mColumn; CellOffset() { } CellOffset(CellOffset offset) { mRow = offset.mRow; mColumn = offset.mColumn; } } private static class Cell implements Parcelable { public static final Creator<Cell> CREATOR = new Creator<Cell>() { public Cell createFromParcel(Parcel in) { return new Cell(in); } public Cell[] newArray(int size) { return new Cell[size]; } }; static final int WORD_NUMBER_NONE = -1; static final int FLAG_CHEATED = 1; static final int FLAG_CIRCLED = 1 << 1; static final int FLAG_ERROR = 1 << 2; static final int FLAG_MARKED = 1 << 3; String mNumber; String mChar; int mWordAcrossNumber; int mWordDownNumber; int mFlag; public Cell() { mFlag = 0; mWordAcrossNumber = mWordDownNumber = WORD_NUMBER_NONE; } static String canonicalize(String ch) { return ch != null ? ch.toUpperCase() : null; } boolean isEmpty() { return mChar == null; } boolean clearChar() { return setChar(null); } void reset() { clearChar(); setFlag(FLAG_CHEATED | FLAG_ERROR, false); } boolean setChar(String ch) { boolean changed = false; ch = canonicalize(ch); if (!TextUtils.equals(ch, mChar)) { mChar = ch; changed = true; } return changed; } boolean isSolved(Crossword.Cell cwCell) { return mChar != null && cwCell.contains(mChar); } void markError(Crossword.Cell cwCell, boolean setCheatFlag) { boolean error = !isEmpty() && !cwCell.contains(mChar); if (error) { mFlag |= FLAG_ERROR; if (setCheatFlag) { mFlag |= FLAG_CHEATED; } } else { mFlag &= ~FLAG_ERROR; } } boolean isFlagSet(int flag) { return (mFlag & flag) == flag; } boolean setFlag(int flag, boolean set) { int old = mFlag; if (set) { mFlag |= flag; } else { mFlag &= ~flag; } return old != mFlag; } private Cell(Parcel in) { mNumber = in.readString(); setChar(in.readString()); mWordAcrossNumber = in.readInt(); mWordDownNumber = in.readInt(); mFlag = in.readInt(); } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel dest, int flags) { dest.writeString(mNumber); dest.writeString(mChar); dest.writeInt(mWordAcrossNumber); dest.writeInt(mWordDownNumber); dest.writeInt(mFlag); } } private class Renderer { boolean mCancel; void renderCell(Canvas canvas, Cell cell, RectF cellRect, Paint fillPaint, boolean fastRender) { canvas.drawRect(cellRect, fillPaint); if (!fastRender) { // Render the markers first, so that the cell stroke paints over if (cell.isFlagSet(Cell.FLAG_MARKED) && (mMarkerDisplayMode & MARKER_CUSTOM) != 0) { Path path = new Path(); path.moveTo(cellRect.right - mMarkerSideLength, cellRect.top); path.lineTo(cellRect.right, cellRect.top); path.lineTo(cellRect.right, cellRect.top + mMarkerSideLength); path.close(); canvas.drawPath(path, mMarkedCellFillPaint); } if (cell.isFlagSet(Cell.FLAG_CHEATED) && (mMarkerDisplayMode & MARKER_CHEAT) != 0) { Path path = new Path(); path.moveTo(cellRect.right, cellRect.bottom); path.lineTo(cellRect.right - mMarkerSideLength, cellRect.bottom); path.lineTo(cellRect.right, cellRect.bottom - mMarkerSideLength); path.close(); canvas.drawPath(path, mCheatedCellFillPaint); } if (cell.isFlagSet(Cell.FLAG_ERROR) && (mMarkerDisplayMode & MARKER_ERROR) != 0) { Path path = new Path(); path.moveTo(cellRect.left, cellRect.bottom); path.lineTo(cellRect.left + mMarkerSideLength, cellRect.bottom); path.lineTo(cellRect.left, cellRect.bottom - mMarkerSideLength); path.close(); canvas.drawPath(path, mMistakeCellFillPaint); } } canvas.drawRect(cellRect, mCellStrokePaint); if (fastRender) { return; } if (cell.isFlagSet(Cell.FLAG_CIRCLED)) { canvas.drawCircle(cellRect.centerX(), cellRect.centerY(), mCircleRadius, mCircleStrokePaint); } float numberY = cellRect.top + mNumberTextPadding + mNumberTextHeight; if (cell.mNumber != null) { float textWidth = mNumberTextPaint.measureText(cell.mNumber); float numberX = cellRect.left + mNumberTextPadding + (textWidth / 2); if (cell.isFlagSet(Cell.FLAG_CIRCLED)) { canvas.drawText(cell.mNumber, numberX, numberY, mNumberStrokePaint); } canvas.drawText(cell.mNumber, numberX, numberY, mNumberTextPaint); } if (!cell.isEmpty()) { String text = cell.mChar; if (text.length() > 8) { // FIXME: customize max length and replacement pattern text = text.substring(0, 8) + ""; } mAnswerTextRect.set(cellRect.left, numberY, cellRect.right, cellRect.bottom); float textSize = mAnswerTextSize; float textWidth; do { mAnswerTextPaint.setTextSize(textSize); textWidth = mAnswerTextPaint.measureText(text); textSize -= mScaledDensity; } while (textWidth >= mCellSize); mAnswerTextPaint.getTextBounds("A", 0, 1, mTempRect); float xOffset = textWidth / 2f; float yOffset = mTempRect.height() / 2; canvas.drawText(text, mAnswerTextRect.centerX() - xOffset, mAnswerTextRect.centerY() + yOffset, mAnswerTextPaint); } } void renderSelection(Canvas canvas, boolean clearSelection) { if (mSelection == null) { return; } mCancel = false; int startRow = mSelection.getStartRow(); int endRow = mSelection.getEndRow(); int startColumn = mSelection.getStartColumn(); int endColumn = mSelection.getEndColumn(); RectF cellRect = new RectF(); canvas.save(); canvas.scale(mRenderScale, mRenderScale); float top = mSelection.getStartRow() * mCellSize; for (int row = startRow, index = 0; row <= endRow; row++, top += mCellSize) { float left = mSelection.getStartColumn() * mCellSize; for (int column = startColumn; column <= endColumn; column++, left += mCellSize) { Cell cell = mPuzzleCells[row][column]; if (cell != null) { if (mCancel) { canvas.restore(); return; } // Draw the unselected cell Paint paint; if (clearSelection) { paint = mCellFillPaint; } else { if (index == mSelection.mCell) { paint = mSelectedCellFillPaint; } else { paint = mSelectedWordFillPaint; } } cellRect.set(left, top, left + mCellSize, top + mCellSize); renderCell(canvas, cell, cellRect, paint, false); } index++; } } canvas.restore(); } void renderPuzzle(Canvas canvas, float scale, boolean fastRender) { mCancel = false; long startedMillis = SystemClock.uptimeMillis(); RectF cellRect = new RectF(); canvas.save(); canvas.scale(scale, scale); float top = 0; for (int i = 0; i < mPuzzleHeight; i++, top += mCellSize) { float left = 0; for (int j = 0; j < mPuzzleWidth; j++, left += mCellSize) { if (mCancel) { canvas.restore(); return; } Cell cell = mPuzzleCells[i][j]; if (cell != null) { cellRect.set(left, top, left + mCellSize, top + mCellSize); renderCell(canvas, cell, cellRect, mCellFillPaint, fastRender); } } } canvas.restore(); if (!fastRender) { renderSelection(canvas, false); } Log.d(LOG_TAG, String.format("Rendered puzzle (%.02fs)", (SystemClock.uptimeMillis() - startedMillis) / 1000f)); } } private class RenderTask extends AsyncTask<Void, Void, Void> { Canvas mRenderingCanvas; Bitmap mRenderedPuzzle; Renderer mRenderer = new Renderer(); float mScale; RenderTask(float scale) { mScale = scale; } @Override protected Void doInBackground(Void... params) { if (mPuzzleWidth > 0 && mPuzzleHeight > 0) { int width = (int) mPuzzleRect.width(); int height = (int) mPuzzleRect.height(); mRenderedPuzzle = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565); mRenderingCanvas = new Canvas(mRenderedPuzzle); int sizeBytes; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { sizeBytes = mRenderedPuzzle.getAllocationByteCount(); } else { sizeBytes = mRenderedPuzzle.getByteCount(); } Log.d(LOG_TAG, String.format("Created a new %dx%d puzzle bitmap (%,dkB)...", width, height, sizeBytes / 1024)); mRenderer.renderPuzzle(mRenderingCanvas, mScale, false); } else { Log.d(LOG_TAG, "Not creating an empty puzzle bitmap"); } return null; } @Override protected void onPostExecute(Void param) { super.onPostExecute(param); if (isCancelled()) { return; } mPuzzleCanvas = mRenderingCanvas; mPuzzleBitmap = mRenderedPuzzle; mBitmapScale = 1.0f; Log.d(LOG_TAG, "Invalidating..."); ViewCompat.postInvalidateOnAnimation(CrosswordView.this); synchronized (mRendererLock) { mAsyncTask = null; } } @Override protected void onCancelled(Void aVoid) { super.onCancelled(aVoid); mRenderer.mCancel = true; Log.d(LOG_TAG, "Task cancelled"); synchronized (mRendererLock) { mAsyncTask = null; } } } private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener { @Override public boolean onScaleBegin(ScaleGestureDetector detector) { if (mIsZooming) { // Double-tap scaling interferes with autozoom, so ignore // zoom requests mIgnoreZoom = true; return true; } mZoomer.forceFinished(true); mIsZooming = false; mScaleStart = mRenderScale; return true; } @Override public boolean onScale(ScaleGestureDetector detector) { if (mIgnoreZoom) { return true; } mRenderScale *= detector.getScaleFactor(); recomputePuzzleRect(); if (constrainScaling()) { recomputePuzzleRect(); } mBitmapScale = mRenderScale / mScaleStart; invalidate(); return true; } @Override public void onScaleEnd(ScaleGestureDetector detector) { if (!mIgnoreZoom) { regenerateBitmaps(); } mIgnoreZoom = false; } } private class GestureListener extends GestureDetector.SimpleOnGestureListener { CellOffset mTapLocation; GestureListener() { mTapLocation = new CellOffset(); } @Override public boolean onDown(MotionEvent e) { if (!mScroller.isFinished()) { mScroller.forceFinished(true); } return true; } @Override public boolean onSingleTapUp(MotionEvent e) { if (getCellOffset(e.getX(), e.getY(), mTapLocation)) { handleCellTap(new CellOffset(mTapLocation)); return true; } return false; } @Override public void onLongPress(MotionEvent e) { if (!mScaleDetector.isInProgress() && getCellOffset(e.getX(), e.getY(), mTapLocation)) { handleCellLongPress(mTapLocation); // Cancel the scale gesture by sending the detector // an ACTION_CANCEL event, to prevent double-tap scaling // from interfering. There should be a nicer way to do // this, but there isn't.. MotionEvent cancelEvent = MotionEvent.obtain(e.getDownTime(), e.getEventTime(), MotionEvent.ACTION_CANCEL, e.getX(), e.getY(), e.getMetaState()); mScaleDetector.onTouchEvent(cancelEvent); } } @Override public boolean onDoubleTap(MotionEvent e) { zoomTo(mFitWidthScaleFactor); return true; } @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distX, float distY) { mBitmapOffset.offset(-distX, -distY); constrainTranslation(); invalidate(); return true; } @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { // Horizontal int startX = (int) mBitmapOffset.x; int minX = startX; int maxX = startX; if (mPuzzleRect.width() >= mContentRect.width()) { // Puzzle exceeds content, set horizontal flinging bounds minX = (int) mTranslationBounds.left; maxX = (int) mTranslationBounds.right; } // Vertical int startY = (int) mBitmapOffset.y; int minY = startY; int maxY = startY; if (mPuzzleRect.height() >= mContentRect.height()) { // Puzzle exceeds content, set vertical flinging bounds minY = (int) mTranslationBounds.top; maxY = (int) mTranslationBounds.bottom; } mScroller.fling(startX, startY, (int) (velocityX / FLING_VELOCITY_DOWNSCALE), (int) (velocityY / FLING_VELOCITY_DOWNSCALE), minX, maxX, minY, maxY); return true; } } }