Coverage Summary for Class: NumpadInputSpec (com.opty.numpad)

Class Class, % Method, % Branch, % Line, % Instruction, %
NumpadInputSpec 100% (1/1) 100% (2/2) 62.5% (10/16) 100% (13/13) 70% (63/90)


 /*
  * Copyright 2024 Opty  All rights reserved.
  *
  * This software and associated documentation files (the "Software") are protected by copyright law and
  * international treaties. This software has been licensed to users under the terms of the End User
  * License Agreement (EULA) and Terms of Service (ToS) available within the application.
  *
  * Unauthorized copying, reproduction, modification, distribution, display, performance, sublicense, sale,
  * access or use of this Software, or any part thereof, is strictly prohibited and will be prosecuted
  * under applicable laws.
  *
  * The Software contains valuable trade secrets and proprietary information of Opty.
  * No part of the Software may be reproduced, distributed, or transmitted in any form or by any means,
  * including photocopying, recording, or other electronic or mechanical methods without the prior written
  * permission of Opty.
  *
  * 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.
  *
  * For licensing inquiries, please contact: support@optymacros.com
  */
 package com.opty.numpad
 
 import androidx.compose.runtime.Immutable
 import androidx.compose.ui.text.TextRange
 import androidx.compose.ui.text.input.TextFieldValue
 import kotlin.math.max
 import kotlin.math.min
 
 @Immutable
 data class NumpadInputSpec(
     val maxValue: Double,
     val decimalSeparator: Char,
     val maxFractionDigits: Int,
     val maxLength: Int =
         maxValue.toLong().toString().length +
             if (maxFractionDigits > 0) maxFractionDigits + 1 else 0,
 ) {
     init {
         require(maxValue >= 0.0)
         require(maxFractionDigits >= 0)
         require(maxLength > 0)
     }
 
     val allowsDecimal: Boolean
         get() = maxFractionDigits > 0
 }
 
 internal sealed interface NumpadKey {
     data class Digit(val value: Int) : NumpadKey {
         init {
             require(value in 0..9)
         }
     }
 
     data object DecimalSeparator : NumpadKey
 
     data object Backspace : NumpadKey
 }
 
 internal fun TextFieldValue.applyNumpadKey(
     key: NumpadKey,
     spec: NumpadInputSpec,
 ): TextFieldValue {
     val selectionStart = min(selection.start, selection.end).coerceIn(0, text.length)
     val selectionEnd = max(selection.start, selection.end).coerceIn(0, text.length)
 
     val replacement =
         when (key) {
             is NumpadKey.Digit -> key.value.toString()
             NumpadKey.DecimalSeparator -> {
                 if (!spec.allowsDecimal) return this
                 spec.decimalSeparator.toString()
             }
             NumpadKey.Backspace -> ""
         }
 
     val replacementRange =
         when {
             key == NumpadKey.Backspace && selectionStart != selectionEnd -> selectionStart until selectionEnd
             key == NumpadKey.Backspace && selectionStart > 0 -> (selectionStart - 1) until selectionStart
             key == NumpadKey.Backspace -> return this
             key is NumpadKey.Digit && text == "0" && selectionStart == selectionEnd -> 0 until text.length
             else -> selectionStart until selectionEnd
         }
 
     val rawCandidate = text.replaceRange(replacementRange, replacement)
     val candidate =
         when {
             rawCandidate.isEmpty() -> "0"
             rawCandidate == spec.decimalSeparator.toString() -> "0$rawCandidate"
             else -> rawCandidate
         }
     val cursor =
         if (rawCandidate.isEmpty()) {
             1
         } else {
             replacementRange.first + replacement.length + if (candidate.length > rawCandidate.length) 1 else 0
         }.coerceIn(0, candidate.length)
 
     if (!candidate.isValidFor(spec)) return this
     return copy(text = candidate, selection = TextRange(cursor))
 }
 
 private fun String.isValidFor(spec: NumpadInputSpec): Boolean {
     if (isEmpty()) return false
     if (length > spec.maxLength) return false
     if (any { !it.isDigit() && it != spec.decimalSeparator }) return false
     if (count { it == spec.decimalSeparator } > 1) return false
 
     val separatorIndex = indexOf(spec.decimalSeparator)
     if (!spec.allowsDecimal && separatorIndex >= 0) return false
     if (separatorIndex >= 0 && length - separatorIndex - 1 > spec.maxFractionDigits) return false
 
     val parsedValue = replace(spec.decimalSeparator, '.').toDoubleOrNull() ?: return false
     return parsedValue <= spec.maxValue
 }