diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt index f7b919d6..7fa2e719 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt @@ -18,50 +18,20 @@ package com.sadellie.unitto.core.ui.common.textfield -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.layout.layout -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalTextInputService -import androidx.compose.ui.platform.LocalTextToolbar import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.Paragraph import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.createFontFamilyResolver import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.sp +import com.sadellie.unitto.core.ui.common.textfield.autosize.AutoSizeTextField import com.sadellie.unitto.core.ui.theme.LocalNumberTypography -import kotlin.math.ceil -import kotlin.math.roundToInt @Composable fun ExpressionTextField( @@ -104,17 +74,17 @@ fun ExpressionTextField( ) } - AutoSizableTextField( + AutoSizeTextField( modifier = modifier, value = value, - formattedValue = value.text.formatExpression(formatterSymbols), - textStyle = LocalNumberTypography.current.displayLarge.copy(color = textColor), - minRatio = minRatio, onValueChange = { onCursorChange(it.selection) }, - readOnly = readOnly, - visualTransformation = expressionTransformer, placeholder = placeholder, textToolbar = textToolbar, + readOnly = readOnly, + textStyle = LocalNumberTypography.current.displayLarge.copy(textColor), + visualTransformation = expressionTransformer, + cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant), + minRatio = minRatio ) } @@ -158,150 +128,18 @@ fun UnformattedTextField( } } - AutoSizableTextField( + AutoSizeTextField( modifier = modifier, value = value, + onValueChange = { onCursorChange(it.selection) }, + placeholder = placeholder, + textToolbar = textToolbar, + readOnly = readOnly, textStyle = LocalNumberTypography.current.displayLarge.copy(color = textColor), minRatio = minRatio, - onValueChange = { onCursorChange(it.selection) }, - readOnly = readOnly, - placeholder = placeholder, - textToolbar = textToolbar ) } -@Composable -private fun AutoSizableTextField( - modifier: Modifier = Modifier, - value: TextFieldValue, - formattedValue: String = value.text, - textStyle: TextStyle = TextStyle(), - scaleFactor: Float = 0.95f, - minRatio: Float = 1f, - onValueChange: (TextFieldValue) -> Unit, - readOnly: Boolean = false, - visualTransformation: VisualTransformation = VisualTransformation.None, - placeholder: String? = null, - textToolbar: UnittoTextToolbar -) { - val focusRequester = remember { FocusRequester() } - val density = LocalDensity.current - - val textValue = value.copy(value.text.take(2000)) - var nFontSize: TextUnit by remember { mutableStateOf(0.sp) } - var minFontSize: TextUnit - - CompositionLocalProvider( - LocalTextInputService provides null, - LocalTextToolbar provides textToolbar - ) { - val localTextToolbar = LocalTextToolbar.current - - BoxWithConstraints( - modifier = modifier, - contentAlignment = Alignment.BottomStart - ) { - with(density) { - // Cursor handle is not visible without this, 0.836f is the minimum required factor here - nFontSize = maxHeight.toSp() * 0.83f - minFontSize = nFontSize * minRatio - } - - // Modified: https://blog.canopas.com/autosizing-textfield-in-jetpack-compose-7a80f0270853 - val calculateParagraph = @Composable { - Paragraph( - text = formattedValue, - style = textStyle.copy(fontSize = nFontSize), - constraints = Constraints( - maxWidth = ceil(with(density) { maxWidth.toPx() }).toInt() - ), - density = density, - fontFamilyResolver = createFontFamilyResolver(LocalContext.current), - spanStyles = listOf(), - placeholders = listOf(), - maxLines = 1, - ellipsis = false - ) - } - - var intrinsics = calculateParagraph() - with(density) { - while ((intrinsics.maxIntrinsicWidth > maxWidth.toPx()) && nFontSize >= minFontSize) { - nFontSize *= scaleFactor - intrinsics = calculateParagraph() - } - } - - val nTextStyle = textStyle.copy( - // https://issuetracker.google.com/issues/266470454 - // textAlign = TextAlign.End, - fontSize = nFontSize - ) - var offset = Offset.Zero - - BasicTextField( - value = textValue, - onValueChange = { - localTextToolbar.showMenu(Rect(offset, 0f)) - localTextToolbar.hide() - onValueChange(it) - }, - modifier = Modifier - .focusRequester(focusRequester) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { - localTextToolbar.hide() - focusRequester.requestFocus() - onValueChange(value.copy(selection = TextRange.Zero)) - localTextToolbar.showMenu(Rect(offset, 0f)) - } - ) - .widthIn(max = with(density) { intrinsics.width.toDp() }) - .layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - // TextField size is changed with a delay (text jumps). Here we correct it. - layout(placeable.width, placeable.height) { - placeable.place( - x = (intrinsics.width - intrinsics.maxIntrinsicWidth) - .coerceAtLeast(0f) - .roundToInt(), - y = (placeable.height - intrinsics.height).roundToInt() - ) - } - } - .onGloballyPositioned { layoutCoords -> - offset = layoutCoords.positionInWindow() - }, - textStyle = nTextStyle, - cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant), - singleLine = true, - readOnly = readOnly, - visualTransformation = visualTransformation, - decorationBox = { innerTextField -> - if (textValue.text.isEmpty() and !placeholder.isNullOrEmpty()) { - Text( - text = placeholder!!, // It's not null, i swear - style = nTextStyle, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - - layout(placeable.width, placeable.height) { - placeable.place(x = -placeable.width, y = 0) - } - } - ) - } - - innerTextField() - } - ) - } - } -} - /** * Copy value to clipboard without grouping symbols. * diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/autosize/AutosizeTextField.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/autosize/AutosizeTextField.kt new file mode 100644 index 00000000..6c7558b9 --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/autosize/AutosizeTextField.kt @@ -0,0 +1,546 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2024 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 . + */ + +package com.sadellie.unitto.core.ui.common.textfield.autosize + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.InternalFoundationTextApi +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.TextDelegate +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalTextInputService +import androidx.compose.ui.platform.LocalTextToolbar +import androidx.compose.ui.platform.TextToolbar +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import com.sadellie.unitto.core.ui.common.textfield.autosize.SuggestedFontSizesStatus.Companion.suggestedFontSizesStatus +import kotlin.math.min + +/** + * Based on: https://gist.github.com/inidamleader/b594d35362ebcf3cedf81055df519300 + * + * @param value The [androidx.compose.ui.text.input.TextFieldValue] to be shown in the + * [BasicTextField]. + * @param onValueChange Called when the input service updates the values in [TextFieldValue]. + * @param placeholder Placeholder text, shown when [value] is empty. + * @param textToolbar [TextToolbar] with modified actions in menu. + * @param modifier optional [Modifier] for this text field. + * @param enabled controls the enabled state of the [BasicTextField]. When `false`, the text + * field will be neither editable nor focusable, the input of the text field will not be selectable + * @param readOnly controls the editable state of the [BasicTextField]. When `true`, the text + * field can not be modified, however, a user can focus it and copy text from it. Read-only text + * fields are usually used to display pre-filled forms that user can not edit + * @param textStyle Style configuration that applies at character level such as color, font etc. + * @param keyboardOptions software keyboard options that contains configuration such as + * [KeyboardType] and [ImeAction]. + * @param keyboardActions when the input service emits an IME action, the corresponding callback + * is called. Note that this IME action may be different from what you specified in + * [KeyboardOptions.imeAction]. + * @param singleLine when set to true, this text field becomes a single horizontally scrolling + * text field instead of wrapping onto multiple lines. The keyboard will be informed to not show + * the return key as the [ImeAction]. [maxLines] and [minLines] are ignored as both are + * automatically set to 1. + * @param maxLines the maximum height in terms of maximum number of visible lines. It is required + * that 1 <= [minLines] <= [maxLines]. This parameter is ignored when [singleLine] is true. + * @param minLines the minimum height in terms of minimum number of visible lines. It is required + * that 1 <= [minLines] <= [maxLines]. This parameter is ignored when [singleLine] is true. + * @param visualTransformation The visual transformation filter for changing the visual + * representation of the input. By default no visual transformation is applied. + * @param onTextLayout Callback that is executed when a new text layout is calculated. A + * [TextLayoutResult] object that callback provides contains paragraph information, size of the + * text, baselines and other details. The callback can be used to add additional decoration or + * functionality to the text. For example, to draw a cursor or selection around the text. + * @param interactionSource the [MutableInteractionSource] representing the stream of + * [Interaction]s for this TextField. You can create and pass in your own remembered + * [MutableInteractionSource] if you want to observe [Interaction]s and customize the + * appearance / behavior of this TextField in different [Interaction]s. + * @param cursorBrush [Brush] to paint cursor with. If [SolidColor] with [Color.Unspecified] + * provided, there will be no cursor drawn + * @param suggestedFontSizes The suggested font sizes to choose from (Should be sorted from smallest to largest, not empty and contains only sp text unit). + * @param suggestedFontSizesStatus Whether or not suggestedFontSizes is valid: not empty - contains oly sp text unit - sorted. + * You can check validity by invoking [List.suggestedFontSizesStatus] + * @param stepGranularityTextSize The step size for adjusting the text size. + * @param minTextSize The minimum text size allowed. + * @param maxTextSize The maximum text size allowed. + * @param minRatio How small font can be. Percentage from calculated max font size. + * @param lineSpacingRatio The ratio of line spacing to text size. + * @param alignment The alignment of the text within its container. + */ +@Composable +internal fun AutoSizeTextField( + modifier: Modifier = Modifier, + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + placeholder: String? = "", + textToolbar: TextToolbar, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = TextStyle.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = true, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + visualTransformation: VisualTransformation = VisualTransformation.None, + onTextLayout: (TextLayoutResult) -> Unit = {}, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + cursorBrush: Brush = SolidColor(Color.Black), + suggestedFontSizes: ImmutableWrapper> = emptyList().toImmutableWrapper(), + suggestedFontSizesStatus: SuggestedFontSizesStatus = suggestedFontSizes.value.suggestedFontSizesStatus, + stepGranularityTextSize: TextUnit = TextUnit.Unspecified, + minTextSize: TextUnit = TextUnit.Unspecified, + maxTextSize: TextUnit = TextUnit.Unspecified, + minRatio: Float = 1f, + lineSpacingRatio: Float = textStyle.lineHeight.value / textStyle.fontSize.value, + alignment: Alignment = Alignment.BottomEnd, +) = CompositionLocalProvider( + LocalTextInputService provides null, + LocalTextToolbar provides textToolbar +) { + val localTextToolbar = LocalTextToolbar.current + + AutoSizeTextField( + value = value, + onValueChange = { + localTextToolbar.showMenu(Rect(Offset.Zero, 0f)) + localTextToolbar.hide() + onValueChange(it) + }, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + visualTransformation = visualTransformation, + onTextLayout = onTextLayout, + interactionSource = interactionSource, + cursorBrush = cursorBrush, + decorationBox = { innerTextField, calculatedStyle -> + if (value.text.isEmpty() and !placeholder.isNullOrEmpty()) { + Text( + text = placeholder!!, + style = calculatedStyle().copy( + textAlign = TextAlign.End, + color = MaterialTheme.colorScheme.onSurface.copy(0.5f) + ) + ) + } + innerTextField() + }, + suggestedFontSizes = suggestedFontSizes, + suggestedFontSizesStatus = suggestedFontSizesStatus, + stepGranularityTextSize = stepGranularityTextSize, + minTextSize = minTextSize, + maxTextSize = maxTextSize, + minRatio = minRatio, + lineSpacingRatio = lineSpacingRatio, + alignment = alignment, + ) +} + +/** + * Based on: https://gist.github.com/inidamleader/b594d35362ebcf3cedf81055df519300 + * + * @param value The [androidx.compose.ui.text.input.TextFieldValue] to be shown in the + * [BasicTextField]. + * @param onValueChange Called when the input service updates the values in [TextFieldValue]. + * @param modifier optional [Modifier] for this text field. + * @param enabled controls the enabled state of the [BasicTextField]. When `false`, the text + * field will be neither editable nor focusable, the input of the text field will not be selectable + * @param readOnly controls the editable state of the [BasicTextField]. When `true`, the text + * field can not be modified, however, a user can focus it and copy text from it. Read-only text + * fields are usually used to display pre-filled forms that user can not edit + * @param textStyle Style configuration that applies at character level such as color, font etc. + * @param keyboardOptions software keyboard options that contains configuration such as + * [KeyboardType] and [ImeAction]. + * @param keyboardActions when the input service emits an IME action, the corresponding callback + * is called. Note that this IME action may be different from what you specified in + * [KeyboardOptions.imeAction]. + * @param singleLine when set to true, this text field becomes a single horizontally scrolling + * text field instead of wrapping onto multiple lines. The keyboard will be informed to not show + * the return key as the [ImeAction]. [maxLines] and [minLines] are ignored as both are + * automatically set to 1. + * @param maxLines the maximum height in terms of maximum number of visible lines. It is required + * that 1 <= [minLines] <= [maxLines]. This parameter is ignored when [singleLine] is true. + * @param minLines the minimum height in terms of minimum number of visible lines. It is required + * that 1 <= [minLines] <= [maxLines]. This parameter is ignored when [singleLine] is true. + * @param visualTransformation The visual transformation filter for changing the visual + * representation of the input. By default no visual transformation is applied. + * @param onTextLayout Callback that is executed when a new text layout is calculated. A + * [TextLayoutResult] object that callback provides contains paragraph information, size of the + * text, baselines and other details. The callback can be used to add additional decoration or + * functionality to the text. For example, to draw a cursor or selection around the text. + * @param interactionSource the [MutableInteractionSource] representing the stream of + * [Interaction]s for this TextField. You can create and pass in your own remembered + * [MutableInteractionSource] if you want to observe [Interaction]s and customize the + * appearance / behavior of this TextField in different [Interaction]s. + * @param cursorBrush [Brush] to paint cursor with. If [SolidColor] with [Color.Unspecified] + * provided, there will be no cursor drawn + * @param decorationBox Composable lambda that allows to add decorations around text field, such + * as icon, placeholder, helper messages or similar, and automatically increase the hit target area + * of the text field. To allow you to control the placement of the inner text field relative to your + * decorations, the text field implementation will pass in a framework-controlled composable + * parameter "innerTextField" to the decorationBox lambda you provide. You must call + * innerTextField exactly once. + * @param suggestedFontSizes The suggested font sizes to choose from (Should be sorted from smallest to largest, not empty and contains only sp text unit). + * @param suggestedFontSizesStatus Whether or not suggestedFontSizes is valid: not empty - contains oly sp text unit - sorted. + * You can check validity by invoking [List.suggestedFontSizesStatus] + * @param stepGranularityTextSize The step size for adjusting the text size. + * @param minTextSize The minimum text size allowed. + * @param maxTextSize The maximum text size allowed. + * @param minRatio How small font can be. Percentage from calculated max font size. + * @param lineSpacingRatio The ratio of line spacing to text size. + * @param alignment The alignment of the text within its container. + */ +@Composable +private fun AutoSizeTextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = TextStyle.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + visualTransformation: VisualTransformation = VisualTransformation.None, + onTextLayout: (TextLayoutResult) -> Unit = {}, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + cursorBrush: Brush = SolidColor(Color.Black), + decorationBox: @Composable ( + innerTextField: @Composable () -> Unit, + textStyle: () -> TextStyle, // Passing lambda for performance + ) -> Unit = { _, _ -> }, + suggestedFontSizes: ImmutableWrapper> = emptyList().toImmutableWrapper(), + suggestedFontSizesStatus: SuggestedFontSizesStatus = suggestedFontSizes.value.suggestedFontSizesStatus, + stepGranularityTextSize: TextUnit = TextUnit.Unspecified, + minTextSize: TextUnit = TextUnit.Unspecified, + maxTextSize: TextUnit = TextUnit.Unspecified, + minRatio: Float = 1f, + lineSpacingRatio: Float = textStyle.lineHeight.value / textStyle.fontSize.value, + alignment: Alignment = Alignment.TopStart, +) { + val focusRequester = remember { FocusRequester() } + val density = LocalDensity.current + val localTextToolbar = LocalTextToolbar.current + // Change font scale to 1F + CompositionLocalProvider( + LocalDensity provides Density(density = density.density, fontScale = 1F) + ) { + BoxWithConstraints( + modifier = modifier, + contentAlignment = alignment, + ) { + val combinedTextStyle = LocalTextStyle.current + textStyle.copy( + textAlign = when (alignment) { + Alignment.TopStart, Alignment.CenterStart, Alignment.BottomStart -> TextAlign.Start + Alignment.TopCenter, Alignment.Center, Alignment.BottomCenter -> TextAlign.Center + Alignment.TopEnd, Alignment.CenterEnd, Alignment.BottomEnd -> TextAlign.End + else -> TextAlign.Unspecified + }, + ) + + val candidateFontSizes = + if (suggestedFontSizesStatus == SuggestedFontSizesStatus.VALID) + suggestedFontSizes.value + else + remember(suggestedFontSizes) { + suggestedFontSizes.value + .filter { it.isSp } + .takeIf { it.isNotEmpty() } + ?.sortedBy { it.value } + } ?: rememberCandidateFontSizes( + density = density, + maxDpSize = DpSize(maxWidth, maxHeight), + maxTextSize = maxTextSize, + minTextSize = minTextSize, + stepGranularityTextSize = stepGranularityTextSize, + ) + + val layoutDirection = LocalLayoutDirection.current + val currentDensity = LocalDensity.current + val fontFamilyResolver = LocalFontFamilyResolver.current + val coercedLineSpacingRatio = lineSpacingRatio.coerceAtLeast(1f).takeIf { !lineSpacingRatio.isNaN() } ?: 1f + if (candidateFontSizes.isEmpty()) return@BoxWithConstraints + val (electedIndex, _) = candidateFontSizes.findElectedIndex { + calculateLayout( + text = visualTransformation.filter(value.annotatedString).text, + textStyle = combinedTextStyle.copy( + fontSize = it, + lineHeight = it * coercedLineSpacingRatio, + ), + maxLines = maxLines, + minLines = minLines, + softWrap = true, + layoutDirection = layoutDirection, + density = currentDensity, + fontFamilyResolver = fontFamilyResolver, + ) + } + val minFontSize = candidateFontSizes.maxBy { it.value } * minRatio + val electedFontSize = candidateFontSizes[electedIndex] + val fSize: TextUnit = listOf(minFontSize, electedFontSize).maxBy { it.value } + val calculatedTextStyle = combinedTextStyle.copy( + fontSize = fSize, + lineHeight = fSize * coercedLineSpacingRatio + ) + + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { + localTextToolbar.hide() + focusRequester.requestFocus() + onValueChange(value.copy(selection = TextRange.Zero)) + localTextToolbar.showMenu(Rect(Offset.Zero, 0f)) + } + ), + enabled = enabled, + readOnly = readOnly, + textStyle = calculatedTextStyle, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + visualTransformation = visualTransformation, + onTextLayout = onTextLayout, + interactionSource = interactionSource, + cursorBrush = cursorBrush, + decorationBox = { innerTextField -> + decorationBox(innerTextField) { + combinedTextStyle.copy( + fontSize = electedFontSize, + lineHeight = electedFontSize * coercedLineSpacingRatio + ) + } + }, + ) + } + } +} + +@Composable +private fun rememberCandidateFontSizes( + density: Density, + maxDpSize: DpSize, + minTextSize: TextUnit = TextUnit.Unspecified, + maxTextSize: TextUnit = TextUnit.Unspecified, + stepGranularityTextSize: TextUnit = TextUnit.Unspecified, +): List { + val max = remember(maxTextSize, maxDpSize, density) { + val size = density.toIntSize(maxDpSize) + min(size.width, size.height).let { max -> + maxTextSize + .takeIf { it.isSp } + ?.let { density.roundToPx(it) } + ?.coerceIn(range = 0..max) + ?: max + } + } + + val min = remember(minTextSize, max, density) { + minTextSize + .takeIf { it.isSp } + ?.let { density.roundToPx(it) } + ?.coerceIn(range = 0..max) + ?: 0 + } + + val step = remember(stepGranularityTextSize, min, max, density) { + stepGranularityTextSize + .takeIf { it.isSp } + ?.let { density.roundToPx(it) } + ?.coerceAtLeast(minimumValue = 1) + ?: 1 + } + + return remember(max, min, step) { + buildList { + var current = min + while (current <= max) { + add(density.toSp(current)) + current += step + } + } + } +} + +@OptIn(InternalFoundationTextApi::class) +private fun BoxWithConstraintsScope.calculateLayout( + text: AnnotatedString, + textStyle: TextStyle, + maxLines: Int, + minLines: Int, + softWrap: Boolean, + layoutDirection: LayoutDirection, + density: Density, + fontFamilyResolver: FontFamily.Resolver, +) = TextDelegate( + text = text, + style = textStyle, + maxLines = maxLines, + minLines = minLines, + softWrap = softWrap, + overflow = TextOverflow.Clip, + density = density, + fontFamilyResolver = fontFamilyResolver, +).layout( + constraints = constraints, + layoutDirection = layoutDirection, +) + +// This function works by using a binary search algorithm +fun List.findElectedIndex( + shouldMoveBackward: (TextUnit) -> TextLayoutResult +): Pair { + val elements = this + var low = 0 + var high = elements.lastIndex + var textLayoutResult: TextLayoutResult? = null + while (low <= high) { + val mid = low + (high - low) / 2 + textLayoutResult = shouldMoveBackward(elements[mid]) + if (textLayoutResult.hasVisualOverflow) + high = mid - 1 + else + low = mid + 1 + } + return high.coerceIn(elements.indices) to textLayoutResult +} + +enum class SuggestedFontSizesStatus { + VALID, INVALID; + + companion object { + val List.suggestedFontSizesStatus + get() = if (isNotEmpty() && all { it.isSp } && this.sortedBy { it.value } == this) + VALID + else + INVALID + } +} + +@Preview(widthDp = 200, heightDp = 100) +@Preview(widthDp = 200, heightDp = 30) +@Preview(widthDp = 60, heightDp = 30) +@Composable +fun PreviewAutoSizeTextWithMaxLinesSetToIntMaxValue() { + MaterialTheme { + Surface(color = MaterialTheme.colorScheme.primary) { + AutoSizeTextField( + value = TextFieldValue("This is a bunch of text that will be auto sized"), + onValueChange = {}, + modifier = Modifier.fillMaxSize(), + alignment = Alignment.CenterStart, + textStyle = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Preview(widthDp = 200, heightDp = 100) +@Preview(widthDp = 200, heightDp = 30) +@Preview(widthDp = 60, heightDp = 30) +@Composable +fun PreviewAutoSizeTextWithMinSizeSetTo14() { + MaterialTheme { + Surface(color = MaterialTheme.colorScheme.secondary) { + AutoSizeTextField( + value = TextFieldValue("This is a bunch of text that will be auto sized"), + onValueChange = {}, + modifier = Modifier.fillMaxSize(), + minTextSize = 14.sp, + alignment = Alignment.CenterStart, + textStyle = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Preview(widthDp = 200, heightDp = 100) +@Preview(widthDp = 200, heightDp = 30) +@Preview(widthDp = 60, heightDp = 30) +@Composable +fun PreviewAutoSizeTextWithMaxLinesSetToOne() { + MaterialTheme { + Surface(color = MaterialTheme.colorScheme.tertiary) { + AutoSizeTextField( + value = TextFieldValue("This is a bunch of text that will be auto sized"), + onValueChange = {}, + modifier = Modifier.fillMaxSize(), + alignment = Alignment.Center, + maxLines = 1, + textStyle = MaterialTheme.typography.bodyMedium + ) + } + } +} diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/autosize/DensityExt.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/autosize/DensityExt.kt new file mode 100644 index 00000000..e2a9cd24 --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/autosize/DensityExt.kt @@ -0,0 +1,31 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2024 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 . + */ + +package com.sadellie.unitto.core.ui.common.textfield.autosize + +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.isSpecified + +internal fun Density.roundToPx(sp: TextUnit): Int = sp.roundToPx() +internal fun Density.toSp(px: Int): TextUnit = px.toSp() +internal fun Density.toIntSize(dpSize: DpSize): IntSize = + if (dpSize.isSpecified) IntSize(dpSize.width.roundToPx(), dpSize.height.roundToPx()) + else IntSize.Zero diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/autosize/ImmutableWrapper.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/autosize/ImmutableWrapper.kt new file mode 100644 index 00000000..cc91f26e --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/autosize/ImmutableWrapper.kt @@ -0,0 +1,29 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2024 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 . + */ + +package com.sadellie.unitto.core.ui.common.textfield.autosize + +import androidx.compose.runtime.Immutable +import kotlin.reflect.KProperty + +@Immutable +internal data class ImmutableWrapper(val value: T) + +internal fun T.toImmutableWrapper() = ImmutableWrapper(this) + +internal operator fun ImmutableWrapper.getValue(thisRef: Any?, property: KProperty<*>) = value diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt index 2aae26d9..d49cbe66 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import com.sadellie.unitto.core.ui.WindowHeightSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -42,6 +41,7 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.sadellie.unitto.core.ui.LocalWindowSize +import com.sadellie.unitto.core.ui.WindowHeightSizeClass import com.sadellie.unitto.core.ui.common.textfield.ExpressionTextField import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import com.sadellie.unitto.core.ui.common.textfield.UnformattedTextField @@ -104,7 +104,7 @@ fun TextBox( .fillMaxWidth() .padding(horizontal = 8.dp), value = outputTF, - minRatio = 1f, + minRatio = 0.8f, onCursorChange = { outputTF = outputTF.copy(selection = it) }, formatterSymbols = formatterSymbols, textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f), @@ -122,7 +122,7 @@ fun TextBox( .fillMaxWidth() .padding(horizontal = 8.dp), value = outputTF, - minRatio = 1f, + minRatio = 0.8f, onCursorChange = { outputTF = outputTF.copy(selection = it) }, formatterSymbols = formatterSymbols, textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f),