Update text field formatting

This commit is contained in:
Sad Ellie 2023-11-24 13:22:26 +03:00
parent 24f03b9d3d
commit 4305354931
9 changed files with 176 additions and 74 deletions

View File

@ -54,7 +54,7 @@ object Token {
object Operator {
const val plus = "+"
const val minus = ""
const val minus = "" // MINUS SIGN, not a regular minus (HYPHEN-MINUS)
const val multiply = "×"
const val divide = "÷"
const val leftBracket = "("
@ -122,6 +122,7 @@ object Token {
const val comma = ","
const val engineeringE = "E"
const val minus = ""
const val fraction = ""
}
val expressionTokens by lazy {

View File

@ -22,11 +22,19 @@ import com.sadellie.unitto.core.base.Token
private val numbersRegex by lazy { Regex("[\\d.]+") }
/**
* Removes formatting from expression. Reverse of [formatExpression]. Ugly symbols (for example,
* minus) will be replaced with a [Token.Operator.minus] from [Token.sexyToUgly].
*
* @param formatterSymbols [FormatterSymbols] that were used in [formatExpression].
* @return Clean expression. 123,456.789 -> 123456.789
*/
fun String.clearAndFilterExpression(formatterSymbols: FormatterSymbols): String {
var clean = this
.replace(formatterSymbols.grouping, "")
.replace(formatterSymbols.fractional, Token.Digit.dot)
.replace(" ", "")
.replace(" ", Token.Operator.plus)
.replace(Token.DisplayOnly.fraction, Token.Operator.divide)
Token.sexyToUgly.forEach { (token, ugliness) ->
ugliness.forEach {
@ -34,22 +42,29 @@ fun String.clearAndFilterExpression(formatterSymbols: FormatterSymbols): String
}
}
return clean.cleanIt(Token.expressionTokens)
return clean.leaveLegalTokensOnly(Token.expressionTokens)
}
internal fun String.clearAndFilterNumberBase(): String {
return uppercase().cleanIt(Token.numberBaseTokens)
return uppercase().leaveLegalTokensOnly(Token.numberBaseTokens)
}
fun String.formatExpression(
formatterSymbols: FormatterSymbols
): String {
var input = this
// Don't do anything to engineering string.
if (input.contains(Token.DisplayOnly.engineeringE)) {
return input.replace(Token.Digit.dot, formatterSymbols.fractional)
}
// Only format integral part
if (input.contains(Token.DisplayOnly.fraction)) {
val (integral, fraction) = input.split(" ")
return "${integral.formatNumber(formatterSymbols)} $fraction"
}
numbersRegex
.findAll(input)
.map(MatchResult::value)
@ -89,7 +104,7 @@ private fun String.formatNumber(
return output.plus(remainingPart.replace(".", formatterSymbols.fractional))
}
private fun String.cleanIt(legalTokens: List<String>): String {
private fun String.leaveLegalTokensOnly(legalTokens: List<String>): String {
val streamOfTokens = this
fun peekTokenAfter(cursor: Int): String? {

View File

@ -76,29 +76,34 @@ fun ExpressionTextField(
readOnly: Boolean = false,
placeholder: String = "",
) {
val localView = LocalView.current
val clipboardManager = LocalClipboardManager.current
val expressionTransformer = remember(formatterSymbols) { ExpressionTransformer(formatterSymbols) }
fun copyCallback() {
clipboardManager.copyWithoutGrouping(value, formatterSymbols)
onCursorChange(TextRange(value.selection.end))
}
val textToolbar: UnittoTextToolbar = if (readOnly) {
UnittoTextToolbar(
view = LocalView.current,
copyCallback = ::copyCallback,
)
} else {
UnittoTextToolbar(
view = LocalView.current,
copyCallback = ::copyCallback,
pasteCallback = {
pasteCallback(clipboardManager.getText()?.text?.clearAndFilterExpression(formatterSymbols) ?: "")
},
cutCallback = {
clipboardManager.copyWithoutGrouping(value, formatterSymbols)
cutCallback()
}
)
val textToolbar: UnittoTextToolbar = remember(readOnly) {
if (readOnly) {
UnittoTextToolbar(
view = localView,
copyCallback = ::copyCallback,
)
} else {
UnittoTextToolbar(
view = localView,
copyCallback = ::copyCallback,
pasteCallback = {
pasteCallback(clipboardManager.getText()?.text?.clearAndFilterExpression(formatterSymbols) ?: "")
},
cutCallback = {
clipboardManager.copyWithoutGrouping(value, formatterSymbols)
cutCallback()
}
)
}
}
AutoSizableTextField(
@ -111,7 +116,7 @@ fun ExpressionTextField(
readOnly = readOnly,
showToolbar = textToolbar::showMenu,
hideToolbar = textToolbar::hide,
visualTransformation = ExpressionTransformer(formatterSymbols),
visualTransformation = expressionTransformer,
placeholder = placeholder,
textToolbar = textToolbar,
)
@ -129,29 +134,32 @@ fun UnformattedTextField(
readOnly: Boolean = false,
placeholder: String = "",
) {
val localView = LocalView.current
val clipboardManager = LocalClipboardManager.current
fun copyCallback() {
clipboardManager.copy(value)
onCursorChange(TextRange(value.selection.end))
}
val textToolbar: UnittoTextToolbar = if (readOnly) {
UnittoTextToolbar(
view = LocalView.current,
copyCallback = ::copyCallback,
)
} else {
UnittoTextToolbar(
view = LocalView.current,
copyCallback = ::copyCallback,
pasteCallback = {
pasteCallback(clipboardManager.getText()?.text?.clearAndFilterNumberBase() ?: "")
},
cutCallback = {
clipboardManager.copy(value)
cutCallback()
}
)
val textToolbar: UnittoTextToolbar = remember(readOnly) {
if (readOnly) {
UnittoTextToolbar(
view = localView,
copyCallback = ::copyCallback,
)
} else {
UnittoTextToolbar(
view = localView,
copyCallback = ::copyCallback,
pasteCallback = {
pasteCallback(clipboardManager.getText()?.text?.clearAndFilterNumberBase() ?: "")
},
cutCallback = {
clipboardManager.copy(value)
cutCallback()
}
)
}
}
AutoSizableTextField(

View File

@ -0,0 +1,57 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.core.ui
import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols
import com.sadellie.unitto.core.ui.common.textfield.clearAndFilterExpression
import org.junit.Assert.assertEquals
import org.junit.Test
class CleanAndFilterExpression {
@Test
fun noAdditionalSymbols() {
assertEquals("123", "123".clearAndFilterExpression(FormatterSymbols.Comma))
assertEquals("123.456", "123.456".clearAndFilterExpression(FormatterSymbols.Comma))
}
@Test
fun hasFormatterSymbol() {
assertEquals("123456", "123,456".clearAndFilterExpression(FormatterSymbols.Comma))
assertEquals("123456.789", "123,456.789".clearAndFilterExpression(FormatterSymbols.Comma))
}
@Test
fun hasWrongFormatterSymbol() {
assertEquals("123456", "123 456".clearAndFilterExpression(FormatterSymbols.Comma))
assertEquals("123456.789", "123 456.789".clearAndFilterExpression(FormatterSymbols.Comma))
}
@Test
fun fractionExpression() {
assertEquals("1600+1234÷56789", "1,600 123456789".clearAndFilterExpression(FormatterSymbols.Comma))
assertEquals("123456.789+1234÷56789", "123,456.789 123456789".clearAndFilterExpression(FormatterSymbols.Comma))
}
@Test
fun garbage() {
// 'e' is a known symbol
assertEquals("eeee123", "pee pee poo poo -123".clearAndFilterExpression(FormatterSymbols.Comma))
assertEquals("eeee123.456", "pee pee poo poo -123.456".clearAndFilterExpression(FormatterSymbols.Comma))
}
}

View File

@ -27,42 +27,46 @@ class ExpressionTransformerTest {
private val expr = ExpressionTransformer(FormatterSymbols.Comma)
private fun origToTrans(orig: String, trans: String, offset: Int): Int =
expr.ExpressionMapping(orig, trans).originalToTransformed(offset)
// Use "|" for cursor
private fun origToTrans(orig: String, trans: String) {
val transformed = trans.replace("|", "")
val original = orig.replace("|", "")
private fun transToOrig(trans: String, orig: String, offset: Int): Int =
expr.ExpressionMapping(orig, trans).transformedToOriginal(offset)
val offsetInTrans = trans.indexOf("|")
val offsetInOrig = orig.indexOf("|")
val expressionMapping = expr.ExpressionMapping(original, transformed)
assertEquals(offsetInTrans, expressionMapping.originalToTransformed(offsetInOrig))
}
private fun transToOrig(trans: String, orig: String) {
val transformed = trans.replace("|", "")
val original = orig.replace("|", "")
val offsetInTrans = trans.indexOf("|")
val offsetInOrig = orig.indexOf("|")
val expressionMapping = expr.ExpressionMapping(original, transformed)
assertEquals(offsetInOrig, expressionMapping.transformedToOriginal(offsetInTrans))
}
@Test
fun `test 1234`() {
// at the start
assertEquals(0, origToTrans("1,234", "1234", 0))
assertEquals(0, transToOrig("1,234", "1234", 0))
transToOrig("|123", "|123")
origToTrans("12|3", "12|3")
// somewhere in inside, no offset needed
assertEquals(1, origToTrans("1234", "1,234", 1))
assertEquals(1, transToOrig("1,234", "1234", 1))
transToOrig("|1,234", "|1234")
origToTrans("|1234", "|1,234")
// somewhere in inside, offset needed
assertEquals(1, transToOrig("1,234", "1234", 2))
transToOrig("1,234|", "1234|")
origToTrans("1234|", "1,234|")
// at the end
assertEquals(5, origToTrans("1234", "1,234", 4))
assertEquals(4, transToOrig("1,234", "1234", 5))
}
transToOrig("|cos(1)+1,234", "|cos(1)+1234")
origToTrans("co|s(1)+1234", "|cos(1)+1,234")
@Test
fun `test 123`() {
// at the start
assertEquals(0, origToTrans("123", "123", 0))
assertEquals(0, transToOrig("123", "123", 0))
// somewhere in inside
assertEquals(1, origToTrans("123", "123", 1))
assertEquals(1, transToOrig("123", "123", 1))
// at the end
assertEquals(3, origToTrans("123", "123", 3))
assertEquals(3, transToOrig("123", "123", 3))
transToOrig("cos|(1)+1,234", "cos(|1)+1234")
origToTrans("cos|(1)+1234", "cos(|1)+1,234")
}
}

View File

@ -32,8 +32,9 @@ private const val INCOMPLETE_EXPR = "50+123456÷8×0.8-12+"
private const val COMPLETE_EXPR = "50+123456÷8×0.8-12+0-√9×4^9+2×(9+8×7)"
private const val LONG_HALF_COMPLETE_EXPR = "50+123456÷89078..9×0.8-12+0-√9×4^9+2×(9+8×7)×sin(13sin123cos"
private const val SOME_BRACKETS = "(((((((("
private const val FRACTION_VALUE = "1600 123456789"
class FormatterTest {
class FormatterExpressionTest {
@Test
fun setSeparatorSpaces() {
@ -47,6 +48,7 @@ class FormatterTest {
assertEquals("50+123 456÷8×0.812+0√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format())
assertEquals("50+123 456÷89 078..9×0.812+0√9×4^9+2×(9+8×7)×sin(13sin123cos", LONG_HALF_COMPLETE_EXPR.format())
assertEquals("((((((((", SOME_BRACKETS.format())
assertEquals("1 600 123456789", FRACTION_VALUE.format())
}
@Test
@ -61,6 +63,7 @@ class FormatterTest {
assertEquals("50+123,456÷8×0.812+0√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format())
assertEquals("50+123,456÷89,078..9×0.812+0√9×4^9+2×(9+8×7)×sin(13sin123cos", LONG_HALF_COMPLETE_EXPR.format())
assertEquals("((((((((", SOME_BRACKETS.format())
assertEquals("1,600 123456789", FRACTION_VALUE.format())
}
@Test
@ -75,5 +78,6 @@ class FormatterTest {
assertEquals("50+123.456÷8×0,812+0√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format())
assertEquals("50+123.456÷89.078,,9×0,812+0√9×4^9+2×(9+8×7)×sin(13sin123cos", LONG_HALF_COMPLETE_EXPR.format())
assertEquals("((((((((", SOME_BRACKETS.format())
assertEquals("1.600 123456789", FRACTION_VALUE.format())
}
}

View File

@ -24,13 +24,25 @@ import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode
/**
* Tries to convert [BigDecimal] into fractional string.
*
* 0.5 -> `12`
*
* 123.5 -> `123 12`
*
* 123 -> `Empty string`
*
* @receiver [BigDecimal]. Scale doesn't matter, but should be `MAX_PRECISION`
* @return
*/
fun BigDecimal.toFractionalString(): String {
// https://www.khanacademy.org/math/cc-eighth-grade-math/cc-8th-numbers-operations/cc-8th-repeating-decimals/v/coverting-repeating-decimals-to-fractions-1
// https://www.khanacademy.org/math/cc-eighth-grade-math/cc-8th-numbers-operations/cc-8th-repeating-decimals/v/coverting-repeating-decimals-to-fractions-2
val (integral, fractional) = this.divideAndRemainder(BigDecimal.ONE)
val integralBI = integral.toBigInteger()
if (fractional.isEqualTo(BigDecimal.ZERO)) return integralBI.toString()
if (fractional.isEqualTo(BigDecimal.ZERO)) return ""
val res: String = if (integral.isEqualTo(BigDecimal.ZERO)) "" else "$integralBI "

View File

@ -116,7 +116,7 @@ fun TextBox(
var outputTF by remember(output) {
mutableStateOf(TextFieldValue(output.text))
}
UnformattedTextField(
ExpressionTextField(
modifier = Modifier
.weight(2f)
.fillMaxWidth()
@ -124,6 +124,7 @@ fun TextBox(
value = outputTF,
minRatio = 1f,
onCursorChange = { outputTF = outputTF.copy(selection = it) },
formatterSymbols = formatterSymbols,
textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f),
readOnly = true,
)

View File

@ -26,13 +26,13 @@ class DecimalToFractionTest {
@Test
fun testNoDecimal1() {
val bd = BigDecimal("100")
assertFractional("100", bd.toFractionalString())
assertFractional("", bd.toFractionalString())
}
@Test
fun testNoDecimal2() {
val bd = BigDecimal("100.000000000")
assertFractional("100", bd.toFractionalString())
assertFractional("", bd.toFractionalString())
}
@Test