org.akop.crosswords.view.CrosswordView.java Source code

Java tutorial

Introduction

Here is the source code for org.akop.crosswords.view.CrosswordView.java

Source

// Copyright (c) 2014-2015 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.crosswords.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.Parcel;
import android.os.Parcelable;
import android.support.annotation.NonNull;
import android.support.v4.view.ViewCompat;
import android.text.InputType;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
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.crosswords.Crosswords;
import org.akop.crosswords.R;
import org.akop.crosswords.view.inputmethod.CrosswordInputConnection;
import org.akop.crosswords.widget.Zoomer;
import org.akop.xross.object.Crossword;

public class CrosswordView extends View implements View.OnKeyListener {
    public interface OnSelectionChangeListener {
        void onWordDeselected(CrosswordView view);

        void onWordSelectionChanged(CrosswordView view, Crossword.Word word);
    }

    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 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 = 2;
    private static final float NUMBER_TEXT_SIZE = 5;
    private static final float ANSWER_TEXT_PADDING = 2;
    private static final float ANSWER_TEXT_SIZE = 10;
    private static final float NUMBER_TEXT_STROKE_WIDTH = 1;

    private static int NORMAL_CELL_FILL_COLOR = Color.parseColor("#ffffff");
    private static int CHEATED_CELL_FILL_COLOR = Color.parseColor("#ff8b85");
    private static int MISTAKE_CELL_FILL_COLOR = Color.parseColor("#ff0000");
    private static int SELECTED_WORD_FILL_COLOR = Color.parseColor("#faeace");
    private static int SELECTED_CELL_FILL_COLOR = Color.parseColor("#ecae44");
    private static int TEXT_COLOR = Color.parseColor("#000000");
    private static int CELL_STROKE_COLOR = Color.parseColor("#000000");
    private static 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 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 mAnswerTextPadding;

    private int mPuzzleWidth; // Total number of cells across
    private int mPuzzleHeight; // Total number of cells down
    private Cell[][] mPuzzleCells; // Map of the cells
    private Crossword.Word mSelectedWord;
    private int mSelectedCell;
    private char[] mAllowedChars;
    private char[] mAutocorrectBuffer;

    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 BitmapRenderer mAsyncRenderer;
    private Canvas mPuzzleCanvas;
    private Bitmap mPuzzleBitmap;
    private Paint mBitmapPaint;

    private boolean mEnableErrorHighlighting;
    private boolean mIsZooming;
    private Zoomer mZoomer;
    private Scroller mScroller;

    private Rect mTempRect = new Rect(); // For temp. calculation

    private OnSelectionChangeListener mSelectionChangeListener;
    private OnLongPressListener mLongpressListener;

    private CrosswordInputConnection.OnInputEventListener mInputEventListener = new CrosswordInputConnection.OnInputEventListener() {
        @Override
        public void onWordEntered(CharSequence text) {
            if (text != null) {
                if (mSelectedWord != null) {
                    // Words like "ain't" contain punctuation marks that aren'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
                    char[] tempBuffer = new char[chars.length];
                    int k = 0;
                    for (char ch : chars) {
                        if (isAcceptableChar(ch)) {
                            tempBuffer[k++] = ch;
                        }
                    }

                    char[] validChars;
                    if (k == tempBuffer.length) {
                        validChars = tempBuffer;
                    } else {
                        // Truncate to the last valid character
                        validChars = new char[k];
                        System.arraycopy(tempBuffer, 0, validChars, 0, k);
                    }

                    // If the length of resulting word matches that of
                    // selection, fill it in
                    if (mSelectedWord.getLength() == validChars.length) {
                        fillSelectedWord(validChars, false);
                    }
                }
            }
        }

        @Override
        public void onWordCancelled() {
            if (mAutocorrectBuffer != null) {
                // Replace the contents of the selection with whatever is in
                // mAutocorrectBuffer
                fillSelectedWord(mAutocorrectBuffer, true);
                mAutocorrectBuffer = null;
            } else {
                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 textColor = TEXT_COLOR;
        int cellStrokeColor = CELL_STROKE_COLOR;
        int circleStrokeColor = CIRCLE_STROKE_COLOR;

        float numberTextSize = NUMBER_TEXT_SIZE * dm.scaledDensity;
        float answerTextSize = ANSWER_TEXT_SIZE * dm.scaledDensity;

        mCellSize = CELL_SIZE * dm.density;
        mNumberTextPadding = NUMBER_TEXT_PADDING * dm.density;
        mAnswerTextPadding = ANSWER_TEXT_PADDING * dm.density;

        // Read supplied attributes
        if (attrs != null) {
            Resources.Theme theme = context.getTheme();
            TypedArray a = theme.obtainStyledAttributes(attrs, R.styleable.Crossword, 0, 0);
            mCellSize = a.getDimension(R.styleable.Crossword_cellSize, mCellSize);
            mNumberTextPadding = a.getDimension(R.styleable.Crossword_numberTextPadding, mNumberTextPadding);
            numberTextSize = a.getDimension(R.styleable.Crossword_numberTextSize, numberTextSize);
            mAnswerTextPadding = a.getDimension(R.styleable.Crossword_answerTextPadding, mAnswerTextPadding);
            answerTextSize = a.getDimension(R.styleable.Crossword_answerTextSize, answerTextSize);
            cellFillColor = a.getColor(R.styleable.Crossword_defaultCellFillColor, cellFillColor);
            cheatedCellFillColor = a.getColor(R.styleable.Crossword_cheatedCellFillColor, cheatedCellFillColor);
            mistakeCellFillColor = a.getColor(R.styleable.Crossword_mistakeCellFillColor, mistakeCellFillColor);
            selectedWordFillColor = a.getColor(R.styleable.Crossword_selectedWordFillColor, selectedWordFillColor);
            selectedCellFillColor = a.getColor(R.styleable.Crossword_selectedCellFillColor, selectedCellFillColor);
            cellStrokeColor = a.getColor(R.styleable.Crossword_cellStrokeColor, cellStrokeColor);
            circleStrokeColor = a.getColor(R.styleable.Crossword_circleStrokeColor, circleStrokeColor);
            textColor = a.getColor(R.styleable.Crossword_textColor, textColor);
            a.recycle();
        }

        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);

        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(textColor);
        mNumberTextPaint.setTextAlign(Paint.Align.CENTER);
        mNumberTextPaint.setTextSize(numberTextSize);

        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 * dm.scaledDensity);

        mAnswerTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mAnswerTextPaint.setColor(textColor);
        mAnswerTextPaint.setTextSize(answerTextSize);
        mAnswerTextPaint.setTextAlign(Paint.Align.CENTER);

        // http://www.google.com/fonts/specimen/Kalam
        Typeface typeface = Typeface.createFromAsset(context.getAssets(), "kalam-regular.ttf");
        mAnswerTextPaint.setTypeface(typeface);

        // 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);

        setFocusableInTouchMode(true);
        setOnKeyListener(this);
    }

