Expression formatting and cursor fixes

P.S. This commit is a mess, lots of hardcoded stuff and weird decisions.
This commit is contained in:
Sad Ellie 2023-02-24 19:05:06 +04:00
parent 1a6d6fdce4
commit 81f750402a
9 changed files with 543 additions and 149 deletions

View File

@ -25,23 +25,28 @@ import com.sadellie.unitto.core.base.KEY_0
import com.sadellie.unitto.core.base.KEY_COMMA
import com.sadellie.unitto.core.base.KEY_DOT
import com.sadellie.unitto.core.base.KEY_E
import com.sadellie.unitto.core.base.KEY_LEFT_BRACKET
import com.sadellie.unitto.core.base.KEY_MINUS
import com.sadellie.unitto.core.base.KEY_RIGHT_BRACKET
import com.sadellie.unitto.core.base.OPERATORS
import com.sadellie.unitto.core.base.Separator
import java.math.BigDecimal
import java.math.RoundingMode
object Formatter {
private const val SPACE = " "
private const val PERIOD = "."
private const val COMMA = ","
// Legacy, LOL. Will change later
object Formatter : UnittoFormatter()
open class UnittoFormatter {
/**
* This regex will catch things like "123.456", "123", ".456"
*/
private val numbersRegex = Regex("[\\d.]+")
private val SPACE = " "
private val PERIOD = "."
private val COMMA = ","
/**
* Grouping separator.
*/
private var grouping: String = SPACE
var grouping: String = SPACE
/**
* Fractional part separator.
@ -91,11 +96,8 @@ object Formatter {
var output = input
// We may receive expressions
// Find all numbers in that expression
val allNumbers: List<String> = input.split(
*OPERATORS.toTypedArray(), KEY_LEFT_BRACKET, KEY_RIGHT_BRACKET
)
// We may receive expressions. Find all numbers in this expression
val allNumbers: List<String> = input.getOnlyNumbers()
allNumbers.forEach {
output = output.replace(it, formatNumber(it))
@ -108,34 +110,50 @@ object Formatter {
return output
}
/**
* Remove formatting. Reverses [format]
*/
fun reFormat(input: String): String {
// We get 123.45,6789
// We need 12.345,6789
// 123.45,6789
// Remove grouping
// 12345,6789
// Replace fractional with "." because formatter accepts only numbers where fractional is a dot
val cleanString = input
.replace(grouping, "")
.replace(fractional, KEY_DOT)
return format(cleanString)
}
/**
* Format given [input].
*
* Input must be a number. Will replace grouping separators and fractional part separators.
* Input must be a number with dot!!!. Will replace grouping separators and fractional part (dot)
* separators.
*
* @see grouping
* @see fractional
*/
private fun formatNumber(input: String): String {
val splitInput = input.split(".")
var firstPart = splitInput[0]
if (input.any { it.isLetter() }) return input
// Number of empty symbols (spaces) we need to add to correctly split into chunks.
var firstPart = input.takeWhile { it != '.' }
val remainingPart = input.removePrefix(firstPart)
// Number of empty symbols (spaces) we need to add to correctly split into chunks.
val offset = 3 - firstPart.length.mod(3)
var output = if (offset != 3) {
// We add some spaces at the begging so that last chunk has 3 symbols
val output = if (offset != 3) {
// We add some spaces at the beginning so that last chunk has 3 symbols
firstPart = " ".repeat(offset) + firstPart
firstPart.chunked(3).joinToString(grouping).drop(offset)
} else {
firstPart.chunked(3).joinToString(grouping)
}
// Handling fractional part
if (input.contains(".")) {
output = output + fractional + splitInput.getOrElse(1) { "" }
}
return output
return (output + remainingPart.replace(".", fractional))
}
/**
@ -175,4 +193,10 @@ object Formatter {
}
return result.trimEnd()
}
/**
* @receiver Must be a string with a dot (".") used as a fractional.
*/
private fun String.getOnlyNumbers(): List<String> =
numbersRegex.findAll(this).map(MatchResult::value).toList()
}

View File

@ -36,6 +36,7 @@ private const val INCOMPLETE_VALUE = "123456."
private const val NO_FRACTIONAL_VALUE = "123456"
private const val INCOMPLETE_EXPR = "50+123456÷8×0.812+"
private const val COMPLETE_EXPR = "50+123456÷8×0.812+0-√9*4^9+2×(9+8×7)"
private const val LONG_HALF_COMPLETE_EXPR = "50+123456÷89078..9×0.812+0-√9*4^9+2×(9+8×7)×sin(13sin123cos"
private const val SOME_BRACKETS = "(((((((("
@RunWith(RobolectricTestRunner::class)
@ -55,6 +56,7 @@ class FormatterTest {
assertEquals("123 456", formatter.format(NO_FRACTIONAL_VALUE))
assertEquals("50+123 456÷8×0.812+", formatter.format(INCOMPLETE_EXPR))
assertEquals("50+123 456÷8×0.812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
assertEquals("50+123 456÷89 078..9×0.812+0√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR))
assertEquals("((((((((", formatter.format(SOME_BRACKETS))
}
@ -69,6 +71,7 @@ class FormatterTest {
assertEquals("123,456", formatter.format(NO_FRACTIONAL_VALUE))
assertEquals("50+123,456÷8×0.812+", formatter.format(INCOMPLETE_EXPR))
assertEquals("50+123,456÷8×0.812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
assertEquals("50+123,456÷89,078..9×0.812+0√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR))
assertEquals("((((((((", formatter.format(SOME_BRACKETS))
}
@ -83,6 +86,7 @@ class FormatterTest {
assertEquals("123.456", formatter.format(NO_FRACTIONAL_VALUE))
assertEquals("50+123.456÷8×0,812+", formatter.format(INCOMPLETE_EXPR))
assertEquals("50+123.456÷8×0,812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
assertEquals("50+123.456÷89.078,,9×0,812+0√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR))
assertEquals("((((((((", formatter.format(SOME_BRACKETS))
}
@ -91,96 +95,96 @@ class FormatterTest {
formatter.setSeparator(Separator.SPACES)
composeTestRule.setContent {
var basicValue = BigDecimal.valueOf(1)
assertEquals("-28", Formatter.formatTime("-28", basicValue))
assertEquals("-0.05", Formatter.formatTime("-0.05", basicValue))
assertEquals("0", Formatter.formatTime("0", basicValue))
assertEquals("0", Formatter.formatTime("-0", basicValue))
assertEquals("-28", formatter.formatTime("-28", basicValue))
assertEquals("-0.05", formatter.formatTime("-0.05", basicValue))
assertEquals("0", formatter.formatTime("0", basicValue))
assertEquals("0", formatter.formatTime("-0", basicValue))
basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0)
assertEquals("-28d", Formatter.formatTime("-28", basicValue))
assertEquals("-1h 12m", Formatter.formatTime("-0.05", basicValue))
assertEquals("0", Formatter.formatTime("0", basicValue))
assertEquals("0", Formatter.formatTime("-0", basicValue))
assertEquals("-28d", formatter.formatTime("-28", basicValue))
assertEquals("-1h 12m", formatter.formatTime("-0.05", basicValue))
assertEquals("0", formatter.formatTime("0", basicValue))
assertEquals("0", formatter.formatTime("-0", basicValue))
// DAYS
basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0)
assertEquals("12h", Formatter.formatTime("0.5", basicValue))
assertEquals("1h 12m", Formatter.formatTime("0.05", basicValue))
assertEquals("7m 12s", Formatter.formatTime("0.005", basicValue))
assertEquals("28d", Formatter.formatTime("28", basicValue))
assertEquals("90d", Formatter.formatTime("90", basicValue))
assertEquals("90d 12h", Formatter.formatTime("90.5", basicValue))
assertEquals("90d 7m 12s", Formatter.formatTime("90.005", basicValue))
assertEquals("12h", formatter.formatTime("0.5", basicValue))
assertEquals("1h 12m", formatter.formatTime("0.05", basicValue))
assertEquals("7m 12s", formatter.formatTime("0.005", basicValue))
assertEquals("28d", formatter.formatTime("28", basicValue))
assertEquals("90d", formatter.formatTime("90", basicValue))
assertEquals("90d 12h", formatter.formatTime("90.5", basicValue))
assertEquals("90d 7m 12s", formatter.formatTime("90.005", basicValue))
// HOURS
basicValue = BigDecimal.valueOf(3_600_000_000_000_000_000_000.0)
assertEquals("30m", Formatter.formatTime("0.5", basicValue))
assertEquals("3m", Formatter.formatTime("0.05", basicValue))
assertEquals("18s", Formatter.formatTime("0.005", basicValue))
assertEquals("1d 4h", Formatter.formatTime("28", basicValue))
assertEquals("3d 18h", Formatter.formatTime("90", basicValue))
assertEquals("3d 18h 30m", Formatter.formatTime("90.5", basicValue))
assertEquals("3d 18h 18s", Formatter.formatTime("90.005", basicValue))
assertEquals("30m", formatter.formatTime("0.5", basicValue))
assertEquals("3m", formatter.formatTime("0.05", basicValue))
assertEquals("18s", formatter.formatTime("0.005", basicValue))
assertEquals("1d 4h", formatter.formatTime("28", basicValue))
assertEquals("3d 18h", formatter.formatTime("90", basicValue))
assertEquals("3d 18h 30m", formatter.formatTime("90.5", basicValue))
assertEquals("3d 18h 18s", formatter.formatTime("90.005", basicValue))
// MINUTES
basicValue = BigDecimal.valueOf(60_000_000_000_000_000_000.0)
assertEquals("30s", Formatter.formatTime("0.5", basicValue))
assertEquals("3s", Formatter.formatTime("0.05", basicValue))
assertEquals("300ms", Formatter.formatTime("0.005", basicValue))
assertEquals("28m", Formatter.formatTime("28", basicValue))
assertEquals("1h 30m", Formatter.formatTime("90", basicValue))
assertEquals("1h 30m 30s", Formatter.formatTime("90.5", basicValue))
assertEquals("1h 30m 300ms", Formatter.formatTime("90.005", basicValue))
assertEquals("30s", formatter.formatTime("0.5", basicValue))
assertEquals("3s", formatter.formatTime("0.05", basicValue))
assertEquals("300ms", formatter.formatTime("0.005", basicValue))
assertEquals("28m", formatter.formatTime("28", basicValue))
assertEquals("1h 30m", formatter.formatTime("90", basicValue))
assertEquals("1h 30m 30s", formatter.formatTime("90.5", basicValue))
assertEquals("1h 30m 300ms", formatter.formatTime("90.005", basicValue))
// SECONDS
basicValue = BigDecimal.valueOf(1_000_000_000_000_000_000)
assertEquals("500ms", Formatter.formatTime("0.5", basicValue))
assertEquals("50ms", Formatter.formatTime("0.05", basicValue))
assertEquals("5ms", Formatter.formatTime("0.005", basicValue))
assertEquals("28s", Formatter.formatTime("28", basicValue))
assertEquals("1m 30s", Formatter.formatTime("90", basicValue))
assertEquals("1m 30s 500ms", Formatter.formatTime("90.5", basicValue))
assertEquals("1m 30s 5ms", Formatter.formatTime("90.005", basicValue))
assertEquals("500ms", formatter.formatTime("0.5", basicValue))
assertEquals("50ms", formatter.formatTime("0.05", basicValue))
assertEquals("5ms", formatter.formatTime("0.005", basicValue))
assertEquals("28s", formatter.formatTime("28", basicValue))
assertEquals("1m 30s", formatter.formatTime("90", basicValue))
assertEquals("1m 30s 500ms", formatter.formatTime("90.5", basicValue))
assertEquals("1m 30s 5ms", formatter.formatTime("90.005", basicValue))
// MILLISECONDS
basicValue = BigDecimal.valueOf(1_000_000_000_000_000)
assertEquals("500µs", Formatter.formatTime("0.5", basicValue))
assertEquals("50µs", Formatter.formatTime("0.05", basicValue))
assertEquals("5µs", Formatter.formatTime("0.005", basicValue))
assertEquals("28ms", Formatter.formatTime("28", basicValue))
assertEquals("90ms", Formatter.formatTime("90", basicValue))
assertEquals("90ms 500µs", Formatter.formatTime("90.5", basicValue))
assertEquals("90ms 5µs", Formatter.formatTime("90.005", basicValue))
assertEquals("500µs", formatter.formatTime("0.5", basicValue))
assertEquals("50µs", formatter.formatTime("0.05", basicValue))
assertEquals("5µs", formatter.formatTime("0.005", basicValue))
assertEquals("28ms", formatter.formatTime("28", basicValue))
assertEquals("90ms", formatter.formatTime("90", basicValue))
assertEquals("90ms 500µs", formatter.formatTime("90.5", basicValue))
assertEquals("90ms 5µs", formatter.formatTime("90.005", basicValue))
// MICROSECONDS
basicValue = BigDecimal.valueOf(1_000_000_000_000)
assertEquals("500ns", Formatter.formatTime("0.5", basicValue))
assertEquals("50ns", Formatter.formatTime("0.05", basicValue))
assertEquals("5ns", Formatter.formatTime("0.005", basicValue))
assertEquals("28µs", Formatter.formatTime("28", basicValue))
assertEquals("90µs", Formatter.formatTime("90", basicValue))
assertEquals("90µs 500ns", Formatter.formatTime("90.5", basicValue))
assertEquals("90µs 5ns", Formatter.formatTime("90.005", basicValue))
assertEquals("500ns", formatter.formatTime("0.5", basicValue))
assertEquals("50ns", formatter.formatTime("0.05", basicValue))
assertEquals("5ns", formatter.formatTime("0.005", basicValue))
assertEquals("28µs", formatter.formatTime("28", basicValue))
assertEquals("90µs", formatter.formatTime("90", basicValue))
assertEquals("90µs 500ns", formatter.formatTime("90.5", basicValue))
assertEquals("90µs 5ns", formatter.formatTime("90.005", basicValue))
// NANOSECONDS
basicValue = BigDecimal.valueOf(1_000_000_000)
assertEquals("500 000 000as", Formatter.formatTime("0.5", basicValue))
assertEquals("50 000 000as", Formatter.formatTime("0.05", basicValue))
assertEquals("5 000 000as", Formatter.formatTime("0.005", basicValue))
assertEquals("28ns", Formatter.formatTime("28", basicValue))
assertEquals("90ns", Formatter.formatTime("90", basicValue))
assertEquals("90ns 500 000 000as", Formatter.formatTime("90.5", basicValue))
assertEquals("90ns 5 000 000as", Formatter.formatTime("90.005", basicValue))
assertEquals("500 000 000as", formatter.formatTime("0.5", basicValue))
assertEquals("50 000 000as", formatter.formatTime("0.05", basicValue))
assertEquals("5 000 000as", formatter.formatTime("0.005", basicValue))
assertEquals("28ns", formatter.formatTime("28", basicValue))
assertEquals("90ns", formatter.formatTime("90", basicValue))
assertEquals("90ns 500 000 000as", formatter.formatTime("90.5", basicValue))
assertEquals("90ns 5 000 000as", formatter.formatTime("90.005", basicValue))
// ATTOSECONDS
basicValue = BigDecimal.valueOf(1)
assertEquals("0.5", Formatter.formatTime("0.5", basicValue))
assertEquals("0.05", Formatter.formatTime("0.05", basicValue))
assertEquals("0.005", Formatter.formatTime("0.005", basicValue))
assertEquals("28", Formatter.formatTime("28", basicValue))
assertEquals("90", Formatter.formatTime("90", basicValue))
assertEquals("90.5", Formatter.formatTime("90.5", basicValue))
assertEquals("90.005", Formatter.formatTime("90.005", basicValue))
assertEquals("0.5", formatter.formatTime("0.5", basicValue))
assertEquals("0.05", formatter.formatTime("0.05", basicValue))
assertEquals("0.005", formatter.formatTime("0.005", basicValue))
assertEquals("28", formatter.formatTime("28", basicValue))
assertEquals("90", formatter.formatTime("90", basicValue))
assertEquals("90.5", formatter.formatTime("90.5", basicValue))
assertEquals("90.005", formatter.formatTime("90.005", basicValue))
}
}
}

View File

@ -54,16 +54,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sadellie.unitto.core.ui.Formatter
import com.sadellie.unitto.core.ui.common.MenuButton
import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar
import com.sadellie.unitto.core.ui.Formatter
import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayMedium
import com.sadellie.unitto.data.model.HistoryItem
import com.sadellie.unitto.feature.calculator.components.CalculatorKeyboard
@ -205,10 +204,7 @@ private fun CalculatorScreen(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
value = TextFieldValue(
text = uiState.input,
selection = TextRange(uiState.selection.first, uiState.selection.last)
),
value = uiState.input,
onCursorChange = onCursorChange,
pasteCallback = addSymbol,
cutCallback = deleteSymbol
@ -306,7 +302,7 @@ private fun PreviewCalculatorScreen() {
CalculatorScreen(
uiState = CalculatorUIState(
input = "12345",
input = TextFieldValue("12345"),
output = "12345",
history = historyItems
),

View File

@ -18,12 +18,12 @@
package com.sadellie.unitto.feature.calculator
import androidx.compose.ui.text.input.TextFieldValue
import com.sadellie.unitto.data.model.HistoryItem
internal data class CalculatorUIState(
val input: String = "",
val input: TextFieldValue = TextFieldValue(),
val output: String = "",
val selection: IntRange = 0..0,
val angleMode: AngleMode = AngleMode.RAD,
val history: List<HistoryItem> = emptyList()
)

View File

@ -45,11 +45,13 @@ import org.mariuszgromada.math.mxparser.Expression
import java.math.BigDecimal
import javax.inject.Inject
import org.mariuszgromada.math.mxparser.mXparser as MathParser
import org.mariuszgromada.math.mxparser.License as MathParserLicense
@HiltViewModel
internal class CalculatorViewModel @Inject constructor(
userPrefsRepository: UserPreferencesRepository,
private val calculatorHistoryRepository: CalculatorHistoryRepository
private val calculatorHistoryRepository: CalculatorHistoryRepository,
private val textFieldController: TextFieldController
) : ViewModel() {
private val _userPrefs: StateFlow<UserPreferences> =
userPrefsRepository.userPreferencesFlow.stateIn(
@ -58,19 +60,16 @@ internal class CalculatorViewModel @Inject constructor(
UserPreferences()
)
private val _input: MutableStateFlow<String> = MutableStateFlow("")
private val _output: MutableStateFlow<String> = MutableStateFlow("")
private val _selection: MutableStateFlow<IntRange> = MutableStateFlow(IntRange(0, 0))
private val _angleMode: MutableStateFlow<AngleMode> = MutableStateFlow(AngleMode.RAD)
private val _history = calculatorHistoryRepository.historyFlow
val uiState = combine(
_input, _output, _selection, _angleMode, _history
) { input, output, selection, angleMode, history ->
textFieldController.input, _output, _angleMode, _history, _userPrefs
) { input, output, angleMode, history, _ ->
return@combine CalculatorUIState(
input = input,
output = output,
selection = selection,
angleMode = angleMode,
history = history
)
@ -78,30 +77,11 @@ internal class CalculatorViewModel @Inject constructor(
viewModelScope, SharingStarted.WhileSubscribed(5000L), CalculatorUIState()
)
fun addSymbol(symbol: String) {
val selection = _selection.value
_input.update {
if (it.isEmpty()) symbol else it.replaceRange(selection.first, selection.last, symbol)
}
_selection.update { it.first + symbol.length..it.first + symbol.length }
}
fun addSymbol(symbol: String) = textFieldController.addToInput(symbol)
fun deleteSymbol() {
val selection = _selection.value
val newSelectionStart = when (selection.last) {
0 -> return
selection.first -> _selection.value.first - 1
else -> _selection.value.first
}
fun deleteSymbol() = textFieldController.delete()
_selection.update { newSelectionStart..newSelectionStart }
_input.update { it.removeRange(newSelectionStart, selection.last) }
}
fun clearSymbols() {
_selection.update { 0..0 }
_input.update { "" }
}
fun clearSymbols() = textFieldController.clearInput()
fun toggleCalculatorMode() {
_angleMode.update {
@ -117,44 +97,37 @@ internal class CalculatorViewModel @Inject constructor(
// Called when user clicks "=" on a keyboard
fun evaluate() {
if (!Expression(_input.value.clean).checkSyntax()) return
// Input and output can change while saving in history. This way we cache it here (i think)
val input = _input.value
val currentInput = textFieldController.input.value.text
val output = _output.value
if (!Expression(currentInput.clean).checkSyntax()) return
// Save to history
viewModelScope.launch(Dispatchers.IO) {
calculatorHistoryRepository.add(
expression = input,
expression = textFieldController.inputTextWithoutFormatting(),
result = output
)
}
_input.update { _output.value }
_selection.update { _input.value.length.._input.value.length }
_output.update { "" }
}
fun clearHistory() {
viewModelScope.launch(Dispatchers.IO) {
calculatorHistoryRepository.clear()
}
fun clearHistory() = viewModelScope.launch(Dispatchers.IO) {
calculatorHistoryRepository.clear()
}
fun onCursorChange(selection: IntRange) {
// When we paste, selection is set to the length of the pasted text (start and end)
if (selection.first > _input.value.length) return
_selection.update { selection }
}
fun onCursorChange(selection: IntRange) = textFieldController.moveCursor(selection)
private fun calculateInput() {
val currentInput = textFieldController.input.value.text
// Input is empty, don't calculate
if (_input.value.isEmpty()) {
if (currentInput.isEmpty()) {
_output.update { "" }
return
}
val calculated = Expression(_input.value.clean).calculate()
val calculated = Expression(currentInput.clean).calculate()
// Calculation error, return NaN
if (calculated.isNaN() or calculated.isInfinite()) {
@ -168,7 +141,7 @@ internal class CalculatorViewModel @Inject constructor(
.trimZeros()
try {
val inputBigDecimal = BigDecimal(_input.value)
val inputBigDecimal = BigDecimal(currentInput)
// Input and output are identical values
if (inputBigDecimal.compareTo(calculatedBigDecimal) == 0) {
@ -192,8 +165,7 @@ internal class CalculatorViewModel @Inject constructor(
val leftBrackets = count { it.toString() == KEY_LEFT_BRACKET }
val rightBrackets = count { it.toString() == KEY_RIGHT_BRACKET }
val neededBrackets = leftBrackets - rightBrackets
return this
.replace(KEY_MINUS_DISPLAY, KEY_MINUS)
return replace(KEY_MINUS_DISPLAY, KEY_MINUS)
.plus(KEY_RIGHT_BRACKET.repeat(neededBrackets.coerceAtLeast(0)))
}
@ -203,10 +175,11 @@ internal class CalculatorViewModel @Inject constructor(
* to load CPU very much. We use BigDecimal to achieve same result without CPU overload.
*/
MathParser.setCanonicalRounding(false)
MathParserLicense.iConfirmNonCommercialUse("Sad Ellie")
// Observe and invoke calculation without UI lag.
viewModelScope.launch(Dispatchers.Default) {
merge(_userPrefs, _input, _angleMode).collectLatest {
merge(_userPrefs, textFieldController.input, _angleMode).collectLatest {
calculateInput()
}
}

View File

@ -0,0 +1,190 @@
/*
* 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.feature.calculator
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.sadellie.unitto.core.base.KEY_COS
import com.sadellie.unitto.core.base.KEY_DOT
import com.sadellie.unitto.core.base.KEY_LN
import com.sadellie.unitto.core.base.KEY_LOG
import com.sadellie.unitto.core.base.KEY_SIN
import com.sadellie.unitto.core.base.KEY_TAN
import com.sadellie.unitto.core.ui.UnittoFormatter
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import kotlin.math.abs
class TextFieldController @Inject constructor() {
// Internally we don't care about user preference here, because during composition this
// symbols will be replaced to those that user wanted.
// We do this because it adds unnecessary logic: it requires additional logic to observe and
// react to formatting preferences at this level.
private val localFormatter: UnittoFormatter by lazy {
UnittoFormatter().also {
it.grouping = "`"
it.fractional = "|"
}
}
var input: MutableStateFlow<TextFieldValue> = MutableStateFlow(TextFieldValue())
fun addToInput(symbols: String) {
val text = input.value.text
val selection = input.value.selection
val lastToEndDistance = text.length - selection.end
val newInput = if (text.isEmpty()) {
symbols
} else {
text.replaceRange(selection.start, selection.end, symbols)
}
val inputFormatted = newInput.fixFormat()
val newSelectionStartEnd = inputFormatted.length - lastToEndDistance
input.update {
it.copy(
text = inputFormatted,
selection = TextRange(newSelectionStartEnd, newSelectionStartEnd)
)
}
}
fun moveCursor(newPosition: IntRange) {
val cursorFixer = CursorFixer(grouping = localFormatter.grouping)
val currentInput = input.value.text
val fixedLeftCursor = cursorFixer.fixCursorIfNeeded(currentInput, newPosition.first)
val fixedRightCursor = cursorFixer.fixCursorIfNeeded(currentInput, newPosition.last)
// Will modify
input.update {
it.copy(
selection = TextRange(fixedLeftCursor, fixedRightCursor)
)
}
}
fun delete() {
val selection = input.value.selection
val distanceFromEnd = input.value.text.length - selection.end
val newSelectionStart = when (selection.end) {
// Don't delete if at the start of the text field
0 -> return
// We don't have anything selected (cursor in one position)
// like this 1234|56 => after deleting will be like this 123|56
// Cursor moved one symbol left
selection.start -> selection.start - 1
// We have multiple symbols selected
// like this 123[45]6 => after deleting will be like this 123|6
// Cursor will be placed where selection start was
else -> selection.start
}
input.update {
val newText = it.text
.removeRange(newSelectionStart, it.selection.end)
.fixFormat()
it.copy(
text = newText,
selection = TextRange(newText.length - distanceFromEnd, newText.length - distanceFromEnd)
)
}
}
fun clearInput() = input.update { TextFieldValue() }
fun inputTextWithoutFormatting() = input.value.text
.replace(localFormatter.grouping, "")
.replace(localFormatter.fractional, KEY_DOT)
private fun String.fixFormat(): String = localFormatter.reFormat(this)
inner class CursorFixer(private val grouping: String) {
fun fixCursorIfNeeded(str: String, pos: Int): Int {
// First we check if try to place cursors at illegal position
// If yes,
// we go left until cursor is position legally. Remember the distance
val bestLeft = bestPositionLeft(str, pos)
// we go right until cursor is position legally. Remember the distance
val bestRight = bestPositionRight(str, pos)
// Now we compare left and right distance
val bestPosition = listOf(bestLeft, bestRight)
// We move to the that's smaller
.minBy { abs(it - pos) }
return bestPosition
}
fun bestPositionLeft(str: String, pos: Int): Int {
var cursorPosition = pos
while (placedIllegally(str, cursorPosition)) cursorPosition--
return cursorPosition
}
private fun bestPositionRight(str: String, pos: Int): Int {
var cursorPosition = pos
while (placedIllegally(str, cursorPosition)) cursorPosition++
return cursorPosition
}
private fun placedIllegally(str: String, pos: Int): Boolean {
// For things like "123,|456" - this is illegal
if (pos.afterToken(str, grouping)) return true
// For things like "123,456+c|os(8)" - this is illegal
val illegalTokens = listOf(
KEY_COS, KEY_SIN, KEY_LN, KEY_LOG, KEY_TAN
)
illegalTokens.forEach {
if (pos.atToken(str, it)) return true
}
return false
}
/**
* Don't use if token is 1 symbol long, it wouldn't make sense! Use [afterToken] instead.
* @see [afterToken]
*/
private fun Int.atToken(str: String, token: String): Boolean {
val checkBound = (token.length - 1).coerceAtLeast(1)
val stringToScan = str.substring(
startIndex = (this - checkBound).coerceAtLeast(0),
endIndex = (this + checkBound).coerceAtMost(str.length)
)
return stringToScan.contains(token)
}
private fun Int.afterToken(str: String, token: String): Boolean {
val stringToScan = str.substring(
startIndex = (this - token.length).coerceAtLeast(0),
endIndex = this
)
return stringToScan.contains(token)
}
}
}

View File

@ -120,7 +120,7 @@ private fun HistoryListItem(
Modifier.clickable { onTextClick(historyItem.expression) }
) {
Text(
text = historyItem.expression,
text = Formatter.format(historyItem.expression),
maxLines = 1,
modifier = Modifier
.fillMaxWidth()

View File

@ -22,6 +22,9 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalTextInputService
@ -29,6 +32,7 @@ import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import com.sadellie.unitto.core.ui.Formatter
import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayLarge
@Composable
@ -40,6 +44,18 @@ internal fun InputTextField(
cutCallback: () -> Unit
) {
val clipboardManager = LocalClipboardManager.current
val formattedInput: TextFieldValue by remember(value) {
derivedStateOf {
value.copy(
// We replace this because internally input value is already formatted, but uses
// "|" as grouping and "-" as fractional.
value.text
.replace("`", Formatter.grouping)
.replace("|", Formatter.fractional)
)
}
}
CompositionLocalProvider(
LocalTextInputService provides null,
LocalTextToolbar provides UnittoTextToolbar(
@ -51,7 +67,7 @@ internal fun InputTextField(
BasicTextField(
modifier = modifier,
singleLine = true,
value = value,
value = formattedInput,
onValueChange = {
onCursorChange(it.selection.start..it.selection.end)
},

View File

@ -0,0 +1,191 @@
/*
* 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.feature.calculator
import com.sadellie.unitto.core.base.Separator
import com.sadellie.unitto.core.ui.Formatter
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
internal class TextFieldControllerTest {
private lateinit var textFieldController: TextFieldController
private val TextFieldController.text: String
get() = this.input.value.text
.replace("`", ",")
.replace("|", ".")
private val TextFieldController.selection: IntRange
get() = this.input.value.selection.start..this.input.value.selection.end
@Before
fun setUp() {
textFieldController = TextFieldController()
Formatter.setSeparator(Separator.COMMA)
}
@Test
fun `add when empty`() {
// Add one symbol
textFieldController.addToInput("1")
assertEquals("1", textFieldController.text)
assertEquals(1..1, textFieldController.selection)
textFieldController.clearInput()
// Add multiple
textFieldController.addToInput("123")
assertEquals("123", textFieldController.text)
assertEquals(3..3, textFieldController.selection)
textFieldController.clearInput()
// Add multiple
textFieldController.addToInput("1234")
assertEquals("1,234", textFieldController.text)
assertEquals(5..5, textFieldController.selection)
textFieldController.clearInput()
// Add multiple
textFieldController.addToInput("123456.789")
assertEquals("123,456.789", textFieldController.text)
assertEquals(11..11, textFieldController.selection)
textFieldController.clearInput()
}
@Test
fun `Add when not empty one symbol at a time (check formatting)`() {
// Should be 1|
textFieldController.addToInput("1")
assertEquals("1", textFieldController.text)
assertEquals(1..1, textFieldController.selection)
// Should be 12|
textFieldController.addToInput("2")
assertEquals("12", textFieldController.text)
assertEquals(2..2, textFieldController.selection)
// Should be 123|
textFieldController.addToInput("3")
assertEquals("123", textFieldController.text)
assertEquals(3..3, textFieldController.selection)
// Should be 1,234|
textFieldController.addToInput("4")
assertEquals("1,234", textFieldController.text)
assertEquals(5..5, textFieldController.selection)
// Should be 12,345|
textFieldController.addToInput("5")
assertEquals("12,345", textFieldController.text)
assertEquals(6..6, textFieldController.selection)
}
@Test
fun `Delete on empty input`() {
// Delete on empty input
textFieldController.delete()
assertEquals("", textFieldController.text)
assertEquals(0..0, textFieldController.selection)
textFieldController.clearInput()
}
@Test
fun `Delete last remaining symbol`() {
textFieldController.addToInput("1")
textFieldController.delete()
assertEquals("", textFieldController.text)
assertEquals(0..0, textFieldController.selection)
textFieldController.clearInput()
}
@Test
fun `Delete by one symbol (check formatting)`() {
textFieldController.addToInput("123456")
// Input is formatted into 123,456
textFieldController.delete()
assertEquals("12,345", textFieldController.text)
assertEquals(6..6, textFieldController.selection)
textFieldController.delete()
assertEquals("1,234", textFieldController.text)
assertEquals(5..5, textFieldController.selection)
textFieldController.delete()
assertEquals("123", textFieldController.text)
println("in 123: ${textFieldController.selection}")
assertEquals(3..3, textFieldController.selection)
textFieldController.clearInput()
}
@Test
fun `Delete multiple symbols, selected before separator`() {
textFieldController.addToInput("123789456")
// Input is formatted to 123,789,456
textFieldController.moveCursor(3..7)
textFieldController.delete()
assertEquals("123,456", textFieldController.text)
assertEquals(3..3, textFieldController.selection)
textFieldController.clearInput()
}
@Test
fun `Delete multiple symbols, selected not near separator`() {
textFieldController.addToInput("123789456")
// Input is formatted to 123,789,456
textFieldController.moveCursor(3..9)
textFieldController.delete()
assertEquals("12,356", textFieldController.text)
assertEquals(4..4, textFieldController.selection)
textFieldController.clearInput()
}
@Test
fun `Delete multiple symbols in weird input`() {
textFieldController.addToInput("123...789456")
// Input is formatted to 123...789456
textFieldController.moveCursor(3..9)
textFieldController.delete()
assertEquals(4..4, textFieldController.selection)
assertEquals("123,456", textFieldController.text)
textFieldController.clearInput()
}
@Test
fun `placed cursor illegally`() {
textFieldController.addToInput("123456.789")
// Input is 123,456.789
textFieldController.moveCursor(4..4)
// Cursor should be placed like this 123|,456.789
assertEquals(3..3, textFieldController.selection)
textFieldController.clearInput()
textFieldController.addToInput("123456.789+cos(")
// Input is 123,456.789+cos(
textFieldController.moveCursor(13..13)
// Cursor should be placed like this 123,456.789+c|os(
assertEquals(12..12, textFieldController.selection)
textFieldController.clearInput()
}
@Test
fun `get clear input text without formatting`() {
textFieldController.addToInput("123456.789+cos(..)")
// Input is 123,456.789
assertEquals("123456.789+cos(..)", textFieldController.inputTextWithoutFormatting())
}
}