Newer
Older
package org.futo.inputmethod.latin.uix
Aleksandras Kostarevas
committed
import android.content.Context
import android.os.Build
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.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
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
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
*/
Aleksandras Kostarevas
committed
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
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
164
165
166
167
168
169
170
171
172
173
174
175
@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 {
with(topSuggestionIcon) {
val iconSize = topSuggestionIcon.intrinsicSize
translate(
left = (size.width - iconSize.width) / 2.0f,
top = size.height - iconSize.height * 2.0f
) {
draw(
topSuggestionIcon.intrinsicSize,
alpha = if(actualIsPrimary){ 1.0f } else { 0.66f } / 1.25f,
colorFilter = ColorFilter.tint(color = iconColor)
)
}
else -> Modifier
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) {
}
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(VoiceInputAction, onSelect)
ActionItem(ThemeAction, onSelect)
ActionItem(UndoAction, onSelect)
ActionItem(RedoAction, onSelect)
ActionItem(ClipboardAction, 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"
)
Aleksandras Kostarevas
committed
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
@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"
)
}
}
}
words: SuggestedWords?,
suggestionStripListener: SuggestionStripView.Listener,
onActionActivated: (Action) -> Unit,
inlineSuggestions: List<MutableState<View?>>,
forceOpenActionsInitially: Boolean = false,
Aleksandras Kostarevas
committed
importantNotice: ImportantNotice? = null
Aleksandras Kostarevas
committed
val context = LocalContext.current
val isActionsOpen = remember { mutableStateOf(forceOpenActionsInitially) }
Surface(modifier = Modifier
.fillMaxWidth()
.height(40.dp), color = MaterialTheme.colorScheme.background)
{
Row {
Aleksandras Kostarevas
committed
ExpandActionsButton(isActionsOpen.value) {
isActionsOpen.value = !isActionsOpen.value
if(isActionsOpen.value && importantNotice != null) {
importantNotice.onDismiss(context)
}
}
if(importantNotice != null && !isActionsOpen.value) {
ImportantNoticeView(importantNotice)
}else {
Aleksandras Kostarevas
committed
if (isActionsOpen.value) {
LazyRow {
item {
ActionItems(onActionActivated)
}
Aleksandras Kostarevas
committed
} 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))
Aleksandras Kostarevas
committed
if (!isActionsOpen.value) {
ActionItemSmall(VoiceInputAction, onActionActivated)
}
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
@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"
)
}
}
}
}
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
/* ---- 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(),
onActionActivated = { },
inlineSuggestions = listOf()
)
}
}
Aleksandras Kostarevas
committed
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
@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(),
onActionActivated = { },
inlineSuggestions = listOf()
)
}
}
@Composable
@Preview
fun PreviewExpandedActionBar(colorScheme: ColorScheme = DarkColorScheme) {
UixThemeWrapper(colorScheme) {
ActionBar(
words = exampleSuggestedWordsEmpty,
suggestionStripListener = ExampleListener(),
onActionActivated = { },
inlineSuggestions = listOf(),
forceOpenActionsInitially = true
)
}
}
@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)
fun PreviewActionBarWithEmptySuggestionsDynamicDark() {
PreviewActionBarWithEmptySuggestions(dynamicDarkColorScheme(LocalContext.current))
@RequiresApi(Build.VERSION_CODES.S)
fun PreviewExpandedActionBarDynamicDark() {
PreviewExpandedActionBar(dynamicDarkColorScheme(LocalContext.current))