Skip to content
Snippets Groups Projects
ActionBar.kt 23.4 KiB
Newer Older
package org.futo.inputmethod.latin.uix

import android.view.View
import androidx.annotation.RequiresApi
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
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.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.CornerRadius
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.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
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.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.futo.inputmethod.latin.R
import org.futo.inputmethod.latin.SuggestedWords
import org.futo.inputmethod.latin.SuggestedWords.SuggestedWordInfo
import org.futo.inputmethod.latin.SuggestedWords.SuggestedWordInfo.KIND_TYPED
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.TextEditAction
import org.futo.inputmethod.latin.uix.actions.ThemeAction
import org.futo.inputmethod.latin.uix.actions.UndoAction
import org.futo.inputmethod.latin.uix.actions.VoiceInputAction
import org.futo.inputmethod.latin.uix.theme.DarkColorScheme
import org.futo.inputmethod.latin.uix.theme.Typography
import org.futo.inputmethod.latin.uix.theme.UixThemeWrapper
import java.lang.Integer.min
import kotlin.math.ceil
import kotlin.math.roundToInt

/*
 * The UIX Action Bar is intended to replace the previous top bar of the AOSP keyboard.
 * Its goal is to function similar to the old top bar by showing predictions, but also modernize
 * it with actions and new features.
 *
 * Example bar:
 * [>] word1 | word2 | word3 [mic]
 *
 * The [>] button expands the action bar, replacing word predictions with actions the user can take.
 * Actions have little icons which perform an action. Some examples:
 * - Microphone: opens the voice input menu
 * - Undo/Redo
 * - Text editing: switches to the text editing menu
 * - Settings: opens the keyboard settings menu
 * - Report problem: opens the report menu
 *
 * Generally there are a few kinds of actions:
 * - Take an action on the text being typed (undo/redo)
 * - Switch from the keyboard UI to something else (voice input, text editing)
 * - Open an app (settings, report)
 *
 * The UIX effort is to modernize the AOSP Keyboard by replacing and extending
 * parts of it with UI written in Android Compose, while keeping most of the
 * battle-tested original keyboard code the same
 *
 * TODO: Will need to make RTL languages work
 */

interface ImportantNotice {
    @Composable fun getText(): String
    fun onDismiss(context: Context)
    fun onOpen(context: Context)
}


val suggestionStylePrimary = TextStyle(
    fontFamily = FontFamily.SansSerif,
    fontWeight = FontWeight.Medium,
    fontSize = 18.sp,
    lineHeight = 26.sp,
    letterSpacing = 0.5.sp,
    //textAlign = TextAlign.Center
)

val suggestionStyleAlternative = TextStyle(
    fontFamily = FontFamily.SansSerif,
    fontWeight = FontWeight.Normal,
    fontSize = 18.sp,
    lineHeight = 26.sp,
    letterSpacing = 0.5.sp,
    //textAlign = TextAlign.Center
)


// Automatically try to fit the given text to the available space in one line.
// If text is too long, the text gets scaled horizontally to fit.
// TODO: Could also put ellipsis in the middle
@OptIn(ExperimentalTextApi::class)
@Composable
fun AutoFitText(
    text: String,
    modifier: Modifier = Modifier,
    style: TextStyle = TextStyle.Default,
    layoutDirection: LayoutDirection = LayoutDirection.Ltr
) {
    val measurer = rememberTextMeasurer()

    Canvas(modifier = modifier.fillMaxSize()) {
        val measurement = measurer.measure(
            text = AnnotatedString(text),
            style = style,
            overflow = TextOverflow.Visible,
            softWrap = false,
            maxLines = 1,
            constraints = Constraints(
                maxWidth = Int.MAX_VALUE,
                maxHeight = ceil(this.size.height).roundToInt()
            ),
            layoutDirection = layoutDirection,
            density = this
        )

        val scale = (size.width / measurement.size.width).coerceAtMost(1.0f)

        translate(left = (scale * (size.width - measurement.size.width)) / 2.0f) {
            scale(scaleX = scale, scaleY = 1.0f) {
                drawText(
                    measurement
                )
            }
        }
    }
}

