From 8772ae4b6f4a28a9611b2cb873e7953f8b5bad7d Mon Sep 17 00:00:00 2001
From: Janne Koschinski <janne@kuschku.de>
Date: Fri, 9 Mar 2018 17:26:00 +0100
Subject: [PATCH] Implement keyboard support for autocomplete

---
 .../quasseldroid_ng/ui/chat/ChatActivity.kt   | 107 ++++++++++++++++--
 .../quasseldroid_ng/ui/chat/InputEditor.kt    |  20 ++++
 .../ui/viewmodel/QuasselViewModel.kt          |  10 +-
 .../util/helper/IntProgressionHelper.kt       |   4 +
 4 files changed, 124 insertions(+), 17 deletions(-)
 create mode 100644 app/src/main/java/de/kuschku/quasseldroid_ng/util/helper/IntProgressionHelper.kt

diff --git a/app/src/main/java/de/kuschku/quasseldroid_ng/ui/chat/ChatActivity.kt b/app/src/main/java/de/kuschku/quasseldroid_ng/ui/chat/ChatActivity.kt
index 4c21f6583..a43142266 100644
--- a/app/src/main/java/de/kuschku/quasseldroid_ng/ui/chat/ChatActivity.kt
+++ b/app/src/main/java/de/kuschku/quasseldroid_ng/ui/chat/ChatActivity.kt
@@ -110,10 +110,31 @@ class ChatActivity : ServiceBoundActivity(), SharedPreferences.OnSharedPreferenc
     }
   }
 
-  private val lastWord = BehaviorSubject.createDefault("")
+  private val lastWord = BehaviorSubject.createDefault(Pair("", IntRange.EMPTY))
   private val textWatcher = object : TextWatcher {
     override fun afterTextChanged(s: Editable?) {
-      lastWord.onNext(s?.lastWord(chatline.selectionStart, onlyBeforeCursor = true).toString())
+      val previous = autocompletionState
+      val next = if (previous != null && s != null) {
+        val suffix = if (previous.range.start == 0) ": " else " "
+        val end = Math.min(
+          s.length, previous.range.start + previous.completion.name.length + suffix.length
+        )
+        val sequence = s.subSequence(previous.range.start, end)
+        if (sequence == previous.completion.name + suffix) {
+          previous.originalWord to (previous.range.start until end)
+        } else {
+          autocompletionState = null
+          s.lastWordIndices(chatline.selectionStart, onlyBeforeCursor = true)?.let { indices ->
+            s.substring(indices) to indices
+          }
+        }
+      } else {
+        s?.lastWordIndices(chatline.selectionStart, onlyBeforeCursor = true)?.let { indices ->
+          s.substring(indices) to indices
+        }
+      }
+
+      if (next != null) lastWord.onNext(next)
     }
 
     override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
@@ -177,11 +198,19 @@ class ChatActivity : ServiceBoundActivity(), SharedPreferences.OnSharedPreferenc
     }.fold(0, Int::or)
 
     chatline.setOnKeyListener { _, keyCode, event ->
-      if (event.hasNoModifiers() && (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER)) {
-        send()
-        true
-      } else {
-        false
+      when (keyCode) {
+        KeyEvent.KEYCODE_ENTER,
+        KeyEvent.KEYCODE_NUMPAD_ENTER -> if (event.hasNoModifiers()) {
+          send()
+          true
+        } else {
+          false
+        }
+        KeyEvent.KEYCODE_TAB          -> {
+          autoComplete(event.isShiftPressed)
+          true
+        }
+        else                          -> false
       }
     }
 
@@ -243,6 +272,60 @@ class ChatActivity : ServiceBoundActivity(), SharedPreferences.OnSharedPreferenc
     editorPanel.panelState = SlidingUpPanelLayout.PanelState.COLLAPSED
   }
 
