@@ -41,6 +41,7 @@ import kotlinx.coroutines.Dispatchers
4141import kotlinx.coroutines.SupervisorJob
4242import kotlinx.coroutines.cancel
4343import kotlinx.coroutines.flow.collectLatest
44+ import kotlinx.coroutines.flow.distinctUntilChanged
4445import kotlinx.coroutines.launch
4546import kotlinx.serialization.json.JsonObject
4647import kotlinx.serialization.json.booleanOrNull
@@ -60,6 +61,7 @@ class ClipSyncInputMethodService : InputMethodService() {
6061
6162 private var keyboardMode = KeyboardMode .Letters
6263 private var isUppercase = false
64+ private var isCapsLockEnabled = false
6365 private var isChineseMode = false
6466 private var currentConnectionState: ConnectionState = ConnectionState .Disconnected
6567 private var activePanel = ImePanel .Clipboard
@@ -70,6 +72,9 @@ class ClipSyncInputMethodService : InputMethodService() {
7072 private var onlineDevices: List <DeviceEntity > = emptyList()
7173 private var composingPinyin = " "
7274 private var composingCandidates: List <String > = emptyList()
75+ private var candidatePageIndex = 0
76+ private var totalCandidatePages = 0
77+ private var lastShiftTapAt = 0L
7378
7479 private val deleteRepeatHandler = Handler (Looper .getMainLooper())
7580 private val deleteRepeatRunnable = object : Runnable {
@@ -83,6 +88,7 @@ class ClipSyncInputMethodService : InputMethodService() {
8388 private var statusBadgeView: TextView ? = null
8489 private var composingBar: LinearLayout ? = null
8590 private var composingTextView: TextView ? = null
91+ private var composingPageView: TextView ? = null
8692 private var candidateRow: LinearLayout ? = null
8793 private var candidateScrollView: HorizontalScrollView ? = null
8894 private var panelToggleRow: LinearLayout ? = null
@@ -110,6 +116,7 @@ class ClipSyncInputMethodService : InputMethodService() {
110116 observeLocalData()
111117 observeConnectionState()
112118 observeMessages()
119+ observeSettings()
113120 }
114121
115122 override fun onCreateInputView (): View {
@@ -282,6 +289,13 @@ class ClipSyncInputMethodService : InputMethodService() {
282289 }
283290 composingBar?.addView(composingTextView)
284291
292+ composingPageView = TextView (this ).apply {
293+ setTextColor(Color .parseColor(" #64748B" ))
294+ setTextSize(TypedValue .COMPLEX_UNIT_SP , 11f )
295+ setPadding(0 , dp(4 ), 0 , 0 )
296+ }
297+ composingBar?.addView(composingPageView)
298+
285299 candidateScrollView = HorizontalScrollView (this ).apply {
286300 isHorizontalScrollBarEnabled = false
287301 setPadding(0 , dp(8 ), 0 , 0 )
@@ -422,8 +436,10 @@ class ClipSyncInputMethodService : InputMethodService() {
422436 KeySpec .Special (SpecialKey .SystemKeyboard , getString(R .string.ime_action_switch_keyboard), 1.05f ),
423437 KeySpec .Special (SpecialKey .ToggleLanguage , resolveLanguageToggleLabel(), 1f ),
424438 KeySpec .Special (SpecialKey .ModeNumbers , getString(R .string.ime_action_numbers), 1f ),
439+ KeySpec .Special (SpecialKey .CursorLeft , getString(R .string.ime_action_cursor_left), 0.8f ),
440+ KeySpec .Special (SpecialKey .Space , resolveSpaceLabel(), 2.8f ),
441+ KeySpec .Special (SpecialKey .CursorRight , getString(R .string.ime_action_cursor_right), 0.8f ),
425442 KeySpec .Text (resolveCommaLabel(), resolveCommaOutput(), 0.8f ),
426- KeySpec .Special (SpecialKey .Space , resolveSpaceLabel(), 3.6f ),
427443 KeySpec .Text (resolvePeriodLabel(), resolvePeriodOutput(), 0.8f ),
428444 KeySpec .Special (SpecialKey .Enter , resolveEnterLabel(), 1.2f )
429445 )
@@ -442,8 +458,10 @@ class ClipSyncInputMethodService : InputMethodService() {
442458 KeySpec .Special (SpecialKey .SystemKeyboard , getString(R .string.ime_action_switch_keyboard), 1.05f ),
443459 KeySpec .Special (SpecialKey .ToggleLanguage , resolveLanguageToggleLabel(), 1f ),
444460 KeySpec .Special (SpecialKey .ModeLetters , getString(R .string.ime_action_letters), 1f ),
461+ KeySpec .Special (SpecialKey .CursorLeft , getString(R .string.ime_action_cursor_left), 0.8f ),
462+ KeySpec .Special (SpecialKey .Space , resolveSpaceLabel(), 2.8f ),
463+ KeySpec .Special (SpecialKey .CursorRight , getString(R .string.ime_action_cursor_right), 0.8f ),
445464 KeySpec .Text (resolveCommaLabel(), resolveCommaOutput(), 0.8f ),
446- KeySpec .Special (SpecialKey .Space , resolveSpaceLabel(), 3.6f ),
447465 KeySpec .Text (resolvePeriodLabel(), resolvePeriodOutput(), 0.8f ),
448466 KeySpec .Special (SpecialKey .Enter , resolveEnterLabel(), 1.2f )
449467 )
@@ -462,8 +480,10 @@ class ClipSyncInputMethodService : InputMethodService() {
462480 KeySpec .Special (SpecialKey .SystemKeyboard , getString(R .string.ime_action_switch_keyboard), 1.05f ),
463481 KeySpec .Special (SpecialKey .ToggleLanguage , resolveLanguageToggleLabel(), 1f ),
464482 KeySpec .Special (SpecialKey .ModeLetters , getString(R .string.ime_action_letters), 1f ),
483+ KeySpec .Special (SpecialKey .CursorLeft , getString(R .string.ime_action_cursor_left), 0.8f ),
484+ KeySpec .Special (SpecialKey .Space , resolveSpaceLabel(), 2.8f ),
485+ KeySpec .Special (SpecialKey .CursorRight , getString(R .string.ime_action_cursor_right), 0.8f ),
465486 KeySpec .Text (" -" , " -" , 0.8f ),
466- KeySpec .Special (SpecialKey .Space , resolveSpaceLabel(), 3.6f ),
467487 KeySpec .Text (" @" , " @" , 0.8f ),
468488 KeySpec .Special (SpecialKey .Enter , resolveEnterLabel(), 1.2f )
469489 )
@@ -512,12 +532,26 @@ class ClipSyncInputMethodService : InputMethodService() {
512532 }
513533 }
514534
535+ if (key is KeySpec .Special && key.action == SpecialKey .Shift ) {
536+ setOnLongClickListener {
537+ toggleCapsLock()
538+ true
539+ }
540+ }
541+
515542 if (key is KeySpec .Special && key.action == SpecialKey .SystemKeyboard ) {
516543 setOnLongClickListener {
517544 showInputMethodPicker()
518545 true
519546 }
520547 }
548+
549+ if (key is KeySpec .Text && keyboardMode == KeyboardMode .Symbols ) {
550+ setOnLongClickListener {
551+ handleSymbolLongPress(key)
552+ true
553+ }
554+ }
521555 }
522556 }
523557
@@ -549,7 +583,14 @@ class ClipSyncInputMethodService : InputMethodService() {
549583 when (action) {
550584 SpecialKey .Shift -> {
551585 if (! isChineseMode) {
552- isUppercase = ! isUppercase
586+ val now = System .currentTimeMillis()
587+ if (now - lastShiftTapAt <= DOUBLE_TAP_WINDOW_MS ) {
588+ toggleCapsLock()
589+ } else {
590+ isCapsLockEnabled = false
591+ isUppercase = ! isUppercase
592+ }
593+ lastShiftTapAt = now
553594 renderKeyboard()
554595 }
555596 }
@@ -610,6 +651,24 @@ class ClipSyncInputMethodService : InputMethodService() {
610651 renderKeyboard()
611652 }
612653
654+ SpecialKey .CandidatePrevPage -> {
655+ if (candidatePageIndex > 0 ) {
656+ candidatePageIndex - = 1
657+ updateComposingState()
658+ }
659+ }
660+
661+ SpecialKey .CandidateNextPage -> {
662+ if (candidatePageIndex + 1 < totalCandidatePages) {
663+ candidatePageIndex + = 1
664+ updateComposingState()
665+ }
666+ }
667+
668+ SpecialKey .CursorLeft -> moveCursor(- 1 )
669+
670+ SpecialKey .CursorRight -> moveCursor(1 )
671+
613672 SpecialKey .SystemKeyboard -> switchToSystemKeyboard()
614673 }
615674 }
@@ -681,6 +740,42 @@ class ClipSyncInputMethodService : InputMethodService() {
681740 }
682741 }
683742
743+ private fun observeSettings () {
744+ scope.launch {
745+ settingsManager.serverUrlFlow
746+ .distinctUntilChanged()
747+ .collectLatest { url ->
748+ if (url.isBlank()) return @collectLatest
749+ if (currentConnectionState is ConnectionState .Connected || currentConnectionState == ConnectionState .Connecting ) {
750+ FileLogger .d(TAG , " IME observed server url change, reconnecting" )
751+ webSocketClient.connect(url)
752+ }
753+ }
754+ }
755+
756+ scope.launch {
757+ settingsManager.deviceNameFlow
758+ .distinctUntilChanged()
759+ .collectLatest {
760+ if (webSocketClient.isConnected()) {
761+ FileLogger .d(TAG , " IME observed device name change, re-sending auth" )
762+ sendAuth()
763+ }
764+ }
765+ }
766+
767+ scope.launch {
768+ settingsManager.encryptionEnabledFlow
769+ .distinctUntilChanged()
770+ .collectLatest {
771+ if (webSocketClient.isConnected()) {
772+ FileLogger .d(TAG , " IME observed encryption change, refreshing auth" )
773+ sendAuth()
774+ }
775+ }
776+ }
777+ }
778+
684779 private fun handleWebSocketMessage (json : String ) {
685780 val wsMessage = WsMessage .fromJson(json)
686781 if (wsMessage == null ) {
@@ -977,6 +1072,10 @@ class ClipSyncInputMethodService : InputMethodService() {
9771072 }
9781073
9791074 private fun syncShiftStateForCursor (force : Boolean = false) {
1075+ if (isCapsLockEnabled) {
1076+ isUppercase = true
1077+ return
1078+ }
9801079 val newUppercase = shouldAutoCapitalize(currentEditorInfo)
9811080 if (force || newUppercase != isUppercase) {
9821081 isUppercase = newUppercase
@@ -1123,17 +1222,26 @@ class ClipSyncInputMethodService : InputMethodService() {
11231222
11241223 private fun appendPinyin (value : String ) {
11251224 composingPinyin + = value.lowercase()
1225+ candidatePageIndex = 0
11261226 updateComposingState()
11271227 }
11281228
11291229 private fun removeLastPinyinLetter () {
11301230 if (composingPinyin.isEmpty()) return
11311231 composingPinyin = composingPinyin.dropLast(1 )
1232+ candidatePageIndex = 0
11321233 updateComposingState()
11331234 }
11341235
11351236 private fun updateComposingState () {
1136- composingCandidates = PinyinCandidateEngine .getCandidates(composingPinyin, CANDIDATE_LIMIT )
1237+ val page = PinyinCandidateEngine .getCandidatePage(
1238+ pinyin = composingPinyin,
1239+ pageIndex = candidatePageIndex,
1240+ pageSize = CANDIDATE_LIMIT
1241+ )
1242+ candidatePageIndex = page.pageIndex
1243+ totalCandidatePages = page.totalPages
1244+ composingCandidates = page.items.map { it.text }
11371245 if (composingPinyin.isEmpty()) {
11381246 currentInputConnection?.finishComposingText()
11391247 } else {
@@ -1166,6 +1274,7 @@ class ClipSyncInputMethodService : InputMethodService() {
11661274 private fun renderComposingBar () {
11671275 val bar = composingBar ? : return
11681276 val textView = composingTextView ? : return
1277+ val pageView = composingPageView ? : return
11691278 val row = candidateRow ? : return
11701279
11711280 val visible = isChineseMode && keyboardMode == KeyboardMode .Letters
@@ -1177,12 +1286,23 @@ class ClipSyncInputMethodService : InputMethodService() {
11771286 } else {
11781287 getString(R .string.ime_composing_prefix, composingPinyin)
11791288 }
1289+ pageView.text = if (composingPinyin.isBlank() || totalCandidatePages <= 1 ) {
1290+ " "
1291+ } else {
1292+ getString(R .string.ime_candidate_page, candidatePageIndex + 1 , totalCandidatePages)
1293+ }
11801294
11811295 row.removeAllViews()
11821296 if (composingPinyin.isBlank()) {
11831297 return
11841298 }
11851299
1300+ if (candidatePageIndex > 0 ) {
1301+ row.addView(createCandidateChip(getString(R .string.ime_action_prev_page), emphasized = false ) {
1302+ handleSpecialKey(SpecialKey .CandidatePrevPage )
1303+ })
1304+ }
1305+
11861306 if (composingCandidates.isEmpty()) {
11871307 row.addView(createCandidateChip(getString(R .string.ime_candidate_raw, composingPinyin), emphasized = true ) {
11881308 commitComposingText(selected = composingPinyin)
@@ -1205,6 +1325,12 @@ class ClipSyncInputMethodService : InputMethodService() {
12051325 commitComposingText(selected = composingPinyin)
12061326 })
12071327
1328+ if (candidatePageIndex + 1 < totalCandidatePages) {
1329+ row.addView(createCandidateChip(getString(R .string.ime_action_next_page), emphasized = false ) {
1330+ handleSpecialKey(SpecialKey .CandidateNextPage )
1331+ })
1332+ }
1333+
12081334 candidateScrollView?.post { candidateScrollView?.scrollTo(0 , 0 ) }
12091335 }
12101336
@@ -1227,7 +1353,45 @@ class ClipSyncInputMethodService : InputMethodService() {
12271353 rightMargin = dp(6 )
12281354 }
12291355 setOnClickListener { onClick() }
1356+ setOnLongClickListener {
1357+ currentInputConnection?.commitText(label.substringAfter(" . " , label), 1 )
1358+ true
1359+ }
1360+ }
1361+ }
1362+
1363+ private fun toggleCapsLock () {
1364+ isCapsLockEnabled = ! isCapsLockEnabled
1365+ isUppercase = isCapsLockEnabled || ! isUppercase
1366+ renderKeyboard()
1367+ }
1368+
1369+ private fun moveCursor (offset : Int ) {
1370+ if (composingPinyin.isNotEmpty()) {
1371+ commitComposingText()
12301372 }
1373+ val extracted = currentInputConnection?.getExtractedText(
1374+ android.view.inputmethod.ExtractedTextRequest (),
1375+ 0
1376+ )
1377+ val current = extracted?.selectionStart ? : 0
1378+ val target = (current + offset).coerceAtLeast(0 )
1379+ currentInputConnection?.setSelection(target, target)
1380+ }
1381+
1382+ private fun handleSymbolLongPress (key : KeySpec .Text ) {
1383+ val alternate = when (key.output) {
1384+ " ." -> " …"
1385+ " ," -> " 、"
1386+ " ?" -> " ?"
1387+ " !" -> " !"
1388+ " :" -> " :"
1389+ " ;" -> " ;"
1390+ " -" -> " _"
1391+ " @" -> " #"
1392+ else -> key.output
1393+ }
1394+ currentInputConnection?.commitText(alternate, 1 )
12311395 }
12321396
12331397 private fun createContainerBackground (fillColor : Int , strokeColor : Int , radiusDp : Int ): GradientDrawable {
@@ -1250,7 +1414,8 @@ class ClipSyncInputMethodService : InputMethodService() {
12501414 private const val TAG = " ClipSyncIME"
12511415 private const val DELETE_REPEAT_START_DELAY_MS = 350L
12521416 private const val DELETE_REPEAT_INTERVAL_MS = 50L
1253- private const val CANDIDATE_LIMIT = 12
1417+ private const val CANDIDATE_LIMIT = 8
1418+ private const val DOUBLE_TAP_WINDOW_MS = 280L
12541419 }
12551420}
12561421
@@ -1275,6 +1440,10 @@ private enum class SpecialKey {
12751440 Delete ,
12761441 Space ,
12771442 Enter ,
1443+ CandidatePrevPage ,
1444+ CandidateNextPage ,
1445+ CursorLeft ,
1446+ CursorRight ,
12781447 ModeLetters ,
12791448 ModeNumbers ,
12801449 ModeSymbols ,
0 commit comments