    @Override
    protected Parcelable onSaveInstanceState() {
        Parcelable superState = super.onSaveInstanceState();
        SavedState savedState = new SavedState(superState);

        savedState.mPuzzleCells = mPuzzleCells;
        savedState.mPuzzleHeight = mPuzzleHeight;
        savedState.mPuzzleWidth = mPuzzleWidth;
        savedState.mBitmapOffset = mBitmapOffset;
        savedState.mRenderScale = mRenderScale;
        savedState.mSelectedWord = mSelectedWord;
        savedState.mSelectedCell = mSelectedCell;
        savedState.mAllowedChars = mAllowedChars;
        savedState.mCrossword = mCrossword;
        savedState.mEnableErrorHighlighting = mEnableErrorHighlighting;

        return savedState;
    }

    @Override
    protected void onRestoreInstanceState(Parcelable state) {
        if (!(state instanceof SavedState)) {
            super.onRestoreInstanceState(state);
            return;
        }

        SavedState savedState = (SavedState) state;
        super.onRestoreInstanceState(savedState.getSuperState());

        mPuzzleCells = savedState.mPuzzleCells;
        mPuzzleHeight = savedState.mPuzzleHeight;
        mPuzzleWidth = savedState.mPuzzleWidth;
        mBitmapOffset = savedState.mBitmapOffset;
        mRenderScale = savedState.mRenderScale;
        mSelectedWord = savedState.mSelectedWord;
        mSelectedCell = savedState.mSelectedCell;
        mAllowedChars = savedState.mAllowedChars;
        mCrossword = savedState.mCrossword;
        mEnableErrorHighlighting = savedState.mEnableErrorHighlighting;
    }