@Composable
fun RowScope.SuggestionItem(words: SuggestedWords, idx: Int, isPrimary: Boolean, onClick: () -> Unit) {
    val word = try {
         words.getWord(idx)
    } catch(e: IndexOutOfBoundsException) {
        null
    }

    val wordInfo = try {
        words.getInfo(idx)
    } catch(e: IndexOutOfBoundsException) {
        null
    }

    val actualIsPrimary = isPrimary && (words.mWillAutoCorrect || ((wordInfo?.isExactMatch) == true))

    val iconColor = MaterialTheme.colorScheme.onBackground
    val topSuggestionIcon = painterResource(id = R.drawable.transformer_suggestion)
    val textButtonModifier = when (wordInfo?.mOriginatesFromTransformerLM) {
        true -> Modifier.drawBehind {
                val iconSize = topSuggestionIcon.intrinsicSize
                translate(
                    left = (size.width - iconSize.width) / 2.0f,
                    top = size.height - iconSize.height * 2.0f
                ) {
                        alpha = if(actualIsPrimary){ 1.0f } else { 0.66f } / 1.25f,
                        colorFilter = ColorFilter.tint(color = iconColor)
    val textModifier = when (actualIsPrimary) {
        true -> Modifier
        false -> Modifier.alpha(0.75f)
    val textStyle = when (actualIsPrimary) {
        true -> suggestionStylePrimary
        false -> suggestionStyleAlternative
    }.copy(color = MaterialTheme.colorScheme.onBackground)

    TextButton(
        onClick = onClick,
        modifier = textButtonModifier
            .weight(1.0f)
            .fillMaxHeight(),
        shape = RectangleShape,
        colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onBackground),
        enabled = word != null
    ) {
        if(word != null) {
            AutoFitText(word, style = textStyle, modifier = textModifier)
        }
    }
}

@Composable fun RowScope.SuggestionSeparator() {
    Box(
        modifier = Modifier
            .fillMaxHeight(0.66f)
            .align(CenterVertically)
            .background(color = MaterialTheme.colorScheme.outline)
    )
}



// Show the most probable in the middle, then left, then right
val ORDER_OF_SUGGESTIONS = listOf(1, 0, 2)

@Composable
fun RowScope.SuggestionItems(words: SuggestedWords, onClick: (i: Int) -> Unit) {
    val maxSuggestions = min(ORDER_OF_SUGGESTIONS.size, words.size())

    if(maxSuggestions == 0) {
        Spacer(modifier = Modifier.weight(1.0f))
        return
    }


    var offset = 0

    try {
        val info = words.getInfo(0)
        if (info.kind == KIND_TYPED && !info.isExactMatch && !info.isExactMatchWithIntentionalOmission) {
            offset = 1
        }
    } catch(_: IndexOutOfBoundsException) {

    }

    // Check for "clueless" suggestions, and display typed word in center if so
    try {
        if(offset == 1) {
            val info = words.getInfo(1)
            if(info.mOriginatesFromTransformerLM && info.mScore < -50) {
                offset = 0;
            }
        }
    } catch(_: IndexOutOfBoundsException) {

    }

    for (i in 0 until maxSuggestions) {
        val remapped = if(offset == 1 && i == 2) {
            0 - offset
        } else {
            ORDER_OF_SUGGESTIONS[i]
        }
        SuggestionItem(
            words,
            remapped + offset,
            isPrimary = remapped == 0
        ) { onClick(remapped + offset) }

        if (i < maxSuggestions - 1) SuggestionSeparator()
    }
}

@Composable
fun ActionItem(action: Action, onSelect: (Action) -> Unit) {
    val col = MaterialTheme.colorScheme.secondaryContainer
    val contentCol = MaterialTheme.colorScheme.onSecondaryContainer
    IconButton(onClick = { onSelect(action) }, modifier = Modifier
        .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)
            )
        }
        .fillMaxHeight(),
        colors = IconButtonDefaults.iconButtonColors(contentColor = contentCol)
    ) {
            painter = painterResource(id = action.icon),
            contentDescription = stringResource(action.name)
@Composable
fun ActionItemSmall(action: Action, onSelect: (Action) -> Unit) {
    IconButton(onClick = {
        onSelect(action)
    }, modifier = Modifier
        .width(42.dp)
        .fillMaxHeight()) {
        Icon(
            painter = painterResource(id = action.icon),
            contentDescription = stringResource(action.name)
fun RowScope.ActionItems(onSelect: (Action) -> Unit) {
    ActionItem(EmojiAction, onSelect)
    ActionItem(VoiceInputAction, onSelect)
    ActionItem(ThemeAction, onSelect)
    ActionItem(UndoAction, onSelect)
    ActionItem(RedoAction, onSelect)
    ActionItem(ClipboardAction, onSelect)
    ActionItem(TextEditAction, onSelect)
@Composable
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 {
        MaterialTheme.colorScheme.onBackground
    }

    IconButton(
        onClick = onClick,
        modifier = Modifier
            .width(42.dp)
            .rotate(
                if (isActionsOpen) {
                    180.0f
                } else {
                    0.0f
                }
            )
            .fillMaxHeight()
            .drawBehind {
                drawCircle(color = moreActionsColor, radius = size.width / 3.0f + 1.0f)
                drawCircle(color = moreActionsFill, radius = size.width / 3.0f - 2.0f)
            },

        colors = IconButtonDefaults.iconButtonColors(contentColor = actionsContent)
    ) {
        Icon(
            painter = painterResource(id = R.drawable.chevron_right),
            contentDescription = "Open Actions"
        )
@Composable
fun ImportantNoticeView(
    importantNotice: ImportantNotice
) {
    val context = LocalContext.current

    Row {
        TextButton(
            onClick = { importantNotice.onOpen(context) },
            modifier = Modifier
                .weight(1.0f)
                .fillMaxHeight(),
            shape = RectangleShape,
            colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onBackground),
            enabled = true
        ) {
            AutoFitText(importantNotice.getText(), style = suggestionStylePrimary.copy(color = MaterialTheme.colorScheme.onBackground))
        }

        val color = MaterialTheme.colorScheme.primary
        IconButton(
            onClick = { importantNotice.onDismiss(context) },
            modifier = Modifier
                .width(42.dp)
                .fillMaxHeight()
                .drawBehind {
                    drawCircle(color = color, radius = size.width / 3.0f + 1.0f)
                },

            colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.onPrimary)
        ) {
            Icon(
                painter = painterResource(id = R.drawable.close),
                contentDescription = "Close"
            )
        }
    }
}

@Composable
fun ActionBar(
    words: SuggestedWords?,
    suggestionStripListener: SuggestionStripView.Listener,
    onActionActivated: (Action) -> Unit,
    inlineSuggestions: List<MutableState<View?>>,
    forceOpenActionsInitially: Boolean = false,
    val isActionsOpen = remember { mutableStateOf(forceOpenActionsInitially) }
    Surface(modifier = Modifier
        .fillMaxWidth()
        .height(40.dp), color = MaterialTheme.colorScheme.background)
    {
        Row {
            ExpandActionsButton(isActionsOpen.value) {
                isActionsOpen.value = !isActionsOpen.value
                if(isActionsOpen.value && importantNotice != null) {
                    importantNotice.onDismiss(context)
                }
            }

            if(importantNotice != null && !isActionsOpen.value) {
                ImportantNoticeView(importantNotice)
            }else {
                if (isActionsOpen.value) {
                    LazyRow {
                        item {
                            ActionItems(onActionActivated)
                        }
                } else if (inlineSuggestions.isNotEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                    InlineSuggestions(inlineSuggestions)
                } else if (words != null) {
                    SuggestionItems(words) {
                        suggestionStripListener.pickSuggestionManually(
                            words.getInfo(it)
                        )
                    }
                } else {
                    Spacer(modifier = Modifier.weight(1.0f))
                if (!isActionsOpen.value) {
                    ActionItemSmall(VoiceInputAction, onActionActivated)
                }
@Composable
fun ActionWindowBar(
    windowName: String,
    canExpand: Boolean,
    onBack: () -> Unit,
    onExpand: () -> Unit
) {
    Surface(
        modifier = Modifier
            .fillMaxWidth()
            .height(40.dp), color = MaterialTheme.colorScheme.background
    )
    {
        Row {
            IconButton(onClick = onBack) {
                Icon(
                    painter = painterResource(id = R.drawable.arrow_left_26),
                    contentDescription = "Back"
                )
            }

            Text(
                windowName,
                style = Typography.titleMedium,
                modifier = Modifier.align(CenterVertically)
            )

            Spacer(modifier = Modifier.weight(1.0f))

            if(canExpand) {
                IconButton(onClick = onExpand) {
                    Icon(
                        painter = painterResource(id = R.drawable.arrow_up),
                        contentDescription = "Show Keyboard"
                    )
                }
            }
        }
    }
}

@Composable
fun CollapsibleSuggestionsBar(
    onClose: () -> Unit,
    onCollapse: () -> Unit,
    words: SuggestedWords?,
    suggestionStripListener: SuggestionStripView.Listener,
    inlineSuggestions: List<MutableState<View?>>,
) {
    Surface(modifier = Modifier
        .fillMaxWidth()
        .height(40.dp), color = MaterialTheme.colorScheme.background)
    {
        Row {
            val color = MaterialTheme.colorScheme.primary

            IconButton(
                onClick = onClose,
                modifier = Modifier
                    .width(42.dp)
                    .fillMaxHeight()
                    .drawBehind {
                        drawCircle(color = color, radius = size.width / 3.0f + 1.0f)
                    },

                colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.onPrimary)
            ) {
                Icon(
                    painter = painterResource(id = R.drawable.close),
                    contentDescription = "Close"
                )
            }

            if(inlineSuggestions.isNotEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                InlineSuggestions(inlineSuggestions)
            } else if(words != null) {
                SuggestionItems(words) {
                    suggestionStripListener.pickSuggestionManually(
                        words.getInfo(it)
                    )
                }
            } else {
                Spacer(modifier = Modifier.weight(1.0f))
            }

            IconButton(
                onClick = onCollapse,
                modifier = Modifier
                    .width(42.dp)
                    .fillMaxHeight(),
                colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.onBackground)
            ) {
                Icon(
                    painter = painterResource(id = R.drawable.arrow_down),
                    contentDescription = "Collapse"
                )
            }
        }
    }
}




/* ---- Previews ---- */

class ExampleListener : SuggestionStripView.Listener {
    override fun showImportantNoticeContents() {
    }

    override fun pickSuggestionManually(word: SuggestedWordInfo?) {
    }

    override fun onCodeInput(primaryCode: Int, x: Int, y: Int, isKeyRepeat: Boolean) {
    }
}

val exampleSuggestionsList = arrayListOf(
    SuggestedWordInfo("verylongword123", "", 100, 1, null, 0, 0),
    SuggestedWordInfo("world understanding of patience", "",  99, 1, null, 0, 0),
    SuggestedWordInfo("short", "", 98, 1, null, 0, 0),
    SuggestedWordInfo("extra1", "", 97, 1, null, 0, 0),
    SuggestedWordInfo("extra2", "", 96, 1, null, 0, 0),
    SuggestedWordInfo("extra3", "", 95, 1, null, 0, 0)
)

val exampleSuggestedWords = SuggestedWords(
    exampleSuggestionsList,
    exampleSuggestionsList,
    exampleSuggestionsList[0],
    true,
    true,
    false,
    0,
    0
)

val exampleSuggestedWordsEmpty = SuggestedWords(
    arrayListOf(),
    arrayListOf(),
    exampleSuggestionsList[0],
    true,
    true,
    false,
    0,
    0
)

@Composable
@Preview
fun PreviewActionBarWithSuggestions(colorScheme: ColorScheme = DarkColorScheme) {
    UixThemeWrapper(colorScheme) {
        ActionBar(
            words = exampleSuggestedWords,
            suggestionStripListener = ExampleListener(),
            inlineSuggestions = listOf()
@Composable
@Preview
fun PreviewActionBarWithNotice(colorScheme: ColorScheme = DarkColorScheme) {
    UixThemeWrapper(colorScheme) {
        ActionBar(
            words = exampleSuggestedWords,
            suggestionStripListener = ExampleListener(),
            onActionActivated = { },
            inlineSuggestions = listOf(),
            importantNotice = object : ImportantNotice {
                @Composable
                override fun getText(): String {
                    return "Update available: v1.2.3"
                }

                override fun onDismiss(context: Context) {
                    TODO("Not yet implemented")
                }

                override fun onOpen(context: Context) {
                    TODO("Not yet implemented")
                }

            }
        )
    }
}

@Composable
@Preview
fun PreviewActionBarWithEmptySuggestions(colorScheme: ColorScheme = DarkColorScheme) {
    UixThemeWrapper(colorScheme) {
        ActionBar(
            words = exampleSuggestedWordsEmpty,
            suggestionStripListener = ExampleListener(),
            inlineSuggestions = listOf()
        )
    }
}

@Composable
@Preview
fun PreviewExpandedActionBar(colorScheme: ColorScheme = DarkColorScheme) {
    UixThemeWrapper(colorScheme) {
        ActionBar(
            words = exampleSuggestedWordsEmpty,
            suggestionStripListener = ExampleListener(),
            onActionActivated = { },
            inlineSuggestions = listOf(),
@Composable
@Preview
fun PreviewCollapsibleBar(colorScheme: ColorScheme = DarkColorScheme) {
    CollapsibleSuggestionsBar(
        onCollapse = { },
        onClose = { },
        words = exampleSuggestedWords,
        suggestionStripListener = ExampleListener(),
        inlineSuggestions = listOf()
    )
}

@RequiresApi(Build.VERSION_CODES.S)
@Composable
@Preview
fun PreviewActionBarWithSuggestionsDynamicLight() {
    PreviewActionBarWithSuggestions(dynamicLightColorScheme(LocalContext.current))
}

@RequiresApi(Build.VERSION_CODES.S)
@Composable
@Preview
fun PreviewActionBarWithEmptySuggestionsDynamicLight() {
    PreviewActionBarWithEmptySuggestions(dynamicLightColorScheme(LocalContext.current))
}

@RequiresApi(Build.VERSION_CODES.S)
@Composable
@Preview
fun PreviewExpandedActionBarDynamicLight() {
    PreviewExpandedActionBar(dynamicLightColorScheme(LocalContext.current))
}

@RequiresApi(Build.VERSION_CODES.S)
@Composable
@Preview
fun PreviewActionBarWithSuggestionsDynamicDark() {
    PreviewActionBarWithSuggestions(dynamicDarkColorScheme(LocalContext.current))
@RequiresApi(Build.VERSION_CODES.S)
@Composable
@Preview
fun PreviewActionBarWithEmptySuggestionsDynamicDark() {
    PreviewActionBarWithEmptySuggestions(dynamicDarkColorScheme(LocalContext.current))
@RequiresApi(Build.VERSION_CODES.S)
@Composable
@Preview
fun PreviewExpandedActionBarDynamicDark() {
    PreviewExpandedActionBar(dynamicDarkColorScheme(LocalContext.current))