+  data class AutoCompletionState(
+    val originalWord: String,
+    val range: IntRange,
+    val lastCompletion: AutoCompleteAdapter.AutoCompleteItem? = null,
+    val completion: AutoCompleteAdapter.AutoCompleteItem
+  )
+
+  private var autocompletionState: AutoCompletionState? = null
+  private fun autoComplete(reverse: Boolean = false) {
+    val originalWord = lastWord.value
+
+    val previous = autocompletionState
+    if (!originalWord.second.isEmpty()) {
+      val autoCompletedWords = viewModel.autoCompleteData.value.orEmpty()
+      if (previous != null && lastWord.value == previous.originalWord to previous.range) {
+        val previousIndex = autoCompletedWords.indexOf(previous.completion)
+        val autoCompletedWord = if (previousIndex != -1) {
+          val change = if (reverse) -1 else +1
+          val newIndex = (previousIndex + change + autoCompletedWords.size) % autoCompletedWords.size
+
+          autoCompletedWords[newIndex]
+        } else {
+          autoCompletedWords.firstOrNull()
+        }
+        if (autoCompletedWord != null) {
+          val newState = AutoCompletionState(
+            previous.originalWord,
+            originalWord.second,
+            previous.completion,
+            autoCompletedWord
+          )
+          inputEditor.autoComplete(newState)
+          autocompletionState = newState
+        } else {
+          autocompletionState = null
+        }
+      } else {
+        val autoCompletedWord = autoCompletedWords.firstOrNull()
+        if (autoCompletedWord != null) {
+          val newState = AutoCompletionState(
+            originalWord.first,
+            originalWord.second,
+            null,
+            autoCompletedWord
+          )
+          inputEditor.autoComplete(newState)
+          autocompletionState = newState
+        } else {
+          autocompletionState = null
+        }
+      }
+    }
+  }
+
   private fun send() {
     val text = chatline.text
     if (text.isNotBlank()) {
@@ -300,7 +383,7 @@ class ChatActivity : ServiceBoundActivity(), SharedPreferences.OnSharedPreferenc
   }
 
   override fun onOptionsItemSelected(item: MenuItem?) = when (item?.itemId) {
-    android.R.id.home    -> {
+    android.R.id.home -> {
       drawerToggle.onOptionsItemSelected(item)
     }
 
@@ -354,7 +437,7 @@ class ChatActivity : ServiceBoundActivity(), SharedPreferences.OnSharedPreferenc
       }
       true
     }
-    R.id.clear           -> {
+    R.id.clear -> {
       handler.post {
         viewModel.sessionManager { manager ->
           viewModel.getBuffer().let { buffer ->
@@ -369,11 +452,11 @@ class ChatActivity : ServiceBoundActivity(), SharedPreferences.OnSharedPreferenc
       }
       true
     }
-    R.id.settings        -> {
+    R.id.settings -> {
       startActivity(Intent(applicationContext, SettingsActivity::class.java))
       true
     }
-    R.id.disconnect      -> {
+    R.id.disconnect -> {
       handler.post {
         sharedPreferences(Keys.Status.NAME, Context.MODE_PRIVATE) {
           editApply {
@@ -387,7 +470,7 @@ class ChatActivity : ServiceBoundActivity(), SharedPreferences.OnSharedPreferenc
       }
       true
     }
-    else                 -> super.onOptionsItemSelected(item)
+    else -> super.onOptionsItemSelected(item)
   }
 
   override fun onMenuItemClick(item: MenuItem?) = when (item?.itemId) {
diff --git a/app/src/main/java/de/kuschku/quasseldroid_ng/ui/chat/InputEditor.kt b/app/src/main/java/de/kuschku/quasseldroid_ng/ui/chat/InputEditor.kt
index 6eed8d219..82aac52f6 100644
--- a/app/src/main/java/de/kuschku/quasseldroid_ng/ui/chat/InputEditor.kt
+++ b/app/src/main/java/de/kuschku/quasseldroid_ng/ui/chat/InputEditor.kt
@@ -223,6 +223,26 @@ class InputEditor(private val editText: EditText) {
     }
   }
 
+  fun autoComplete(item: ChatActivity.AutoCompletionState) {
+    val suffix = if (item.range.start == 0) ": " else " "
+    val replacement = "${item.completion.name}$suffix"
+    val previousReplacement = item.lastCompletion?.let { "${item.lastCompletion.name}$suffix" }
+
+    if (previousReplacement != null &&
+        editText.text.length >= item.range.start + previousReplacement.length &&
+        editText.text.substring(
+          item.range.start, item.range.start + previousReplacement.length
+        ) == previousReplacement) {
+      editText.text.replace(
+        item.range.start, item.range.start + previousReplacement.length, replacement
+      )
+      editText.setSelection(item.range.start + replacement.length)
+    } else {
+      editText.text.replace(item.range.start, item.range.endInclusive + 1, replacement)
+      editText.setSelection(item.range.start + replacement.length)
+    }
+  }
+
   fun share(text: CharSequence?) {
     editText.setText(text)
     editText.setSelection(editText.text.length)
diff --git a/app/src/main/java/de/kuschku/quasseldroid_ng/ui/viewmodel/QuasselViewModel.kt b/app/src/main/java/de/kuschku/quasseldroid_ng/ui/viewmodel/QuasselViewModel.kt
index 3759193a6..faf2619f0 100644
--- a/app/src/main/java/de/kuschku/quasseldroid_ng/ui/viewmodel/QuasselViewModel.kt
+++ b/app/src/main/java/de/kuschku/quasseldroid_ng/ui/viewmodel/QuasselViewModel.kt
@@ -185,18 +185,18 @@ class QuasselViewModel : ViewModel() {
     }
   }
 
-  val lastWord = MutableLiveData<Observable<String>>()
+  val lastWord = MutableLiveData<Observable<Pair<String, IntRange>>>()
 
   val autoCompleteData: LiveData<List<AutoCompleteAdapter.AutoCompleteItem>?> = session.zip(
     buffer, lastWord
   ).switchMapRx { (session, id, lastWordWrapper) ->
     lastWordWrapper
       .distinctUntilChanged()
-      .debounce(300, TimeUnit.MILLISECONDS)
+      .debounce(16, TimeUnit.MILLISECONDS)
       .switchMap { lastWord ->
         val bufferSyncer = session?.bufferSyncer
         val bufferInfo = bufferSyncer?.bufferInfo(id)
-        if (bufferSyncer != null && lastWord.length >= 3) {
+        if (bufferSyncer != null && lastWord.second.length >= 3) {
           bufferSyncer.liveBufferInfos().switchMap { infos ->
             if (bufferInfo?.type?.hasFlag(
                 Buffer_Type.ChannelBuffer
@@ -270,7 +270,7 @@ class QuasselViewModel : ViewModel() {
                         .filter {
                           it.name.trimStart(*ignoredStartingCharacters)
                             .startsWith(
-                              lastWord.trimStart(*ignoredStartingCharacters),
+                              lastWord.first.trimStart(*ignoredStartingCharacters),
                               ignoreCase = true
                             )
                         }.sorted()
@@ -491,4 +491,4 @@ class QuasselViewModel : ViewModel() {
     selectedBufferId.postValue(-1)
     collapsedNetworks.value = emptySet()
   }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/de/kuschku/quasseldroid_ng/util/helper/IntProgressionHelper.kt b/app/src/main/java/de/kuschku/quasseldroid_ng/util/helper/IntProgressionHelper.kt
new file mode 100644
index 000000000..28ea37162
--- /dev/null
+++ b/app/src/main/java/de/kuschku/quasseldroid_ng/util/helper/IntProgressionHelper.kt
@@ -0,0 +1,4 @@
+package de.kuschku.quasseldroid_ng.util.helper
+
+val IntProgression.length: Int
+  get() = this.last + 1 - this.first
\ No newline at end of file
-- 
GitLab