From 3ea6a08ccc370b8a29e385c05e417f325ea54169 Mon Sep 17 00:00:00 2001 From: Aleksandras Kostarevas Date: Thu, 18 Apr 2024 15:20:08 -0500 Subject: [PATCH] Allow re-arranging actionbar --- java/res/values/strings-uix.xml | 1 + .../futo/inputmethod/latin/uix/ActionBar.kt | 213 +++++++++++++----- .../futo/inputmethod/latin/uix/UixManager.kt | 7 +- .../inputmethod/latin/uix/actions/Registry.kt | 73 ++++++ .../latin/uix/actions/VoiceInputAction.kt | 2 +- .../inputmethod/latin/uix/settings/Hooks.kt | 22 ++ 6 files changed, 259 insertions(+), 59 deletions(-) create mode 100644 java/src/org/futo/inputmethod/latin/uix/actions/Registry.kt diff --git a/java/res/values/strings-uix.xml b/java/res/values/strings-uix.xml index 0656f823b..ea21613ed 100644 --- a/java/res/values/strings-uix.xml +++ b/java/res/values/strings-uix.xml @@ -1,6 +1,7 @@ Voice Input + Voice Input (System) Theme Switcher Emojis Paste from Clipboard diff --git a/java/src/org/futo/inputmethod/latin/uix/ActionBar.kt b/java/src/org/futo/inputmethod/latin/uix/ActionBar.kt index 8b8480f7c..788ba5ff2 100644 --- a/java/src/org/futo/inputmethod/latin/uix/ActionBar.kt +++ b/java/src/org/futo/inputmethod/latin/uix/ActionBar.kt @@ -4,10 +4,12 @@ import android.content.Context import android.os.Build import android.view.View import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -16,8 +18,10 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ColorScheme @@ -34,6 +38,7 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.Center @@ -47,9 +52,15 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -63,9 +74,12 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import kotlinx.coroutines.runBlocking import org.futo.inputmethod.latin.R import org.futo.inputmethod.latin.SuggestedWords import org.futo.inputmethod.latin.SuggestedWords.SuggestedWordInfo @@ -73,16 +87,11 @@ import org.futo.inputmethod.latin.SuggestedWords.SuggestedWordInfo.KIND_EMOJI_SU import org.futo.inputmethod.latin.SuggestedWords.SuggestedWordInfo.KIND_TYPED import org.futo.inputmethod.latin.common.Constants import org.futo.inputmethod.latin.suggestions.SuggestionStripView -import org.futo.inputmethod.latin.uix.actions.ClipboardAction -import org.futo.inputmethod.latin.uix.actions.EmojiAction -import org.futo.inputmethod.latin.uix.actions.RedoAction -import org.futo.inputmethod.latin.uix.actions.SettingsAction -import org.futo.inputmethod.latin.uix.actions.SystemVoiceInputAction -import org.futo.inputmethod.latin.uix.actions.TextEditAction -import org.futo.inputmethod.latin.uix.actions.ThemeAction -import org.futo.inputmethod.latin.uix.actions.UndoAction +import org.futo.inputmethod.latin.uix.actions.ActionRegistry +import org.futo.inputmethod.latin.uix.actions.DefaultActionsString +import org.futo.inputmethod.latin.uix.actions.ExpandableActionItems import org.futo.inputmethod.latin.uix.actions.VoiceInputAction -import org.futo.inputmethod.latin.uix.settings.useDataStore +import org.futo.inputmethod.latin.uix.settings.useDataStoreValueNullable import org.futo.inputmethod.latin.uix.theme.DarkColorScheme import org.futo.inputmethod.latin.uix.theme.Typography import org.futo.inputmethod.latin.uix.theme.UixThemeWrapper @@ -244,7 +253,9 @@ fun RowScope.SuggestionItem(words: SuggestedWords, idx: Int, isPrimary: Boolean, ) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { if (word != null) { - AutoFitText(word, style = textStyle, modifier = textModifier.align(Center).padding(2.dp)) + AutoFitText(word, style = textStyle, modifier = textModifier + .align(Center) + .padding(2.dp)) } } } @@ -331,22 +342,100 @@ fun RowScope.SuggestionItems(words: SuggestedWords, onClick: (i: Int) -> Unit, o } } +@OptIn(ExperimentalFoundationApi::class) @Composable -fun ActionItem(action: Action, onSelect: (Action) -> Unit) { +fun LazyItemScope.ActionItem(idx: Int, action: Action, onSelect: (Action) -> Unit) { + val dragging = remember { mutableStateOf(false) } + val offsetX = remember { mutableFloatStateOf(0.0f) } + val offsetY = remember { mutableFloatStateOf(0.0f) } + val haptic = LocalHapticFeedback.current + + val width = 64.dp + val widthPx = with(LocalDensity.current) { + width.toPx() + } + + val context = LocalContext.current + + val isWindowAction = action.windowImpl != null + val col = MaterialTheme.colorScheme.secondaryContainer - val contentCol = MaterialTheme.colorScheme.onSecondaryContainer - IconButton(onClick = { onSelect(action) }, modifier = Modifier + + val modifier = Modifier + .offset { IntOffset(offsetX.floatValue.roundToInt(), offsetY.floatValue.roundToInt()) } + .pointerInput(Unit) { + detectDragGesturesAfterLongPress(onDragStart = { + dragging.value = true + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + offsetY.floatValue = -widthPx / 8.0f + }, onDragCancel = { + dragging.value = false + offsetX.floatValue = 0.0f + offsetY.floatValue = 0.0f + }, onDragEnd = { + dragging.value = false + offsetX.floatValue = 0.0f + offsetY.floatValue = 0.0f + }, onDrag = { change, dragAmount -> + change.consume() + offsetX.floatValue += dragAmount.x + + if (offsetX.floatValue >= widthPx) { + offsetX.floatValue -= widthPx + runBlocking { + context.setSetting( + ExpandableActionItems, ActionRegistry.moveElement( + context.getSetting(ExpandableActionItems, DefaultActionsString), + action, + 1 + ) + ) + } + } else if (offsetX.floatValue <= -widthPx) { + offsetX.floatValue += widthPx + runBlocking { + context.setSetting( + ExpandableActionItems, ActionRegistry.moveElement( + context.getSetting(ExpandableActionItems, DefaultActionsString), + action, + -1 + ) + ) + } + } + }) + } + .width(width) + .let { + if (!dragging.value) { + it.animateItemPlacement() + } else { + it.zIndex(10.0f) + } + } .drawBehind { val radius = size.height / 4.0f drawRoundRect( col, topLeft = Offset(size.width * 0.1f, size.height * 0.05f), size = Size(size.width * 0.8f, size.height * 0.9f), - cornerRadius = CornerRadius(radius, radius) + cornerRadius = CornerRadius(radius, radius), + style = if(isWindowAction) { + Fill + } else { + Stroke(width = 4.0f) + } ) } - .width(50.dp) - .fillMaxHeight(), + .fillMaxHeight() + + val contentCol = if(isWindowAction) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onBackground + } + + IconButton(onClick = { onSelect(action) }, modifier = modifier, colors = IconButtonDefaults.iconButtonColors(contentColor = contentCol) ) { Icon( @@ -371,17 +460,18 @@ fun ActionItemSmall(action: Action, onSelect: (Action) -> Unit) { } @Composable -fun RowScope.ActionItems(onSelect: (Action) -> Unit) { - val systemVoiceInput = useDataStore(key = USE_SYSTEM_VOICE_INPUT.key, default = USE_SYSTEM_VOICE_INPUT.default) +fun ActionItems(onSelect: (Action) -> Unit) { + val actions = useDataStoreValueNullable(key = ExpandableActionItems, default = DefaultActionsString) - ActionItem(SettingsAction, onSelect) - ActionItem(EmojiAction, onSelect) - ActionItem(if(systemVoiceInput.value) { SystemVoiceInputAction } else { VoiceInputAction }, onSelect) - ActionItem(ThemeAction, onSelect) - ActionItem(UndoAction, onSelect) - ActionItem(RedoAction, onSelect) - ActionItem(ClipboardAction, onSelect) - ActionItem(TextEditAction, onSelect) + if(actions != null) { + val actionItems = ActionRegistry.stringToActions(actions) + + LazyRow { + items(actionItems.size, key = { actionItems[it].name }) { + ActionItem(it, actionItems[it], onSelect) + } + } + } } @@ -389,12 +479,6 @@ fun RowScope.ActionItems(onSelect: (Action) -> Unit) { fun ExpandActionsButton(isActionsOpen: Boolean, onClick: () -> Unit) { val moreActionsColor = MaterialTheme.colorScheme.primary - val moreActionsFill = if(isActionsOpen) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.background - } - val actionsContent = if(isActionsOpen) { MaterialTheme.colorScheme.onPrimary } else { @@ -414,8 +498,15 @@ fun ExpandActionsButton(isActionsOpen: Boolean, onClick: () -> Unit) { ) .fillMaxHeight() .drawBehind { - drawCircle(color = moreActionsColor, radius = size.width / 3.0f + 1.0f) - drawCircle(color = moreActionsFill, radius = size.width / 3.0f - 2.0f) + drawCircle( + color = moreActionsColor, + radius = size.width / 3.0f + 1.0f, + style = if(!isActionsOpen) { + Stroke(3.0f) + } else { + Fill + } + ) }, colors = IconButtonDefaults.iconButtonColors(contentColor = actionsContent) @@ -479,7 +570,11 @@ fun ActionBar( val view = LocalView.current val context = LocalContext.current val isActionsOpen = remember { mutableStateOf(forceOpenActionsInitially) } - val systemVoiceInput = useDataStore(key = USE_SYSTEM_VOICE_INPUT.key, default = USE_SYSTEM_VOICE_INPUT.default) + + val activateActionWithHaptic: (Action) -> Unit = { + keyboardManagerForAction?.performHapticAndAudioFeedback(Constants.CODE_TAB, view) + onActionActivated(it) + } Surface(modifier = Modifier .fillMaxWidth() @@ -498,30 +593,34 @@ fun ActionBar( ImportantNoticeView(importantNotice) }else { - if (isActionsOpen.value) { - LazyRow { - item { - ActionItems { - keyboardManagerForAction?.performHapticAndAudioFeedback(Constants.CODE_TAB, view) - onActionActivated(it) - } - } - } - } else if (inlineSuggestions.isNotEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - InlineSuggestions(inlineSuggestions) - } else if (words != null) { - SuggestionItems(words, onClick = { - suggestionStripListener.pickSuggestionManually( - words.getInfo(it) - ) - keyboardManagerForAction?.performHapticAndAudioFeedback(Constants.CODE_TAB, view) - }, onLongClick = { suggestionStripListener.requestForgetWord(words.getInfo(it)) }) - } else { - Spacer(modifier = Modifier.weight(1.0f)) + AnimatedVisibility(isActionsOpen.value) { + ActionItems(activateActionWithHaptic) } - if (!isActionsOpen.value) { - ActionItemSmall(if(systemVoiceInput.value) { SystemVoiceInputAction } else { VoiceInputAction }, onActionActivated) + if(!isActionsOpen.value) { + if (inlineSuggestions.isNotEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + InlineSuggestions(inlineSuggestions) + } else if (words != null) { + SuggestionItems( + words, + onClick = { + suggestionStripListener.pickSuggestionManually( + words.getInfo(it) + ) + keyboardManagerForAction?.performHapticAndAudioFeedback( + Constants.CODE_TAB, + view + ) + }, + onLongClick = { + suggestionStripListener.requestForgetWord( + words.getInfo(it) + ) + }) + } else { + Spacer(modifier = Modifier.weight(1.0f)) + } + ActionItemSmall(VoiceInputAction, activateActionWithHaptic) } } } diff --git a/java/src/org/futo/inputmethod/latin/uix/UixManager.kt b/java/src/org/futo/inputmethod/latin/uix/UixManager.kt index 6e322833b..5932ec948 100644 --- a/java/src/org/futo/inputmethod/latin/uix/UixManager.kt +++ b/java/src/org/futo/inputmethod/latin/uix/UixManager.kt @@ -53,6 +53,7 @@ import org.futo.inputmethod.latin.SuggestedWords.SuggestedWordInfo import org.futo.inputmethod.latin.common.Constants import org.futo.inputmethod.latin.inputlogic.InputLogic import org.futo.inputmethod.latin.suggestions.SuggestionStripView +import org.futo.inputmethod.latin.uix.actions.ActionRegistry import org.futo.inputmethod.latin.uix.actions.EmojiAction import org.futo.inputmethod.latin.uix.settings.SettingsActivity import org.futo.inputmethod.latin.uix.theme.ThemeOption @@ -202,9 +203,13 @@ class UixManager(private val latinIME: LatinIME) { val isMainKeyboardHidden get() = mainKeyboardHidden - private fun onActionActivated(action: Action) { + private fun onActionActivated(rawAction: Action) { latinIME.inputLogic.finishInput() + val action = runBlocking { + ActionRegistry.getActionOverride(latinIME, rawAction) + } + if (action.windowImpl != null) { enterActionWindowView(action) } else if (action.simplePressImpl != null) { diff --git a/java/src/org/futo/inputmethod/latin/uix/actions/Registry.kt b/java/src/org/futo/inputmethod/latin/uix/actions/Registry.kt new file mode 100644 index 000000000..07e46d82c --- /dev/null +++ b/java/src/org/futo/inputmethod/latin/uix/actions/Registry.kt @@ -0,0 +1,73 @@ +package org.futo.inputmethod.latin.uix.actions + +import android.content.Context +import androidx.datastore.preferences.core.stringPreferencesKey +import org.futo.inputmethod.latin.uix.Action +import org.futo.inputmethod.latin.uix.USE_SYSTEM_VOICE_INPUT +import org.futo.inputmethod.latin.uix.getSetting + +val ExpandableActionItems = stringPreferencesKey("expandableActions") +val PinnedActionItems = stringPreferencesKey("pinnedActions") + +// Note: indices must stay stable +val AllActions = listOf( + EmojiAction, + SettingsAction, + ClipboardAction, + TextEditAction, + ThemeAction, + UndoAction, + RedoAction, + VoiceInputAction, + SystemVoiceInputAction, +) + + +object ActionRegistry { + fun stringToActions(string: String): List { + return string.split(",").mapNotNull { idx -> + idx.toIntOrNull()?.let { AllActions.getOrNull(it) } + } + } + + fun actionsToString(actions: List): String { + return actions.map { AllActions.indexOf(it) }.joinToString(separator = ",") + } + + fun moveElement(string: String, action: Action, direction: Int): String { + val actions = stringToActions(string) + val index = actions.indexOf(action) + val filtered = actions.filter { it != action }.toMutableList() + filtered.add((index + direction).coerceIn(0 .. filtered.size), action) + return actionsToString(filtered) + } + + suspend fun getActionOverride(context: Context, action: Action): Action { + return if(action == VoiceInputAction || action == SystemVoiceInputAction) { + val useSystemVoiceInput = context.getSetting(USE_SYSTEM_VOICE_INPUT) + if(useSystemVoiceInput) { + SystemVoiceInputAction + } else { + VoiceInputAction + } + } else { + action + } + } +} + +val DefaultActions = listOf( + EmojiAction, + SettingsAction, + TextEditAction, + ThemeAction, + ClipboardAction, + UndoAction, + RedoAction, +) + +val DefaultActionsString = ActionRegistry.actionsToString(DefaultActions) + + +val DefaultPinnedActions = listOf(VoiceInputAction) +val DefaultPinnedActionsString = ActionRegistry.actionsToString(DefaultPinnedActions) \ No newline at end of file diff --git a/java/src/org/futo/inputmethod/latin/uix/actions/VoiceInputAction.kt b/java/src/org/futo/inputmethod/latin/uix/actions/VoiceInputAction.kt index bb54455cb..b7f6ef0cf 100644 --- a/java/src/org/futo/inputmethod/latin/uix/actions/VoiceInputAction.kt +++ b/java/src/org/futo/inputmethod/latin/uix/actions/VoiceInputAction.kt @@ -52,7 +52,7 @@ import java.util.Locale val SystemVoiceInputAction = Action( icon = R.drawable.mic_fill, - name = R.string.voice_input_action_title, + name = R.string.system_voice_input_action_title, simplePressImpl = { it, _ -> it.triggerSystemVoiceInput() }, diff --git a/java/src/org/futo/inputmethod/latin/uix/settings/Hooks.kt b/java/src/org/futo/inputmethod/latin/uix/settings/Hooks.kt index 22799f06e..26bac9fd9 100644 --- a/java/src/org/futo/inputmethod/latin/uix/settings/Hooks.kt +++ b/java/src/org/futo/inputmethod/latin/uix/settings/Hooks.kt @@ -22,10 +22,32 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.futo.inputmethod.latin.uix.dataStore +import org.futo.inputmethod.latin.uix.getSetting data class DataStoreItem(val value: T, val setValue: (T) -> Job) + +@Composable +fun useDataStoreValueNullable(key: Preferences.Key, default: T): T? { + val context = LocalContext.current + + val initialValue = remember { + runBlocking { + context.getSetting(key, default) + } + } + + val valueFlow: Flow = remember { + context.dataStore.data.map { preferences -> + preferences[key] ?: default + } + } + + return valueFlow.collectAsState(initial = initialValue).value +} + @Composable fun useDataStore(key: Preferences.Key, default: T): DataStoreItem { val context = LocalContext.current