    @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);

        if (mPuzzleBitmap != null) {
            canvas.save();
            canvas.clipRect(mContentRect);
            canvas.translate(mBitmapOffset.x, mBitmapOffset.y);
            canvas.scale(mBitmapScale, mBitmapScale);
            canvas.drawBitmap(mPuzzleBitmap, 0, 0, mBitmapPaint);

            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(), getWidth() - getPaddingRight(),
                getHeight() - getPaddingBottom());

        resetConstraintsAndRedraw();
    }

    @Override
    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
        outAttrs.actionLabel = null;
        outAttrs.inputType = InputType.TYPE_NULL; //InputType.TYPE_CLASS_TEXT;
        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();

        CrosswordInputConnection inputConnection = new CrosswordInputConnection(this);
        inputConnection.setOnInputEventListener(mInputEventListener);

        return inputConnection;
    }

    @Override
    public boolean onCheckIsTextEditor() {
        return true;
    }

    @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) {
        char matrix[][];
        int wordLen = word.getLength();
        if (word.getDirection() == Crossword.Word.DIR_ACROSS) {
            matrix = new char[1][wordLen];
            for (int i = 0; i < wordLen; i++) {
                matrix[0][i] = word.cellAt(i).charAt(0); // FIXME: multicells
            }
        } else if (word.getDirection() == Crossword.Word.DIR_DOWN) {
            matrix = new char[wordLen][1];
            for (int i = 0; i < wordLen; i++) {
                matrix[i][0] = word.cellAt(i).charAt(0); // FIXME: multicells
            }
        } else {
            throw new IllegalArgumentException("Word direction not valid");
        }

        setChars(word.getStartRow(), word.getStartColumn(), matrix, true);
    }

    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;
        }

        // FIXME: multicells
        char ch = word.cellAt(charIndex).charAt(0);
        setChars(row, column, new char[][] { { ch, }, }, true);
    }

    public void solveCrossword() {
        // FIXME: multicells
        char[][] matrix = new char[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).charAt(0);
            }
        }
        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).charAt(0);
            }
        }

        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();
                }
            }
        }

        redrawInPlace();
    }

    public boolean getErrorHighlightingEnabled() {
        return mEnableErrorHighlighting;
    }

    public void setErrorHighlightingEnabled(boolean enabled) {
        if (mEnableErrorHighlighting != enabled) {
            mEnableErrorHighlighting = enabled;
            resetErrorMarkers();
        }
    }

    public void setChars(int startRow, int startColumn, char charMatrix[][], boolean setCheatFlag) {
        // 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");
        }

        // Fill in the char array
        mAutocorrectBuffer = null;
        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) {
                    char ch = Cell.canonicalize(charMatrix[k][l]);
                    if (ch != vwCell.mChar && isAcceptableChar(ch)) {
                        vwCell.setChar(ch);
                        if (setCheatFlag) {
                            vwCell.mCheated = true;
                        }
                        if (mEnableErrorHighlighting) {
                            vwCell.markError(map[i][j]);
                        }
                    }
                }
            }
        }

        // Redraw selection
        redrawInPlace();
    }

    private void fillSelectedWord(char[] chars, boolean rewindCursor) {
        if (mSelectedWord == null) {
            return;
        }

        mAutocorrectBuffer = getChars(mSelectedWord);

        int startRow = mSelectedWord.getStartRow();
        int startCol = mSelectedWord.getStartColumn();
        int dir = mSelectedWord.getDirection();
        int endRow = startRow;
        int endCol = startCol;
        int length = Math.min(mSelectedWord.getLength(), chars.length);

        if (dir == Crossword.Word.DIR_ACROSS) {
            endCol += length - 1;
        } else if (dir == Crossword.Word.DIR_DOWN) {
            endRow += length - 1;
        }

        int k = 0;
        Crossword.Cell[][] map = mCrossword.getCellMap();
        for (int i = startRow; i <= endRow; i++) {
            for (int j = startCol; j <= endCol; j++) {
                Cell cell = mPuzzleCells[i][j];
                char ch = chars[k++];
                if (cell != null) {
                    if (isAcceptableChar(ch)) {
                        cell.setChar(ch);
                    } else {
                        cell.clearChar();
                    }
                    if (mEnableErrorHighlighting) {
                        cell.markError(map[i][j]);
                    }
                }
            }
        }

        int position = 0;
        if (!rewindCursor && k > 1) {
            position = k - 1;
        }

        mSelectedCell = position;

        redrawInPlace();
    }

    public void setCrossword(Crossword crossword) {
        mPuzzleHeight = 0;
        mPuzzleWidth = 0;
        mPuzzleCells = EMPTY_CELLS;
        mAllowedChars = EMPTY_CHARS;
        mCrossword = crossword;

        if (crossword != null) {
            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.mCircled = 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.mCircled = true;
                        }
                        mPuzzleCells[row][column] = cell;
                    }

                    cell.mWordDownNumber = word.getNumber();
                }

                // Number
                if (mPuzzleCells[startRow][column].mNumber == null) {
                    mPuzzleCells[startRow][column].mNumber = word.getNumber() + "";
                }
            }
        }

        mRenderScale = 0; // Will recompute when reset

        resetConstraintsAndRedraw();
    }

    public Crossword.State getState() {
        if (mCrossword == null) {
            return null;
        }

        Crossword.State state = mCrossword.newState();

        if (mSelectedWord != null) {
            state.setSelection(mSelectedWord.getDirection(), mSelectedWord.getNumber(), mSelectedCell);
        }

        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.setCheatedAt(i, j, cell.mCheated);
                }
            }
        }
        mCrossword.updateStateStatistics(state);

        return state;
    }

    public void restoreState(Crossword.State 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.mCheated = state.cheatedAt(i, j);
                    cell.setChar(state.charAt(i, j));
                    if (mEnableErrorHighlighting) {
                        cell.markError(map[i][j]);
                    }
                }
            }
        }

        if (state.hasSelection()) {
            Crossword.Word word = mCrossword.findWord(state.getSelectedDirection(), state.getSelectedNumber());
            int cell = state.getSelectedCell();
            if (word != null && cell < word.getLength()) {
                resetSelection(word, cell, false);
            }
        }

        Crosswords.logv("State loaded successfully");
        resetConstraintsAndRedraw();
    }

    public void setOnSelectionChangeListener(OnSelectionChangeListener listener) {
        mSelectionChangeListener = listener;
    }

    public void setOnLongPressListener(OnLongPressListener listener) {
        mLongpressListener = listener;
    }

    public void selectPreviousWord() {
        if (mCrossword != null) {
            Crossword.Word previous = mCrossword.previousWord(mSelectedWord);
            resetSelection(previous, Math.max(firstFreeCell(previous), 0));
        }
    }

    public void selectNextWord() {
        if (mCrossword != null) {
            Crossword.Word next = mCrossword.nextWord(mSelectedWord);
            resetSelection(next, Math.max(firstFreeCell(next), 0));
        }
    }

    public void selectWord(Crossword.Word word) {
        if (mCrossword != null) {
            resetSelection(word, 0);
        }
    }

    public Rect getCellRect(Crossword.Word word, int cell) {
        Rect cellRect = null;

        if (word != null && cell < word.getLength()) {
            CellOffset co = getCellOffset(word, cell);

            float left = co.mColumn * mScaledCellSize + mBitmapOffset.x;
            float top = co.mRow * 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;
    }

    protected void handleInput(char ch) {
        if (mSelectedWord != null) {
            if (isAcceptableChar(ch)) {
                mAutocorrectBuffer = null;
                Crossword.Word selectedWord = mSelectedWord;
                int selectedCell = mSelectedCell;
                CellOffset co = getCellOffset(mSelectedWord, selectedCell);

                Cell cell = mPuzzleCells[co.mRow][co.mColumn];
                //            boolean wasEmpty = cell.isEmpty();
                cell.setChar(ch);

                if (mEnableErrorHighlighting) {
                    Crossword.Cell[][] map = mCrossword.getCellMap();
                    cell.markError(map[co.mRow][co.mColumn]);
                    //               if (!wasEmpty && !cell.mCheated) {
                    //                  // User practically cheated
                    //                  cell.mCheated = !cell.mError;
                    //               }
                }

                if (selectedCell + 1 < selectedWord.getLength()) {
                    selectedCell++;
                } else {
                    selectedWord = mCrossword.nextWord(selectedWord);
                    selectedCell = 0;
                }

                resetSelection(selectedWord, selectedCell);
            }
        }
    }

    protected void handleBackspace() {
        if (mSelectedWord == null) {
            return;
        }

        Crossword.Word selectedWord = mSelectedWord;
        int selectedIndex = mSelectedCell;

        CellOffset co = getCellOffset(mSelectedWord, mSelectedCell);
        if (!mPuzzleCells[co.mRow][co.mColumn].isEmpty()) {
            // Delete currently selected char
            mPuzzleCells[co.mRow][co.mColumn].clearChar();
        } else if (mSelectedCell > 0) {
            // Go back one cell and remove the char
            selectedIndex--;

            co = getCellOffset(selectedWord, selectedIndex);
            mPuzzleCells[co.mRow][co.mColumn].clearChar();
        } else {
            // At the first letter of a word. Select the previous word and do
            // what we did if (mSelectedCell > 0)
            selectedWord = mCrossword.previousWord(mSelectedWord);
            selectedIndex = selectedWord.getLength() - 1;

            co = getCellOffset(selectedWord, selectedIndex);
            mPuzzleCells[co.mRow][co.mColumn].clearChar();
        }

        resetSelection(selectedWord, selectedIndex);
    }

    protected void switchWordDirection() {
        if (mSelectedWord != null) {
            Crossword.Word selWord = mSelectedWord;
            int selCell = mSelectedCell;
            Crossword.Word orthoWord = null;
            int orthoCell = 0;

            if (selWord.getDirection() == Crossword.Word.DIR_ACROSS) {
                Cell cell = mPuzzleCells[selWord.getStartRow()][selWord.getStartColumn() + selCell];
                if (cell.mWordDownNumber != Cell.WORD_NUMBER_NONE) {
                    orthoWord = mCrossword.findWord(Crossword.Word.DIR_DOWN, cell.mWordDownNumber);
                    orthoCell = selWord.getStartRow() - orthoWord.getStartRow();
                }
            } else if (selWord.getDirection() == Crossword.Word.DIR_DOWN) {
                Cell cell = mPuzzleCells[selWord.getStartRow() + selCell][selWord.getStartColumn()];
                if (cell.mWordAcrossNumber != Cell.WORD_NUMBER_NONE) {
                    orthoWord = mCrossword.findWord(Crossword.Word.DIR_ACROSS, cell.mWordAcrossNumber);
                    orthoCell = selWord.getStartColumn() - orthoWord.getStartColumn();
                }
            }

            if (orthoWord != null) {
                resetSelection(orthoWord, orthoCell);
            }
        }
    }

    protected void zoomTo(float finalRenderScale) {
        float delta = Math.abs(finalRenderScale - mRenderScale);
        if (delta < .01f) {
            Crosswords.logv("Ignoring autozoom request (delta: %.02f)", delta);
            return;
        }

        mZoomer.forceFinished(true);
        mIsZooming = true;
        mScaleStart = mRenderScale;

        mZoomer.startZoom(finalRenderScale - mScaleStart);
        ViewCompat.postInvalidateOnAnimation(CrosswordView.this);
    }

    protected void showKeyboard() {
        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 (mEnableErrorHighlighting) {
                        vwCell.markError(map[i][j]);
                    } else {
                        vwCell.mError = false;
                    }
                }
            }
        }

        // Redraw selection
        redrawInPlace();
    }

    private CellOffset getCellOffset(Crossword.Word word, int position) {
        CellOffset offset = null;
        if (position >= 0 && position < word.getLength()) {
            offset = new CellOffset(word.getStartRow(), word.getStartColumn());
            if (word.getDirection() == Crossword.Word.DIR_ACROSS) {
                offset.mColumn += position;
            } else if (word.getDirection() == Crossword.Word.DIR_DOWN) {
                offset.mRow += position;
            }
        }

        return offset;
    }

    private void resetConstraintsAndRedraw() {
        // 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) (MAX_BITMAP_DIMENSION - 1); // stroke brush again

        mMaxScaleFactor = maxAvailableDimension / (largestDimension * mCellSize);
        mMaxScaleFactor = Math.max(mMaxScaleFactor, mMinScaleFactor);

        mBitmapScale = 1.0f;
        mIsZooming = false;
        if (mRenderScale < mMinScaleFactor) {
            mRenderScale = mMinScaleFactor;
        }

        // Recompute scaled puzzle size
        recomputePuzzleRect();
        if (constrainScaling()) {
            recomputePuzzleRect();
        }

        regenerateBitmaps();
    }

    private void regenerateBitmaps() {
        synchronized (this) {
            if (mAsyncRenderer != null) {
                mAsyncRenderer.cancel(false);
            }

            mAsyncRenderer = new BitmapRenderer(mRenderScale);
            mAsyncRenderer.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(Crossword.Word word, int selectedCell) {
        if (word == null) {
            return;
        }

        RectF wordRect = new RectF();

        wordRect.left = word.getStartColumn() * mScaledCellSize - mContentRect.left;
        wordRect.top = word.getStartRow() * mScaledCellSize - mContentRect.top;

        if (word.getDirection() == Crossword.Word.DIR_ACROSS) {
            wordRect.right = wordRect.left + word.getLength() * mScaledCellSize;
            wordRect.bottom = wordRect.top + mScaledCellSize;
        } else if (word.getDirection() == Crossword.Word.DIR_DOWN) {
            wordRect.right = wordRect.left + mScaledCellSize;
            wordRect.bottom = wordRect.top + word.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

            CellOffset co = getCellOffset(word, selectedCell);
            RectF cellRect = new RectF();
            cellRect.left = co.mColumn * mScaledCellSize;
            cellRect.top = co.mRow * 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() {
        mAsyncRenderer = new BitmapRenderer(mRenderScale);
        mAsyncRenderer.renderPuzzle(mPuzzleCanvas);

        ViewCompat.postInvalidateOnAnimation(this);
    }

    private void renderCell(Canvas canvas, Cell cell, RectF cellRect, Paint fillPaint) {
        canvas.drawRect(cellRect, fillPaint);
        canvas.drawRect(cellRect, mCellStrokePaint);

        if (cell.mCircled) {
            canvas.drawCircle(cellRect.centerX(), cellRect.centerY(), mCircleRadius, mCircleStrokePaint);
        }

        if (cell.mNumber != null) {
            mNumberTextPaint.getTextBounds(cell.mNumber, 0, cell.mNumber.length(), mTempRect);

            float numberX = cellRect.left + mNumberTextPadding + (mTempRect.width() / 2);
            float numberY = cellRect.top + mNumberTextPadding + mTempRect.height();

            if (cell.mCircled) {
                canvas.drawText(cell.mNumber, numberX, numberY, mNumberStrokePaint);
            }

            canvas.drawText(cell.mNumber, numberX, numberY, mNumberTextPaint);
        }

        if (cell.mCheated) {
            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);
            canvas.drawPath(path, mCellStrokePaint);
        }

        if (cell.mError) {
            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.drawPath(path, mCellStrokePaint);
        }

        if (!cell.isEmpty()) {
            mAnswerTextPaint.getTextBounds(cell.mCharStr, 0, cell.mCharStr.length(), mTempRect);
            canvas.drawText(cell.mCharStr, cellRect.left + mCellSize / 2,
                    cellRect.top + mCellSize - mAnswerTextPadding, mAnswerTextPaint);
        }
    }

    private void resetSelection(Crossword.Word newSelectedWord, int newSelectedCell) {
        resetSelection(newSelectedWord, newSelectedCell, true);
    }

    private void resetSelection(Crossword.Word newSelectedWord, int newSelectedCell, boolean bringIntoView) {
        boolean selectionChanged = !Crossword.Word.equals(mSelectedWord, newSelectedWord);
        if (mPuzzleCanvas != null) {
            // Create a canvas on top of the existing bitmap
            if (mSelectedWord != null && selectionChanged) {
                // Clear the selection from the deselected word
                renderSelection(mPuzzleCanvas, true);
                mAutocorrectBuffer = null;
            }
        }

        // Set new selection
        mSelectedWord = newSelectedWord;
        mSelectedCell = newSelectedCell;

        if (mPuzzleCanvas != null) {
            // Bring new selection into view, if requested
            if (bringIntoView) {
                bringIntoView(mSelectedWord, mSelectedCell);
            }

            // Render the new selection
            renderSelection(mPuzzleCanvas, false);
        }

        // Notify the listener of the change in selection
        if (selectionChanged && mSelectionChangeListener != null) {
            if (mSelectedWord == null) {
                mSelectionChangeListener.onWordDeselected(this);
            } else {
                mSelectionChangeListener.onWordSelectionChanged(this, mSelectedWord);
            }
        }

        // Invalidate the view
        invalidate();
    }

    private void renderSelection(Canvas canvas, boolean clearSelection) {
        if (mSelectedWord == null) {
            return;
        }

        int startRow = mSelectedWord.getStartRow();
        int endRow = startRow;
        int startColumn = mSelectedWord.getStartColumn();
        int endColumn = startColumn;
        RectF cellRect = new RectF();

        canvas.save();
        canvas.scale(mRenderScale, mRenderScale);

        if (mSelectedWord.getDirection() == Crossword.Word.DIR_ACROSS) {
            endColumn += mSelectedWord.getLength() - 1;
        } else {
            endRow += mSelectedWord.getLength() - 1;
        }

        float top = mSelectedWord.getStartRow() * mCellSize;
        for (int row = startRow, index = 0; row <= endRow; row++, top += mCellSize) {
            float left = mSelectedWord.getStartColumn() * mCellSize;
            for (int column = startColumn; column <= endColumn; column++, left += mCellSize) {
                Cell cell = mPuzzleCells[row][column];
                if (cell != null) {
                    // Draw the unselected cell
                    Paint paint;
                    if (clearSelection) {
                        paint = mCellFillPaint;
                    } else {
                        if (index == mSelectedCell) {
                            paint = mSelectedCellFillPaint;
                        } else {
                            paint = mSelectedWordFillPaint;
                        }
                    }

                    cellRect.set(left, top, left + mCellSize, top + mCellSize);
                    renderCell(canvas, cell, cellRect, paint);
                }

                index++;
            }
        }

        canvas.restore();
    }

    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 (mSelectedWord != null) {
            CellOffset co = getCellOffset(mSelectedWord, mSelectedCell);
            if (offset.mRow == co.mRow && offset.mColumn == co.mColumn) {
                // Same cell tapped - flip direction
                switchWordDirection();
                showKeyboard();
                return;
            }

            // Select a word in the same direction
            preferredDir = mSelectedWord.getDirection();
        }

        Selectable sel = new Selectable();
        if (getSelectable(offset, preferredDir, sel)) {
            resetSelection(sel.mWord, sel.mCellIndex);
        }

        showKeyboard();
    }

    private void handleCellLongPress(CellOffset offset) {
        Cell cell = mPuzzleCells[offset.mRow][offset.mColumn];
        if (cell == null) {
            return;
        }

        int preferredDir = Crossword.Word.DIR_ACROSS;
        if (mSelectedWord != null) {
            preferredDir = mSelectedWord.getDirection();
        }

        Selectable sel = new Selectable();
        if (getSelectable(offset, preferredDir, sel)) {
            resetSelection(sel.mWord, sel.mCellIndex, false);

            if (mLongpressListener != null) {
                // Notify the listener
                mLongpressListener.onCellLongPressed(this, mSelectedWord, sel.mCellIndex);
            }
        }
    }

    private char[] getChars(Crossword.Word word) {
        int length = word.getLength();
        char[] chars = new char[length];
        for (int i = 0; i < length; i++) {
            Cell cell = getCell(word, i);
            if (cell != null) {
                chars[i] = cell.mChar;
            }
        }

        return chars;
    }

    private int firstFreeCell(Crossword.Word word) {
        int firstFree = -1;
        if (word != null) {
            for (int i = 0, n = word.getLength(); i < n; i++) {
                Cell cell = getCell(word, i);
                if (cell.isEmpty()) {
                    firstFree = i;
                    break;
                }
            }
        }

        return firstFree;
    }

    private Cell getCell(Crossword.Word word, int cell) {
        int row = word.getStartRow();
        int col = word.getStartColumn();
        int dir = word.getDirection();

        if (dir == Crossword.Word.DIR_ACROSS) {
            col += cell;
        } else if (dir == Crossword.Word.DIR_DOWN) {
            row += cell;
        } else {
            throw new IllegalArgumentException("Word direction not valid");
        }

        return mPuzzleCells[row][col];
    }

    private boolean getSelectable(CellOffset cellOffset, int preferredDir, Selectable sel) {
        Cell cell = mPuzzleCells[cellOffset.mRow][cellOffset.mColumn];
        if (cell == null) {
            return false;
        }

        sel.mCellIndex = 0;
        int acrossNumber = cell.mWordAcrossNumber;
        int downNumber = 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 (acrossNumber != Cell.WORD_NUMBER_NONE) {
            if (preferredDir == Crossword.Word.DIR_ACROSS
                    || (preferredDir == Crossword.Word.DIR_DOWN && downNumber == Cell.WORD_NUMBER_NONE)) {
                sel.mWord = mCrossword.findWord(Crossword.Word.DIR_ACROSS, acrossNumber);
                sel.mCellIndex = cellOffset.mColumn - sel.mWord.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 (downNumber != Cell.WORD_NUMBER_NONE) {
            if (preferredDir == Crossword.Word.DIR_DOWN
                    || (preferredDir == Crossword.Word.DIR_ACROSS && acrossNumber == Cell.WORD_NUMBER_NONE)) {
                sel.mWord = mCrossword.findWord(Crossword.Word.DIR_DOWN, downNumber);
                sel.mCellIndex = cellOffset.mRow - sel.mWord.getStartRow();
            }
        }

        return true;
    }

    static class SavedState extends BaseSavedState {
        public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() {
            @Override
            public SavedState createFromParcel(Parcel source) {
                return new SavedState(source);
            }

            @Override
            public SavedState[] newArray(int size) {
                return new SavedState[size];
            }
        };

        private int mPuzzleWidth;
        private int mPuzzleHeight;
        private Cell[][] mPuzzleCells;
        private float mRenderScale;
        private PointF mBitmapOffset;
        private Crossword.Word mSelectedWord;
        private int mSelectedCell;
        private char[] mAllowedChars;
        private Crossword mCrossword;
        private boolean mEnableErrorHighlighting;

        SavedState(Parcelable superState) {
            super(superState);
        }

        private SavedState(Parcel in) {
            super(in);

            mPuzzleWidth = in.readInt();
            mPuzzleHeight = in.readInt();
            mPuzzleCells = new Cell[mPuzzleHeight][mPuzzleWidth];

            // Read the flattened array and write its elements to mPuzzleCells
            Parcelable[] flatCells = in.readParcelableArray(Cell.class.getClassLoader());
            if (flatCells != null) {
                for (int i = 0, k = 0; i < mPuzzleHeight; i++) {
                    for (int j = 0; j < mPuzzleWidth; j++) {
                        mPuzzleCells[i][j] = (Cell) flatCells[k++];
                    }
                }
            }

            mRenderScale = in.readFloat();
            mBitmapOffset = in.readParcelable(PointF.class.getClassLoader());
            mSelectedWord = in.readParcelable(Crossword.Word.class.getClassLoader());
            mSelectedCell = in.readInt();
            mAllowedChars = in.createCharArray();
            mCrossword = in.readParcelable(Crossword.class.getClassLoader());
            mEnableErrorHighlighting = in.readByte() != 0;
        }

        @Override
        public void writeToParcel(@NonNull Parcel dest, int flags) {
            super.writeToParcel(dest, flags);

            dest.writeInt(mPuzzleWidth);
            dest.writeInt(mPuzzleHeight);

            // Flatten to a one-dimensional array
            Cell[] flatCells = null;
            if (mPuzzleCells != null) {
                flatCells = new Cell[mPuzzleHeight * mPuzzleWidth];
                for (int i = 0, k = 0; i < mPuzzleHeight; i++) {
                    for (int j = 0; j < mPuzzleWidth; j++) {
                        flatCells[k++] = mPuzzleCells[i][j];
                    }
                }
            }
            dest.writeParcelableArray(flatCells, 0);

            dest.writeFloat(mRenderScale);
            dest.writeParcelable(mBitmapOffset, 0);
            dest.writeParcelable(mSelectedWord, 0);
            dest.writeInt(mSelectedCell);
            dest.writeCharArray(mAllowedChars);
            dest.writeParcelable(mCrossword, 0);
            dest.writeByte(mEnableErrorHighlighting ? (byte) 1 : 0);
        }
    }

    static class Selectable {
        public Crossword.Word mWord;
        public int mCellIndex;
    }

    static class CellOffset {
        private int mRow;
        private int mColumn;

        public CellOffset() {
        }

        public CellOffset(CellOffset offset) {
            mRow = offset.mRow;
            mColumn = offset.mColumn;
        }

        public CellOffset(int row, int column) {
            mRow = row;
            mColumn = column;
        }
    }

    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];
            }
        };

        private static final int WORD_NUMBER_NONE = -1;

        private String mNumber;
        private char mChar;
        private String mCharStr;
        private int mWordAcrossNumber;
        private int mWordDownNumber;
        private boolean mCheated;
        private boolean mCircled;
        private boolean mError;

        public Cell() {
            mChar = Crossword.State.EMPTY_CELL;
            mWordAcrossNumber = mWordDownNumber = WORD_NUMBER_NONE;
        }

        public static char canonicalize(char ch) {
            return Character.toUpperCase(ch);
        }

        public boolean isEmpty() {
            return mChar == Crossword.State.EMPTY_CELL;
        }

        public void clearChar() {
            setChar(Crossword.State.EMPTY_CELL);
        }

        public void reset() {
            clearChar();
            mCheated = false;
        }

        public void setChar(char ch) {
            if (ch == Crossword.State.EMPTY_CELL) {
                mChar = ch;
                mCharStr = null;
            } else {
                mChar = canonicalize(ch);
                mCharStr = String.valueOf(mChar);
            }
        }

        public void markError(Crossword.Cell cwCell) {
            mError = !isEmpty() && !cwCell.contains(mChar);
        }

        private Cell(Parcel in) {
            mNumber = in.readString();
            setChar(in.createCharArray()[0]);
            mWordAcrossNumber = in.readInt();
            mWordDownNumber = in.readInt();
            mCheated = in.readByte() != 0;
            mCircled = in.readByte() != 0;
            mError = in.readByte() != 0;
        }

        @Override
        public int describeContents() {
            return 0;
        }

        @Override
        public void writeToParcel(Parcel dest, int flags) {
            dest.writeString(mNumber);
            dest.writeCharArray(new char[] { mChar, });
            dest.writeInt(mWordAcrossNumber);
            dest.writeInt(mWordDownNumber);
            dest.writeByte((byte) (mCheated ? 1 : 0));
            dest.writeByte((byte) (mCircled ? 1 : 0));
            dest.writeByte((byte) (mError ? 1 : 0));
        }
    }

    private class BitmapRenderer extends AsyncTask<Void, Void, Void> {
        private float mScale;
        private Canvas mRenderingCanvas;
        private Bitmap mRenderedPuzzle;

        public BitmapRenderer(float scaleFactor) {
            mScale = scaleFactor;
        }

        @Override
        protected Void doInBackground(Void... params) {
            Canvas canvas = null;
            Bitmap puzzleBitmap = null;

            if (mPuzzleWidth > 0 && mPuzzleHeight > 0) {
                int width = (int) mPuzzleRect.width();
                int height = (int) mPuzzleRect.height();

                Crosswords.logv("Creating a new %dx%d puzzle bitmap...", width, height);

                puzzleBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
                canvas = new Canvas(puzzleBitmap);

                renderPuzzle(canvas);
            } else {
                Crosswords.logv("Not creating an empty puzzle bitmap");
            }

            if (!isCancelled()) {
                mRenderedPuzzle = puzzleBitmap;
                mRenderingCanvas = canvas;
            }

            return null;
        }

        @Override
        protected void onPostExecute(Void param) {
            super.onPostExecute(param);

            mPuzzleCanvas = mRenderingCanvas;
            mPuzzleBitmap = mRenderedPuzzle;
            mBitmapScale = 1.0f;

            Crosswords.logv("Invalidating...");
            ViewCompat.postInvalidateOnAnimation(CrosswordView.this);
        }

        @Override
        protected void onCancelled() {
            super.onCancelled();

            Crosswords.logv("Task cancelled");
        }

        public void renderPuzzle(Canvas canvas) {
            RectF cellRect = new RectF();

            Crosswords.logv("Rendering puzzle...");

            canvas.save();
            canvas.scale(mScale, mScale);

            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 (isCancelled()) {
                        return;
                    }

                    Cell cell = mPuzzleCells[i][j];
                    if (cell != null) {
                        cellRect.set(left, top, left + mCellSize, top + mCellSize);
                        renderCell(canvas, cell, cellRect, mCellFillPaint);
                    }
                }
            }

            canvas.restore();

            renderSelection(canvas, false);
        }
    }

    private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
        @Override
        public boolean onScaleBegin(ScaleGestureDetector detector) {
            mZoomer.forceFinished(true);
            mIsZooming = false;

            mScaleStart = mRenderScale;
            return true;
        }

        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            mRenderScale *= detector.getScaleFactor();

            recomputePuzzleRect();
            if (constrainScaling()) {
                recomputePuzzleRect();
            }

            mBitmapScale = mRenderScale / mScaleStart;

            invalidate();
            return true;
        }

        @Override
        public void onScaleEnd(ScaleGestureDetector detector) {
            regenerateBitmaps();
        }
    }

    private class GestureListener extends GestureDetector.SimpleOnGestureListener {
        private CellOffset mTapLocation;

        public 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;
        }
    }
}