Newer
Older
package org.futo.inputmethod.latin.uix
import android.os.Build
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.material3.Button
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.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
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.Color
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.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.common.Constants
import org.futo.inputmethod.latin.suggestions.SuggestionStripView
import org.futo.inputmethod.latin.uix.theme.DarkColorScheme
import org.futo.inputmethod.latin.uix.theme.UixThemeWrapper
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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
*/
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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
@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 topSuggestionIcon = painterResource(id = R.drawable.top_suggestion)
val textButtonModifier = when (isPrimary) {
true -> Modifier.drawBehind {
with(topSuggestionIcon) {
val iconSize = topSuggestionIcon.intrinsicSize
translate(
left = (size.width - iconSize.width) / 2.0f,
top = size.height - iconSize.height * 2.0f
) {
draw(topSuggestionIcon.intrinsicSize)
}
}
}
val textModifier = when (isPrimary) {
true -> Modifier
false -> Modifier.alpha(0.75f)
val textStyle = when (isPrimary) {
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
// Don't show what the user is typing
try {
val info = words.getInfo(0)
if (info.kind == KIND_TYPED && !info.isExactMatch && !info.isExactMatchWithIntentionalOmission) {
offset = 1
}
} catch(_: IndexOutOfBoundsException) {
}
for (i in 0 until maxSuggestions) {
val remapped = 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)
)
}
.width(64.dp)
.fillMaxHeight(),
colors = IconButtonDefaults.iconButtonColors(contentColor = contentCol)
) {
painter = painterResource(id = action.icon),
contentDescription = 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 = action.name
)
}
fun RowScope.ActionItems(onSelect: (Action) -> Unit) {
ActionItem(VoiceInputAction, onSelect)
ActionItem(ThemeAction, onSelect)
Box(modifier = Modifier
.fillMaxHeight()
.weight(1.0f)) {
@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 ActionBar(
words: SuggestedWords?,
suggestionStripListener: SuggestionStripView.Listener,
onActionActivated: (Action) -> Unit,
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) {
ActionItems(onActionActivated)
} else if(words != null) {
SuggestionItems(words) {
suggestionStripListener.pickSuggestionManually(
words.getInfo(it)
} else {
Spacer(modifier = Modifier.weight(1.0f))
ActionItemSmall(VoiceInputAction, onActionActivated)
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
}
/* ---- 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
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
fun PreviewActionBarWithSuggestions(colorScheme: ColorScheme = DarkColorScheme) {
UixThemeWrapper(colorScheme) {
ActionBar(
words = exampleSuggestedWords,
onActionActivated = { },
suggestionStripListener = ExampleListener()
)
}
}
@Composable
@Preview
fun PreviewActionBarWithEmptySuggestions(colorScheme: ColorScheme = DarkColorScheme) {
UixThemeWrapper(colorScheme) {
ActionBar(
words = exampleSuggestedWordsEmpty,
onActionActivated = { },
suggestionStripListener = ExampleListener()
)
}
}
@Composable
@Preview
fun PreviewExpandedActionBar(colorScheme: ColorScheme = DarkColorScheme) {
UixThemeWrapper(colorScheme) {
ActionBar(
words = exampleSuggestedWordsEmpty,
onActionActivated = { },
suggestionStripListener = ExampleListener(),
forceOpenActionsInitially = true
)
}
}
@Composable
@Preview
fun PreviewActionBarWithSuggestionsDynamicLight() {
PreviewActionBarWithSuggestions(dynamicLightColorScheme(LocalContext.current))
}
@Composable
@Preview
fun PreviewActionBarWithEmptySuggestionsDynamicLight() {
PreviewActionBarWithEmptySuggestions(dynamicLightColorScheme(LocalContext.current))
}
@Composable
@Preview
fun PreviewExpandedActionBarDynamicLight() {
PreviewExpandedActionBar(dynamicLightColorScheme(LocalContext.current))
}
@Composable
@Preview
fun PreviewActionBarWithSuggestionsDynamicDark() {
PreviewActionBarWithSuggestions(dynamicDarkColorScheme(LocalContext.current))
fun PreviewActionBarWithEmptySuggestionsDynamicDark() {
PreviewActionBarWithEmptySuggestions(dynamicDarkColorScheme(LocalContext.current))
fun PreviewExpandedActionBarDynamicDark() {
PreviewExpandedActionBar(dynamicDarkColorScheme(LocalContext.current))