Allow re-arranging actionbar

This commit is contained in:
Aleksandras Kostarevas 2024-04-18 15:20:08 -05:00
parent d2de3dee38
commit 3ea6a08ccc
6 changed files with 259 additions and 59 deletions

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="voice_input_action_title">Voice Input</string>
<string name="system_voice_input_action_title">Voice Input (System)</string>
<string name="theme_switcher_action_title">Theme Switcher</string>
<string name="emoji_action_title">Emojis</string>
<string name="clipboard_action_title">Paste from Clipboard</string>

View File

@ -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)
}
}
}

View File

@ -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) {

View File

@ -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<Action> {
return string.split(",").mapNotNull { idx ->
idx.toIntOrNull()?.let { AllActions.getOrNull(it) }
}
}
fun actionsToString(actions: List<Action>): 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)

View File

@ -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()
},

View File

@ -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<T>(val value: T, val setValue: (T) -> Job)
@Composable
fun <T> useDataStoreValueNullable(key: Preferences.Key<T>, default: T): T? {
val context = LocalContext.current
val initialValue = remember {
runBlocking {
context.getSetting(key, default)
}
}
val valueFlow: Flow<T> = remember {
context.dataStore.data.map { preferences ->
preferences[key] ?: default
}
}
return valueFlow.collectAsState(initial = initialValue).value
}
@Composable
fun <T> useDataStore(key: Preferences.Key<T>, default: T): DataStoreItem<T> {
val context = LocalContext.current