/* * Copyright (C) 2016 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.calculator2; import android.annotation.TargetApi; import android.content.ClipData; import android.content.ClipDescription; import android.content.ClipboardManager; import android.content.Context; import android.graphics.Rect; import android.os.Build; import android.support.annotation.IntDef; import android.support.v4.content.ContextCompat; import android.support.v4.os.BuildCompat; import android.text.Layout; import android.text.Spannable; import android.text.SpannableString; import android.text.Spanned; import android.text.TextPaint; import android.text.style.BackgroundColorSpan; import android.text.style.ForegroundColorSpan; import android.text.style.RelativeSizeSpan; import android.util.AttributeSet; import android.view.ActionMode; import android.view.ContextMenu; import android.view.GestureDetector; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.widget.OverScroller; import android.widget.Toast; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; // A text widget that is "infinitely" scrollable to the right, // and obtains the text to display via a callback to Logic. public class CalculatorResult extends AlignedTextView implements MenuItem.OnMenuItemClickListener, Evaluator.EvaluationListener, Evaluator.CharMetricsInfo { static final int MAX_RIGHT_SCROLL = 10000000; static final int INVALID = MAX_RIGHT_SCROLL + 10000; // A larger value is unlikely to avoid running out of space final OverScroller mScroller; final GestureDetector mGestureDetector; private long mIndex; // Index of expression we are displaying. private Evaluator mEvaluator; private boolean mScrollable = false; // A scrollable result is currently displayed. private boolean mValid = false; // The result holds a valid number (not an error message). // A suffix of "Pos" denotes a pixel offset. Zero represents a scroll position // in which the decimal point is just barely visible on the right of the display. private int mCurrentPos;// Position of right of display relative to decimal point, in pixels. // Large positive values mean the decimal point is scrolled off the // left of the display. Zero means decimal point is barely displayed // on the right. private int mLastPos; // Position already reflected in display. Pixels. private int mMinPos; // Minimum position to avoid unnecessary blanks on the left. Pixels. private int mMaxPos; // Maximum position before we start displaying the infinite // sequence of trailing zeroes on the right. Pixels. private int mWholeLen; // Length of the whole part of current result. // In the following, we use a suffix of Offset to denote a character position in a numeric // string relative to the decimal point. Positive is to the right and negative is to // the left. 1 = tenths position, -1 = units. Integer.MAX_VALUE is sometimes used // for the offset of the last digit in an a nonterminating decimal expansion. // We use the suffix "Index" to denote a zero-based index into a string representing a // result. private int mMaxCharOffset; // Character offset from decimal point of rightmost digit // that should be displayed, plus the length of any exponent // needed to display that digit. // Limited to MAX_RIGHT_SCROLL. Often the same as: private int mLsdOffset; // Position of least-significant digit in result private int mLastDisplayedOffset; // Offset of last digit actually displayed after adding // exponent. private boolean mWholePartFits; // Scientific notation not needed for initial display. private float mNoExponentCredit; // Fraction of digit width saved by avoiding scientific notation. // Only accessed from UI thread. private boolean mAppendExponent; // The result fits entirely in the display, even with an exponent, // but not with grouping separators. Since the result is not // scrollable, and we do not add the exponent to max. scroll position, // append an exponent insteadd of replacing trailing digits. private final Object mWidthLock = new Object(); // Protects the next five fields. These fields are only // updated by the UI thread, and read accesses by the UI thread // sometimes do not acquire the lock. private int mWidthConstraint = 0; // Our total width in pixels minus space for ellipsis. // 0 ==> uninitialized. private float mCharWidth = 1; // Maximum character width. For now we pretend that all characters // have this width. // TODO: We're not really using a fixed width font. But it appears // to be close enough for the characters we use that the difference // is not noticeable. private float mGroupingSeparatorWidthRatio; // Fraction of digit width occupied by a digit separator. private float mDecimalCredit; // Fraction of digit width saved by replacing digit with decimal point. private float mNoEllipsisCredit; // Fraction of digit width saved by both replacing ellipsis with digit // and avoiding scientific notation. @Retention(RetentionPolicy.SOURCE) @IntDef({SHOULD_REQUIRE, SHOULD_EVALUATE, SHOULD_NOT_EVALUATE}) public @interface EvaluationRequest {} public static final int SHOULD_REQUIRE = 2; public static final int SHOULD_EVALUATE = 1; public static final int SHOULD_NOT_EVALUATE = 0; @EvaluationRequest private int mEvaluationRequest = SHOULD_REQUIRE; // Should we evaluate when layout completes, and how? private Evaluator.EvaluationListener mEvaluationListener = this; // Listener to use if/when evaluation is requested. public static final int MAX_LEADING_ZEROES = 6; // Maximum number of leading zeroes after decimal point before we // switch to scientific notation with negative exponent. public static final int MAX_TRAILING_ZEROES = 6; // Maximum number of trailing zeroes before the decimal point before // we switch to scientific notation with positive exponent. private static final int SCI_NOTATION_EXTRA = 1; // Extra digits for standard scientific notation. In this case we // have a decimal point and no ellipsis. // We assume that we do not drop digits to make room for the decimal // point in ordinary scientific notation. Thus >= 1. private static final int MAX_COPY_EXTRA = 100; // The number of extra digits we are willing to compute to copy // a result as an exact number. private static final int MAX_RECOMPUTE_DIGITS = 2000; // The maximum number of digits we're willing to recompute in the UI // thread. We only do this for known rational results, where we // can bound the computation cost. private final ForegroundColorSpan mExponentColorSpan; private final BackgroundColorSpan mHighlightSpan; private ActionMode mActionMode; private ActionMode.Callback mCopyActionModeCallback; private ContextMenu mContextMenu; // The user requested that the result currently being evaluated should be stored to "memory". private boolean mStoreToMemoryRequested = false; public CalculatorResult(Context context, AttributeSet attrs) { super(context, attrs); mScroller = new OverScroller(context); mHighlightSpan = new BackgroundColorSpan(getHighlightColor()); mExponentColorSpan = new ForegroundColorSpan( ContextCompat.getColor(context, R.color.display_result_exponent_text_color)); mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { @Override public boolean onDown(MotionEvent e) { return true; } @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { if (!mScroller.isFinished()) { mCurrentPos = mScroller.getFinalX(); } mScroller.forceFinished(true); stopActionModeOrContextMenu(); CalculatorResult.this.cancelLongPress(); // Ignore scrolls of error string, etc. if (!mScrollable) return true; mScroller.fling(mCurrentPos, 0, - (int) velocityX, 0 /* horizontal only */, mMinPos, mMaxPos, 0, 0); postInvalidateOnAnimation(); return true; } @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { int distance = (int)distanceX; if (!mScroller.isFinished()) { mCurrentPos = mScroller.getFinalX(); } mScroller.forceFinished(true); stopActionModeOrContextMenu(); CalculatorResult.this.cancelLongPress(); if (!mScrollable) return true; if (mCurrentPos + distance < mMinPos) { distance = mMinPos - mCurrentPos; } else if (mCurrentPos + distance > mMaxPos) { distance = mMaxPos - mCurrentPos; } int duration = (int)(e2.getEventTime() - e1.getEventTime()); if (duration < 1 || duration > 100) duration = 10; mScroller.startScroll(mCurrentPos, 0, distance, 0, (int)duration); postInvalidateOnAnimation(); return true; } @Override public void onLongPress(MotionEvent e) { if (mValid) { performLongClick(); } } }); final int slop = ViewConfiguration.get(context).getScaledTouchSlop(); setOnTouchListener(new View.OnTouchListener() { // Used to determine whether a touch event should be intercepted. private float mInitialDownX; private float mInitialDownY; @Override public boolean onTouch(View v, MotionEvent event) { final int action = event.getActionMasked(); final float x = event.getX(); final float y = event.getY(); switch (action) { case MotionEvent.ACTION_DOWN: mInitialDownX = x; mInitialDownY = y; break; case MotionEvent.ACTION_MOVE: final float deltaX = Math.abs(x - mInitialDownX); final float deltaY = Math.abs(y - mInitialDownY); if (deltaX > slop && deltaX > deltaY) { // Prevent the DragLayout from intercepting horizontal scrolls. getParent().requestDisallowInterceptTouchEvent(true); } } return mGestureDetector.onTouchEvent(event); } }); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { setupActionMode(); } else { setupContextMenu(); } setCursorVisible(false); setLongClickable(false); setContentDescription(context.getString(R.string.desc_result)); } void setEvaluator(Evaluator evaluator, long index) { mEvaluator = evaluator; mIndex = index; requestLayout(); } // Compute maximum digit width the hard way. private static float getMaxDigitWidth(TextPaint paint) { // Compute the maximum advance width for each digit, thus accounting for between-character // spaces. If we ever support other kinds of digits, we may have to avoid kerning effects // that could reduce the advance width within this particular string. final String allDigits = "0123456789"; final float[] widths = new float[allDigits.length()]; paint.getTextWidths(allDigits, widths); float maxWidth = 0; for (float x : widths) { maxWidth = Math.max(x, maxWidth); } return maxWidth; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (!isLaidOut()) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); // Set a minimum height so scaled error messages won't affect our layout. setMinimumHeight(getLineHeight() + getCompoundPaddingBottom() + getCompoundPaddingTop()); } final TextPaint paint = getPaint(); final Context context = getContext(); final float newCharWidth = getMaxDigitWidth(paint); // Digits are presumed to have no more than newCharWidth. // There are two instances when we know that the result is otherwise narrower than // expected: // 1. For standard scientific notation (our type 1), we know that we have a norrow decimal // point and no (usually wide) ellipsis symbol. We allow one extra digit // (SCI_NOTATION_EXTRA) to compensate, and consider that in determining available width. // 2. If we are using digit grouping separators and a decimal point, we give ourselves // a fractional extra space for those separators, the value of which depends on whether // there is also an ellipsis. // // Maximum extra space we need in various cases: // Type 1 scientific notation, assuming ellipsis, minus sign and E are wider than a digit: // Two minus signs + "E" + "." - 3 digits. // Type 2 scientific notation: // Ellipsis + "E" + "-" - 3 digits. // In the absence of scientific notation, we may need a little less space. // We give ourselves a bit of extra credit towards comma insertion and give // ourselves more if we have either // No ellipsis, or // A decimal separator. // Calculate extra space we need to reserve, in addition to character count. final float decimalSeparatorWidth = Layout.getDesiredWidth( context.getString(R.string.dec_point), paint); final float minusWidth = Layout.getDesiredWidth(context.getString(R.string.op_sub), paint); final float minusExtraWidth = Math.max(minusWidth - newCharWidth, 0.0f); final float ellipsisWidth = Layout.getDesiredWidth(KeyMaps.ELLIPSIS, paint); final float ellipsisExtraWidth = Math.max(ellipsisWidth - newCharWidth, 0.0f); final float expWidth = Layout.getDesiredWidth(KeyMaps.translateResult("e"), paint); final float expExtraWidth = Math.max(expWidth - newCharWidth, 0.0f); final float type1Extra = 2 * minusExtraWidth + expExtraWidth + decimalSeparatorWidth; final float type2Extra = ellipsisExtraWidth + expExtraWidth + minusExtraWidth; final float extraWidth = Math.max(type1Extra, type2Extra); final int intExtraWidth = (int) Math.ceil(extraWidth) + 1 /* to cover rounding sins */; final int newWidthConstraint = MeasureSpec.getSize(widthMeasureSpec) - (getPaddingLeft() + getPaddingRight()) - intExtraWidth; // Calculate other width constants we need to handle grouping separators. final float groupingSeparatorW = Layout.getDesiredWidth(KeyMaps.translateResult(","), paint); // Credits in the absence of any scientific notation: float noExponentCredit = extraWidth - Math.max(ellipsisExtraWidth, minusExtraWidth); final float noEllipsisCredit = extraWidth - minusExtraWidth; // includes noExponentCredit. final float decimalCredit = Math.max(newCharWidth - decimalSeparatorWidth, 0.0f); mNoExponentCredit = noExponentCredit / newCharWidth; synchronized(mWidthLock) { mWidthConstraint = newWidthConstraint; mCharWidth = newCharWidth; mNoEllipsisCredit = noEllipsisCredit / newCharWidth; mDecimalCredit = decimalCredit / newCharWidth; mGroupingSeparatorWidthRatio = groupingSeparatorW / newCharWidth; } super.onMeasure(widthMeasureSpec, heightMeasureSpec); } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); if (mEvaluator != null && mEvaluationRequest != SHOULD_NOT_EVALUATE) { final CalculatorExpr expr = mEvaluator.getExpr(mIndex); if (expr != null && expr.hasInterestingOps()) { if (mEvaluationRequest == SHOULD_REQUIRE) { mEvaluator.requireResult(mIndex, mEvaluationListener, this); } else { mEvaluator.evaluateAndNotify(mIndex, mEvaluationListener, this); } } } } /** * Specify whether we should evaluate result on layout. * @param should one of SHOULD_REQUIRE, SHOULD_EVALUATE, SHOULD_NOT_EVALUATE */ public void setShouldEvaluateResult(@EvaluationRequest int request, Evaluator.EvaluationListener listener) { mEvaluationListener = listener; mEvaluationRequest = request; } // From Evaluator.CharMetricsInfo. @Override public float separatorChars(String s, int len) { int start = 0; while (start < len && !Character.isDigit(s.charAt(start))) { ++start; } // We assume the rest consists of digits, and for consistency with the rest // of the code, we assume all digits have width mCharWidth. final int nDigits = len - start; // We currently insert a digit separator every three digits. final int nSeparators = (nDigits - 1) / 3; synchronized(mWidthLock) { // Always return an upper bound, even in the presence of rounding errors. return nSeparators * mGroupingSeparatorWidthRatio; } } // From Evaluator.CharMetricsInfo. @Override public float getNoEllipsisCredit() { synchronized(mWidthLock) { return mNoEllipsisCredit; } } // From Evaluator.CharMetricsInfo. @Override public float getDecimalCredit() { synchronized(mWidthLock) { return mDecimalCredit; } } // Return the length of the exponent representation for the given exponent, in // characters. private final int expLen(int exp) { if (exp == 0) return 0; final int abs_exp_digits = (int) Math.ceil(Math.log10(Math.abs((double)exp)) + 0.0000000001d /* Round whole numbers to next integer */); return abs_exp_digits + (exp >= 0 ? 1 : 2); } /** * Initiate display of a new result. * Only called from UI thread. * The parameters specify various properties of the result. * @param index Index of expression that was just evaluated. Currently ignored, since we only * expect notification for the expression result being displayed. * @param initPrec Initial display precision computed by evaluator. (1 = tenths digit) * @param msd Position of most significant digit. Offset from left of string. Evaluator.INVALID_MSD if unknown. * @param leastDigPos Position of least significant digit (1 = tenths digit) * or Integer.MAX_VALUE. * @param truncatedWholePart Result up to but not including decimal point. Currently we only use the length. */ @Override public void onEvaluate(long index, int initPrec, int msd, int leastDigPos, String truncatedWholePart) { initPositions(initPrec, msd, leastDigPos, truncatedWholePart); if (mStoreToMemoryRequested) { mEvaluator.copyToMemory(index); mStoreToMemoryRequested = false; } redisplay(); } /** * Store the result for this index if it is available. * If it is unavailable, set mStoreToMemoryRequested to indicate that we should store * when evaluation is complete. */ public void onMemoryStore() { if (mEvaluator.hasResult(mIndex)) { mEvaluator.copyToMemory(mIndex); } else { mStoreToMemoryRequested = true; mEvaluator.requireResult(mIndex, this /* listener */, this /* CharMetricsInfo */); } } /** * Add the result to the value currently in memory. */ public void onMemoryAdd() { mEvaluator.addToMemory(mIndex); } /** * Subtract the result from the value currently in memory. */ public void onMemorySubtract() { mEvaluator.subtractFromMemory(mIndex); } /** * Set up scroll bounds (mMinPos, mMaxPos, etc.) and determine whether the result is * scrollable, based on the supplied information about the result. * This is unfortunately complicated because we need to predict whether trailing digits * will eventually be replaced by an exponent. * Just appending the exponent during formatting would be simpler, but would produce * jumpier results during transitions. * Only called from UI thread. */ private void initPositions(int initPrecOffset, int msdIndex, int lsdOffset, String truncatedWholePart) { int maxChars = getMaxChars(); mWholeLen = truncatedWholePart.length(); // Allow a tiny amount of slop for associativity/rounding differences in length // calculation. If getPreferredPrec() decided it should fit, we want to make it fit, too. // We reserved one extra pixel, so the extra length is OK. final int nSeparatorChars = (int) Math.ceil( separatorChars(truncatedWholePart, truncatedWholePart.length()) - getNoEllipsisCredit() - 0.0001f); mWholePartFits = mWholeLen + nSeparatorChars <= maxChars; mLastPos = INVALID; mLsdOffset = lsdOffset; mAppendExponent = false; // Prevent scrolling past initial position, which is calculated to show leading digits. mCurrentPos = mMinPos = (int) Math.round(initPrecOffset * mCharWidth); if (msdIndex == Evaluator.INVALID_MSD) { // Possible zero value if (lsdOffset == Integer.MIN_VALUE) { // Definite zero value. mMaxPos = mMinPos; mMaxCharOffset = (int) Math.round(mMaxPos/mCharWidth); mScrollable = false; } else { // May be very small nonzero value. Allow user to find out. mMaxPos = mMaxCharOffset = MAX_RIGHT_SCROLL; mMinPos -= mCharWidth; // Allow for future minus sign. mScrollable = true; } return; } int negative = truncatedWholePart.charAt(0) == '-' ? 1 : 0; if (msdIndex > mWholeLen && msdIndex <= mWholeLen + 3) { // Avoid tiny negative exponent; pretend msdIndex is just to the right of decimal point. msdIndex = mWholeLen - 1; } // Set to position of leftmost significant digit relative to dec. point. Usually negative. int minCharOffset = msdIndex - mWholeLen; if (minCharOffset > -1 && minCharOffset < MAX_LEADING_ZEROES + 2) { // Small number of leading zeroes, avoid scientific notation. minCharOffset = -1; } if (lsdOffset < MAX_RIGHT_SCROLL) { mMaxCharOffset = lsdOffset; if (mMaxCharOffset < -1 && mMaxCharOffset > -(MAX_TRAILING_ZEROES + 2)) { mMaxCharOffset = -1; } // lsdOffset is positive or negative, never 0. int currentExpLen = 0; // Length of required standard scientific notation exponent. if (mMaxCharOffset < -1) { currentExpLen = expLen(-minCharOffset - 1); } else if (minCharOffset > -1 || mMaxCharOffset >= maxChars) { // Number is either entirely to the right of decimal point, or decimal point is // not visible when scrolled to the right. currentExpLen = expLen(-minCharOffset); } // Exponent length does not included added decimal point. But whenever we add a // decimal point, we allow an extra character (SCI_NOTATION_EXTRA). final int separatorLength = mWholePartFits && minCharOffset < -3 ? nSeparatorChars : 0; mScrollable = (mMaxCharOffset + currentExpLen + separatorLength - minCharOffset + negative >= maxChars); // Now adjust mMaxCharOffset for any required exponent. int newMaxCharOffset; if (currentExpLen > 0) { if (mScrollable) { // We'll use exponent corresponding to leastDigPos when scrolled to right. newMaxCharOffset = mMaxCharOffset + expLen(-lsdOffset); } else { newMaxCharOffset = mMaxCharOffset + currentExpLen; } if (mMaxCharOffset <= -1 && newMaxCharOffset > -1) { // Very unlikely; just drop exponent. mMaxCharOffset = -1; } else { mMaxCharOffset = Math.min(newMaxCharOffset, MAX_RIGHT_SCROLL); } mMaxPos = Math.min((int) Math.round(mMaxCharOffset * mCharWidth), MAX_RIGHT_SCROLL); } else if (!mWholePartFits && !mScrollable) { // Corner case in which entire number fits, but not with grouping separators. We // will use an exponent in un-scrolled position, which may hide digits. Scrolling // by one character will remove the exponent and reveal the last digits. Note // that in the forced scientific notation case, the exponent length is not // factored into mMaxCharOffset, since we do not want such an increase to impact // scrolling behavior. In the unscrollable case, we thus have to append the // exponent at the end using the forcePrecision argument to formatResult, in order // to ensure that we get the entire result. mScrollable = (mMaxCharOffset + expLen(-minCharOffset - 1) - minCharOffset + negative >= maxChars); if (mScrollable) { mMaxPos = (int) Math.ceil(mMinPos + mCharWidth); // Single character scroll will remove exponent and show remaining piece. } else { mMaxPos = mMinPos; mAppendExponent = true; } } else { mMaxPos = Math.min((int) Math.round(mMaxCharOffset * mCharWidth), MAX_RIGHT_SCROLL); } if (!mScrollable) { // Position the number consistently with our assumptions to make sure it // actually fits. mCurrentPos = mMaxPos; } } else { mMaxPos = mMaxCharOffset = MAX_RIGHT_SCROLL; mScrollable = true; } } /** * Display error message indicated by resourceId. * UI thread only. */ @Override public void onError(long index, int resourceId) { mStoreToMemoryRequested = false; mValid = false; setLongClickable(false); mScrollable = false; final String msg = getContext().getString(resourceId); final float measuredWidth = Layout.getDesiredWidth(msg, getPaint()); if (measuredWidth > mWidthConstraint) { // Multiply by .99 to avoid rounding effects. final float scaleFactor = 0.99f * mWidthConstraint / measuredWidth; final RelativeSizeSpan smallTextSpan = new RelativeSizeSpan(scaleFactor); final SpannableString scaledMsg = new SpannableString(msg); scaledMsg.setSpan(smallTextSpan, 0, msg.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); setText(scaledMsg); } else { setText(msg); } } private final int MAX_COPY_SIZE = 1000000; /* * Return the most significant digit position in the given string or Evaluator.INVALID_MSD. * Unlike Evaluator.getMsdIndexOf, we treat a final 1 as significant. * Pure function; callable from anywhere. */ public static int getNaiveMsdIndexOf(String s) { final int len = s.length(); for (int i = 0; i < len; ++i) { char c = s.charAt(i); if (c != '-' && c != '.' && c != '0') { return i; } } return Evaluator.INVALID_MSD; } /** * Format a result returned by Evaluator.getString() into a single line containing ellipses * (if appropriate) and an exponent (if appropriate). * We add two distinct kinds of exponents: * (1) If the final result contains the leading digit we use standard scientific notation. * (2) If not, we add an exponent corresponding to an interpretation of the final result as * an integer. * We add an ellipsis on the left if the result was truncated. * We add ellipses and exponents in a way that leaves most digits in the position they * would have been in had we not done so. This minimizes jumps as a result of scrolling. * Result is NOT internationalized, uses "E" for exponent. * Called only from UI thread; We sometimes omit locking for fields. * @param precOffset The value that was passed to getString. Identifies the significance of the rightmost digit. A value of 1 means the rightmost digits corresponds to tenths. * @param maxDigs The maximum number of characters in the result * @param truncated The in parameter was already truncated, beyond possibly removing the minus sign. * @param negative The in parameter represents a negative result. (Minus sign may be removed without setting truncated.) * @param lastDisplayedOffset If not null, we set lastDisplayedOffset[0] to the offset of the last digit actually appearing in the display. * @param forcePrecision If true, we make sure that the last displayed digit corresponds to precOffset, and allow maxDigs to be exceeded in adding the exponent and commas. * @param forceSciNotation Force scientific notation. May be set because we don't have space for grouping separators, but whole number otherwise fits. * @param insertCommas Insert commas (literally, not internationalized) as digit separators. We only ever do this for the integral part of a number, and only when no exponent is displayed in the initial position. The combination of which means that we only do it when no exponent is displayed. We insert commas in a way that does consider the width of the actual localized digit separator. Commas count towards maxDigs as the appropriate fraction of a digit. */ private String formatResult(String in, int precOffset, int maxDigs, boolean truncated, boolean negative, int lastDisplayedOffset[], boolean forcePrecision, boolean forceSciNotation, boolean insertCommas) { final int minusSpace = negative ? 1 : 0; final int msdIndex = truncated ? -1 : getNaiveMsdIndexOf(in); // INVALID_MSD is OK. String result = in; boolean needEllipsis = false; if (truncated || (negative && result.charAt(0) != '-')) { needEllipsis = true; result = KeyMaps.ELLIPSIS + result.substring(1, result.length()); // Ellipsis may be removed again in the type(1) scientific notation case. } final int decIndex = result.indexOf('.'); if (lastDisplayedOffset != null) { lastDisplayedOffset[0] = precOffset; } if (forceSciNotation || (decIndex == -1 || msdIndex != Evaluator.INVALID_MSD && msdIndex - decIndex > MAX_LEADING_ZEROES + 1) && precOffset != -1) { // Either: // 1) No decimal point displayed, and it's not just to the right of the last digit, or // 2) we are at the front of a number whos integral part is too large to allow // comma insertion, or // 3) we should suppress leading zeroes. // Add an exponent to let the user track which digits are currently displayed. // Start with type (2) exponent if we dropped no digits. -1 accounts for decimal point. // We currently never show digit separators together with an exponent. final int initExponent = precOffset > 0 ? -precOffset : -precOffset - 1; int exponent = initExponent; boolean hasPoint = false; if (!truncated && msdIndex < maxDigs - 1 && result.length() - msdIndex + 1 + minusSpace <= maxDigs + SCI_NOTATION_EXTRA) { // Type (1) exponent computation and transformation: // Leading digit is in display window. Use standard calculator scientific notation // with one digit to the left of the decimal point. Insert decimal point and // delete leading zeroes. // We try to keep leading digits roughly in position, and never // lengthen the result by more than SCI_NOTATION_EXTRA. if (decIndex > msdIndex) { // In the forceSciNotation, we can have a decimal point in the relevant digit // range. Remove it. result = result.substring(0, decIndex) + result.substring(decIndex + 1, result.length()); // msdIndex and precOffset unaffected. } final int resLen = result.length(); String fraction = result.substring(msdIndex + 1, resLen); result = (negative ? "-" : "") + result.substring(msdIndex, msdIndex + 1) + "." + fraction; // Original exp was correct for decimal point at right of fraction. // Adjust by length of fraction. exponent = initExponent + resLen - msdIndex - 1; hasPoint = true; } // Exponent can't be zero. // Actually add the exponent of either type: if (!forcePrecision) { int dropDigits; // Digits to drop to make room for exponent. if (hasPoint) { // Type (1) exponent. // Drop digits even if there is room. Otherwise the scrolling gets jumpy. dropDigits = expLen(exponent); if (dropDigits >= result.length() - 1) { // Jumpy is better than no mantissa. Probably impossible anyway. dropDigits = Math.max(result.length() - 2, 0); } } else { // Type (2) exponent. // Exponent depends on the number of digits we drop, which depends on // exponent ... for (dropDigits = 2; expLen(initExponent + dropDigits) > dropDigits; ++dropDigits) {} exponent = initExponent + dropDigits; if (precOffset - dropDigits > mLsdOffset) { // This can happen if e.g. result = 10^40 + 10^10 // It turns out we would otherwise display ...10e9 because it takes // the same amount of space as ...1e10 but shows one more digit. // But we don't want to display a trailing zero, even if it's free. ++dropDigits; ++exponent; } } if (dropDigits >= result.length() - 1) { // Display too small to show meaningful result. return KeyMaps.ELLIPSIS + "E" + KeyMaps.ELLIPSIS; } result = result.substring(0, result.length() - dropDigits); if (lastDisplayedOffset != null) { lastDisplayedOffset[0] -= dropDigits; } } result = result + "E" + Integer.toString(exponent); } else if (insertCommas) { // Add commas to the whole number section, and then truncate on left to fit, // counting commas as a fractional digit. final int wholeStart = needEllipsis ? 1 : 0; int orig_length = result.length(); final float nCommaChars; if (decIndex != -1) { nCommaChars = separatorChars(result, decIndex); result = StringUtils.addCommas(result, wholeStart, decIndex) + result.substring(decIndex, orig_length); } else { nCommaChars = separatorChars(result, orig_length); result = StringUtils.addCommas(result, wholeStart, orig_length); } if (needEllipsis) { orig_length -= 1; // Exclude ellipsis. } final float len = orig_length + nCommaChars; int deletedChars = 0; final float ellipsisCredit = getNoEllipsisCredit(); final float decimalCredit = getDecimalCredit(); final float effectiveLen = len - (decIndex == -1 ? 0 : getDecimalCredit()); final float ellipsisAdjustment = needEllipsis ? mNoExponentCredit : getNoEllipsisCredit(); // As above, we allow for a tiny amount of extra length here, for consistency with // getPreferredPrec(). if (effectiveLen - ellipsisAdjustment > (float) (maxDigs - wholeStart) + 0.0001f && !forcePrecision) { float deletedWidth = 0.0f; while (effectiveLen - mNoExponentCredit - deletedWidth > (float) (maxDigs - 1 /* for ellipsis */)) { if (result.charAt(deletedChars) == ',') { deletedWidth += mGroupingSeparatorWidthRatio; } else { deletedWidth += 1.0f; } deletedChars++; } } if (deletedChars > 0) { result = KeyMaps.ELLIPSIS + result.substring(deletedChars, result.length()); } else if (needEllipsis) { result = KeyMaps.ELLIPSIS + result; } } return result; } /** * Get formatted, but not internationalized, result from mEvaluator. * @param precOffset requested position (1 = tenths) of last included digit * @param maxSize maximum number of characters (more or less) in result * @param lastDisplayedOffset zeroth entry is set to actual offset of last included digit, * after adjusting for exponent, etc. May be null. * @param forcePrecision Ensure that last included digit is at pos, at the expense * of treating maxSize as a soft limit. * @param forceSciNotation Force scientific notation, even if not required by maxSize. * @param insertCommas Insert commas as digit separators. */ private String getFormattedResult(int precOffset, int maxSize, int lastDisplayedOffset[], boolean forcePrecision, boolean forceSciNotation, boolean insertCommas) { final boolean truncated[] = new boolean[1]; final boolean negative[] = new boolean[1]; final int requestedPrecOffset[] = {precOffset}; final String rawResult = mEvaluator.getString(mIndex, requestedPrecOffset, mMaxCharOffset, maxSize, truncated, negative, this); return formatResult(rawResult, requestedPrecOffset[0], maxSize, truncated[0], negative[0], lastDisplayedOffset, forcePrecision, forceSciNotation, insertCommas); } /** * Return entire result (within reason) up to current displayed precision. * @param withSeparators Add digit separators */ public String getFullText(boolean withSeparators) { if (!mValid) return ""; if (!mScrollable) return getText().toString(); return KeyMaps.translateResult(getFormattedResult(mLastDisplayedOffset, MAX_COPY_SIZE, null, true /* forcePrecision */, false /* forceSciNotation */, withSeparators)); } /** * Did the above produce a correct result? * UI thread only. */ public boolean fullTextIsExact() { return !mScrollable || (getCharOffset(mMaxPos) == getCharOffset(mCurrentPos) && mMaxCharOffset != MAX_RIGHT_SCROLL); } /** * Get entire result up to current displayed precision, or up to MAX_COPY_EXTRA additional * digits, if it will lead to an exact result. */ public String getFullCopyText() { if (!mValid || mLsdOffset == Integer.MAX_VALUE || fullTextIsExact() || mWholeLen > MAX_RECOMPUTE_DIGITS || mWholeLen + mLsdOffset > MAX_RECOMPUTE_DIGITS || mLsdOffset - mLastDisplayedOffset > MAX_COPY_EXTRA) { return getFullText(false /* withSeparators */); } // It's reasonable to compute and copy the exact result instead. int fractionLsdOffset = Math.max(0, mLsdOffset); String rawResult = mEvaluator.getResult(mIndex).toStringTruncated(fractionLsdOffset); if (mLsdOffset <= -1) { // Result has trailing decimal point. Remove it. rawResult = rawResult.substring(0, rawResult.length() - 1); fractionLsdOffset = -1; } final String formattedResult = formatResult(rawResult, fractionLsdOffset, MAX_COPY_SIZE, false, rawResult.charAt(0) == '-', null, true /* forcePrecision */, false /* forceSciNotation */, false /* insertCommas */); return KeyMaps.translateResult(formattedResult); } /** * Return the maximum number of characters that will fit in the result display. * May be called asynchronously from non-UI thread. From Evaluator.CharMetricsInfo. * Returns zero if measurement hasn't completed. */ @Override public int getMaxChars() { int result; synchronized(mWidthLock) { return (int) Math.floor(mWidthConstraint / mCharWidth); } } /** * @return {@code true} if the currently displayed result is scrollable */ public boolean isScrollable() { return mScrollable; } /** * Map pixel position to digit offset. * UI thread only. */ int getCharOffset(int pos) { return (int) Math.round(pos / mCharWidth); // Lock not needed. } void clear() { mValid = false; mScrollable = false; setText(""); setLongClickable(false); } @Override public void onCancelled(long index) { clear(); mStoreToMemoryRequested = false; } /** * Refresh display. * Only called in UI thread. Index argument is currently ignored. */ @Override public void onReevaluate(long index) { redisplay(); } public void redisplay() { int maxChars = getMaxChars(); if (maxChars < 4) { // Display currently too small to display a reasonable result. Punt to avoid crash. return; } if (mScroller.isFinished() && length() > 0) { setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE); } int currentCharOffset = getCharOffset(mCurrentPos); int lastDisplayedOffset[] = new int[1]; String result = getFormattedResult(currentCharOffset, maxChars, lastDisplayedOffset, mAppendExponent /* forcePrecision; preserve entire result */, !mWholePartFits && currentCharOffset == getCharOffset(mMinPos) /* forceSciNotation */, mWholePartFits /* insertCommas */ ); int expIndex = result.indexOf('E'); result = KeyMaps.translateResult(result); if (expIndex > 0 && result.indexOf('.') == -1) { // Gray out exponent if used as position indicator SpannableString formattedResult = new SpannableString(result); formattedResult.setSpan(mExponentColorSpan, expIndex, result.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); setText(formattedResult); } else { setText(result); } mLastDisplayedOffset = lastDisplayedOffset[0]; mValid = true; setLongClickable(true); } @Override protected void onTextChanged(java.lang.CharSequence text, int start, int lengthBefore, int lengthAfter) { super.onTextChanged(text, start, lengthBefore, lengthAfter); if (!mScrollable || mScroller.isFinished()) { if (lengthBefore == 0 && lengthAfter > 0) { setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE); setContentDescription(null); } else if (lengthBefore > 0 && lengthAfter == 0) { setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_NONE); setContentDescription(getContext().getString(R.string.desc_result)); } } } @Override public void computeScroll() { if (!mScrollable) { return; } if (mScroller.computeScrollOffset()) { mCurrentPos = mScroller.getCurrX(); if (getCharOffset(mCurrentPos) != getCharOffset(mLastPos)) { mLastPos = mCurrentPos; redisplay(); } } if (!mScroller.isFinished()) { postInvalidateOnAnimation(); setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_NONE); } else if (length() > 0){ setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE); } } /** * Use ActionMode for copy/memory support on M and higher. */ @TargetApi(Build.VERSION_CODES.M) private void setupActionMode() { mCopyActionModeCallback = new ActionMode.Callback2() { @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { final MenuInflater inflater = mode.getMenuInflater(); return createContextMenu(inflater, menu); } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { return false; // Return false if nothing is done } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { if (onMenuItemClick(item)) { mode.finish(); return true; } else { return false; } } @Override public void onDestroyActionMode(ActionMode mode) { unhighlightResult(); mActionMode = null; } @Override public void onGetContentRect(ActionMode mode, View view, Rect outRect) { super.onGetContentRect(mode, view, outRect); outRect.left += view.getPaddingLeft(); outRect.top += view.getPaddingTop(); outRect.right -= view.getPaddingRight(); outRect.bottom -= view.getPaddingBottom(); final int width = (int) Layout.getDesiredWidth(getText(), getPaint()); if (width < outRect.width()) { outRect.left = outRect.right - width; } if (!BuildCompat.isAtLeastN()) { // The CAB (prior to N) only takes the translation of a view into account, so // if a scale is applied to the view then the offset outRect will end up being // positioned incorrectly. We workaround that limitation by manually applying // the scale to the outRect, which the CAB will then offset to the correct // position. final float scaleX = view.getScaleX(); final float scaleY = view.getScaleY(); outRect.left *= scaleX; outRect.right *= scaleX; outRect.top *= scaleY; outRect.bottom *= scaleY; } } }; setOnLongClickListener(new View.OnLongClickListener() { @Override public boolean onLongClick(View v) { if (mValid) { mActionMode = startActionMode(mCopyActionModeCallback, ActionMode.TYPE_FLOATING); return true; } return false; } }); } /** * Use ContextMenu for copy/memory support on L and lower. */ private void setupContextMenu() { setOnCreateContextMenuListener(new OnCreateContextMenuListener() { @Override public void onCreateContextMenu(ContextMenu contextMenu, View view, ContextMenu.ContextMenuInfo contextMenuInfo) { final MenuInflater inflater = new MenuInflater(getContext()); createContextMenu(inflater, contextMenu); mContextMenu = contextMenu; for (int i = 0; i < contextMenu.size(); i ++) { contextMenu.getItem(i).setOnMenuItemClickListener(CalculatorResult.this); } } }); setOnLongClickListener(new View.OnLongClickListener() { @Override public boolean onLongClick(View v) { if (mValid) { return showContextMenu(); } return false; } }); } private boolean createContextMenu(MenuInflater inflater, Menu menu) { inflater.inflate(R.menu.menu_result, menu); final boolean displayMemory = mEvaluator.getMemoryIndex() != 0; final MenuItem memoryAddItem = menu.findItem(R.id.memory_add); final MenuItem memorySubtractItem = menu.findItem(R.id.memory_subtract); memoryAddItem.setEnabled(displayMemory); memorySubtractItem.setEnabled(displayMemory); highlightResult(); return true; } public boolean stopActionModeOrContextMenu() { if (mActionMode != null) { mActionMode.finish(); return true; } if (mContextMenu != null) { unhighlightResult(); mContextMenu.close(); return true; } return false; } private void highlightResult() { final Spannable text = (Spannable) getText(); text.setSpan(mHighlightSpan, 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } private void unhighlightResult() { final Spannable text = (Spannable) getText(); text.removeSpan(mHighlightSpan); } private void setPrimaryClip(ClipData clip) { ClipboardManager clipboard = (ClipboardManager) getContext(). getSystemService(Context.CLIPBOARD_SERVICE); clipboard.setPrimaryClip(clip); } private void copyContent() { final CharSequence text = getFullCopyText(); ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); // We include a tag URI, to allow us to recognize our own results and handle them // specially. ClipData.Item newItem = new ClipData.Item(text, null, mEvaluator.capture(mIndex)); String[] mimeTypes = new String[] {ClipDescription.MIMETYPE_TEXT_PLAIN}; ClipData cd = new ClipData("calculator result", mimeTypes, newItem); clipboard.setPrimaryClip(cd); Toast.makeText(getContext(), R.string.text_copied_toast, Toast.LENGTH_SHORT).show(); } @Override public boolean onMenuItemClick(MenuItem item) { switch (item.getItemId()) { case R.id.memory_add: onMemoryAdd(); return true; case R.id.memory_subtract: onMemorySubtract(); return true; case R.id.memory_store: onMemoryStore(); return true; case R.id.menu_copy: if (mEvaluator.evaluationInProgress(mIndex)) { // Refuse to copy placeholder characters. return false; } else { copyContent(); unhighlightResult(); return true; } default: return false; } } @Override protected void onDetachedFromWindow() { stopActionModeOrContextMenu(); super.onDetachedFromWindow(); } }