package jp.juggler.subwaytooter.actpost import android.os.Handler import android.text.* import android.text.style.ForegroundColorSpan import android.view.View import androidx.appcompat.app.AppCompatActivity import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.api.entity.TootTag import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.dialog.launchEmojiPicker import jp.juggler.subwaytooter.emoji.CustomEmoji import jp.juggler.subwaytooter.emoji.EmojiBase import jp.juggler.subwaytooter.emoji.UnicodeEmoji import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.span.NetworkEmojiSpan import jp.juggler.subwaytooter.table.* import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.subwaytooter.util.EmojiDecoder import jp.juggler.subwaytooter.util.PopupAutoCompleteAcct import jp.juggler.subwaytooter.view.MyEditText import jp.juggler.util.* import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.asciiRegex import jp.juggler.util.log.LogCategory import jp.juggler.util.ui.attrColor import jp.juggler.util.ui.showKeyboard import kotlin.math.min // 入力補完機能 class CompletionHelper( private val activity: AppCompatActivity, private val handler: Handler, ) { companion object { private val log = LogCategory("CompletionHelper") private val reCharsNotEmoji = "[^0-9A-Za-z_-]".asciiRegex() // 無視するスパン // ($を.に変換済) val ignoreSpans = setOf( "android.text.Selection.END", "android.text.Selection.START", "android.widget.Editor.SpanController", "android.widget.TextView.ChangeWatcher", "androidx.emoji2.text.SpannableBuilder.WatcherWrapper", "androidx.emoji2.viewsintegration.EmojiKeyListener", "android.text.DynamicLayout.ChangeWatcher", "android.text.method.TextKeyListener", "android.text.method.Touch.DragState", "android.text.style.SpellCheckSpan", ) private val reRemoveSpan = """\Qandroid.text.style.\E.+Span""".toRegex() private fun matchUserNameOrAsciiDomain(cp: Int): Boolean { if (cp >= 0x7f) return false val c = cp.toChar() return '0' <= c && c <= '9' || 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || c == '_' || c == '-' || c == '.' } // Letter | Mark | Decimal_Number | Connector_Punctuation private fun matchIdnWord(cp: Int) = when (Character.getType(cp).toByte()) { // Letter // LCはエイリアスなので文字から得られることはないはず Character.UPPERCASE_LETTER, Character.LOWERCASE_LETTER, Character.TITLECASE_LETTER, Character.MODIFIER_LETTER, Character.OTHER_LETTER, -> true // Mark Character.NON_SPACING_MARK, Character.COMBINING_SPACING_MARK, Character.ENCLOSING_MARK, -> true // Decimal_Number Character.DECIMAL_DIGIT_NUMBER -> true // Connector_Punctuation Character.CONNECTOR_PUNCTUATION -> true else -> false } } interface Callback2 { fun onTextUpdate() fun canOpenPopup(): Boolean } private val pickerCaptionEmoji: String by lazy { activity.getString(R.string.open_picker_emoji) } private var callback2: Callback2? = null private var et: MyEditText? = null private var popup: PopupAutoCompleteAcct? = null private var formRoot: View? = null private var bMainScreen: Boolean = false private var accessInfo: SavedAccount? = null private val onEmojiListLoad: (list: List) -> Unit = { if (popup?.isShowing == true) procTextChanged.run() } private val procTextChanged: Runnable = Runnable { val et = this.et if (et == null || et.selectionStart != et.selectionEnd || callback2?.canOpenPopup() != true) { // EditTextを特定できない // 範囲選択中 // 何らかの理由でポップアップが許可されていない closeAcctPopup() } else { checkMention(et, et.text.toString()) } } private fun checkMention(et: MyEditText, src: String) { // 選択範囲末尾からスキャン var countAtmark = 0 var start: Int = -1 val end = et.selectionEnd var i = end while (i > 0) { val cp = src.codePointBefore(i) i -= Character.charCount(cp) if (cp == '@'.code) { start = i if (++countAtmark >= 2) break else continue } else if (countAtmark == 1) { // @username@host の username部分はUnicodeを含まない if (matchUserNameOrAsciiDomain(cp)) continue else break } else { // @username@host のhost 部分か、 @username のusername部分 // ここはUnicodeを含むかもしれない if (matchUserNameOrAsciiDomain(cp) || matchIdnWord(cp)) continue else break } } if (start == -1) { checkTag(et, src) return } // 最低でも2文字ないと補完しない if (end - start < 2) { closeAcctPopup() return } val limit = 100 val s = src.substring(start, end) val acctList = daoAcctSet.searchPrefix(s, limit) log.d("search for $s, result=${acctList.size}") if (acctList.isEmpty()) { closeAcctPopup() } else { openPopup()?.setList(et, start, end, acctList, null, null) } } private fun checkTag(et: MyEditText, src: String) { val end = et.selectionEnd val lastSharp = src.lastIndexOf('#', end - 1) if (lastSharp == -1 || end - lastSharp < 2) { checkEmoji(et, src) return } val part = src.substring(lastSharp + 1, end) if (!TootTag.isValid(part, accessInfo?.isMisskey == true)) { checkEmoji(et, src) return } val limit = 100 val s = src.substring(lastSharp + 1, end) val tagList = daoTagHistory.searchPrefix(s, limit) log.d("search for $s, result=${tagList.size}") if (tagList.isEmpty()) { closeAcctPopup() } else { openPopup()?.setList(et, lastSharp, end, tagList, null, null) } } private fun checkEmoji(et: MyEditText, src: String) { val end = et.selectionEnd val lastColon = src.lastIndexOf(':', end - 1) if (lastColon == -1 || end - lastColon < 1) { closeAcctPopup() return } if (!EmojiDecoder.canStartShortCode(src, lastColon)) { // : の手前は始端か改行か空白でなければならない log.d("checkEmoji: invalid character before shortcode.") closeAcctPopup() return } val part = src.substring(lastColon + 1, end) if (part.isEmpty()) { // :を入力した直後は候補は0で、「閉じる」と「絵文字を選ぶ」だけが表示されたポップアップを出す openPopup()?.setList( et, lastColon, end, null, pickerCaptionEmoji, openPickerEmoji ) return } if (reCharsNotEmoji.containsMatchIn(part)) { // 範囲内に絵文字に使えない文字がある closeAcctPopup() return } val codeList = ArrayList() val limit = 100 // カスタム絵文字の候補を部分一致検索 codeList.addAll(customEmojiCodeList(accessInfo, limit, part)) // 通常の絵文字を部分一致で検索 val remain = limit - codeList.size if (remain > 0) { val s = src.substring(lastColon + 1, end) .lowercase() .replace('-', '_') val matches = EmojiDecoder.searchShortCode(activity, s, remain) log.d("checkEmoji: search for $s, result=${matches.size}") codeList.addAll(matches) } openPopup()?.setList( et, lastColon, end, codeList, pickerCaptionEmoji, openPickerEmoji ) } // カスタム絵文字の候補を作る private fun customEmojiCodeList( accessInfo: SavedAccount?, @Suppress("SameParameterValue") limit: Int, needle: String, ) = buildList { accessInfo ?: return@buildList val customList = App1.custom_emoji_lister.tryGetList( accessInfo, withAliases = true, callback = onEmojiListLoad ) ?: return@buildList for (item in customList) { if (size >= limit) break if (!item.shortcode.contains(needle)) continue val sb = SpannableStringBuilder() sb.append(' ') sb.setSpan( NetworkEmojiSpan(item.url), 0, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) sb.append(' ') if (item.alias != null) { val start = sb.length sb.append(":") sb.append(item.alias) sb.append(": → ") sb.setSpan( ForegroundColorSpan(activity.attrColor(R.attr.colorTimeSmall)), start, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) } sb.append(':') sb.append(item.shortcode) sb.append(':') add(sb) } } private fun openPopup(): PopupAutoCompleteAcct? { var popup = this@CompletionHelper.popup if (popup?.isShowing == true) return popup val et = this@CompletionHelper.et ?: return null val formRoot = this@CompletionHelper.formRoot ?: return null popup = PopupAutoCompleteAcct(activity, et, formRoot, bMainScreen) this@CompletionHelper.popup = popup return popup } fun setInstance(accessInfo: SavedAccount?) { this.accessInfo = accessInfo accessInfo?.let { App1.custom_emoji_lister.tryGetList( it, callback = onEmojiListLoad ) } if (popup?.isShowing == true) procTextChanged.run() } fun closeAcctPopup() { popup?.dismiss() popup = null } fun onScrollChanged() { popup?.takeIf { it.isShowing }?.updatePosition() } fun onDestroy() { handler.removeCallbacks(procTextChanged) closeAcctPopup() } fun attachEditText( formRoot: View, et: MyEditText, bMainScreen: Boolean, callback2: Callback2, ) { this.formRoot = formRoot this.et = et this.callback2 = callback2 this.bMainScreen = bMainScreen et.addTextChangedListener(object : TextWatcher { override fun beforeTextChanged( s: CharSequence, start: Int, count: Int, after: Int, ) { } override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { handler.removeCallbacks(procTextChanged) handler.postDelayed(procTextChanged, if (popup?.isShowing == true) 100L else 500L) } override fun afterTextChanged(s: Editable) { // ペースト時に余計な装飾を取り除く val spans = s.getSpans(0, s.length, Any::class.java) val isImeComposing = spans.any { it?.javaClass?.name == "android.view.inputmethod.ComposingText" } if (!isImeComposing) { spans?.filter { val name = (it?.javaClass?.name ?: "").replace('$', '.') when { ignoreSpans.contains(name) -> false reRemoveSpan.matches(name) -> { log.i("span remove $name") true } else -> { log.i("span keep $name") false } } } ?.map { Triple(it, s.getSpanStart(it), s.getSpanEnd(it)) } ?.sortedBy { -it.second } ?.forEach { s.removeSpan(it.first) } } this@CompletionHelper.callback2?.onTextUpdate() } }) // 範囲選択されてるならポップアップは閉じる et.onSelectionChange = { selStart, selEnd -> if (selStart != selEnd) { log.d("onSelectionChange: range selected") closeAcctPopup() } } // 全然動いてなさそう… // et.setCustomSelectionActionModeCallback( action_mode_callback ); } private fun SpannableStringBuilder.appendEmoji( emoji: EmojiBase, bInstanceHasCustomEmoji: Boolean, ) = appendEmoji(bInstanceHasCustomEmoji, emoji) private fun SpannableStringBuilder.appendEmoji( bInstanceHasCustomEmoji: Boolean, emoji: EmojiBase, ): SpannableStringBuilder { val separator = EmojiDecoder.customEmojiSeparator() when (emoji) { is CustomEmoji -> { // カスタム絵文字は常にshortcode表現 if (!EmojiDecoder.canStartShortCode(this, this.length)) append(separator) this.append(SpannableString(":${emoji.shortcode}:")) // セパレータにZWSPを使う設定なら、補完した次の位置にもZWSPを追加する。連続して入力補完できるようになる。 if (separator != ' ') append(separator) } is UnicodeEmoji -> { if (!bInstanceHasCustomEmoji) { // 古いタンスだとshortcodeを使う。見た目は絵文字に変える。 if (!EmojiDecoder.canStartShortCode(this, this.length)) append(separator) this.append(DecodeOptions(activity).decodeEmoji(":${emoji.unifiedName}:")) // セパレータにZWSPを使う設定なら、補完した次の位置にもZWSPを追加する。連続して入力補完できるようになる。 if (separator != ' ') append(separator) } else { // 十分に新しいタンスなら絵文字のunicodeを使う。見た目は絵文字に変える。 this.append(DecodeOptions(activity).decodeEmoji(emoji.unifiedCode)) } } } return this } private val openPickerEmoji: Runnable = Runnable { launchEmojiPicker( activity, accessInfo, closeOnSelected = PrefB.bpEmojiPickerCloseOnSelected.value ) { emoji, bInstanceHasCustomEmoji -> val et = this@CompletionHelper.et ?: return@launchEmojiPicker val src = et.text ?: "" val srcLength = src.length val end = min(srcLength, et.selectionEnd) val start = src.lastIndexOf(':', end - 1) if (start == -1 || end - start < 1) return@launchEmojiPicker val sb = SpannableStringBuilder() .append(src.subSequence(0, start)) .appendEmoji(emoji, bInstanceHasCustomEmoji) val newSelection = sb.length if (end < srcLength) sb.append(src.subSequence(end, srcLength)) et.text = sb et.setSelection(newSelection) procTextChanged.run() // キーボードを再度表示する App1.getAppState( activity, "PostHelper/EmojiPicker/cb" ).handler.post { et.showKeyboard() } } } fun openEmojiPickerFromMore() { launchEmojiPicker( activity, accessInfo, closeOnSelected = PrefB.bpEmojiPickerCloseOnSelected.value ) { emoji, bInstanceHasCustomEmoji -> val et = this@CompletionHelper.et ?: return@launchEmojiPicker val src = et.text ?: "" val srcLength = src.length val start = min(srcLength, et.selectionStart) val end = min(srcLength, et.selectionEnd) val sb = SpannableStringBuilder() .append(src.subSequence(0, start)) .appendEmoji(emoji, bInstanceHasCustomEmoji) val newSelection = sb.length if (end < srcLength) sb.append(src.subSequence(end, srcLength)) et.text = sb et.setSelection(newSelection) procTextChanged.run() } } private fun SpannableStringBuilder.appendHashTag(tagWithoutSharp: String): SpannableStringBuilder { val separator = ' ' if (!EmojiDecoder.canStartHashtag(this, this.length)) append(separator) this.append('#').append(tagWithoutSharp) append(separator) return this } fun openFeaturedTagList(list: List?) { val et = this@CompletionHelper.et ?: return activity.run { launchAndShowError { actionsDialog(getString(R.string.featured_hashtags)) { list?.forEach { tag -> action("#${tag.name}") { val src = et.text ?: "" val srcLength = src.length val start = min(srcLength, et.selectionStart) val end = min(srcLength, et.selectionEnd) val sb = SpannableStringBuilder() .append(src.subSequence(0, start)) .appendHashTag(tag.name) val newSelection = sb.length if (end < srcLength) sb.append(src.subSequence(end, srcLength)) et.text = sb et.setSelection(newSelection) procTextChanged.run() } } action(activity.getString(R.string.input_sharp_itself)) { val src = et.text ?: "" val srcLength = src.length val start = min(srcLength, et.selectionStart) val end = min(srcLength, et.selectionEnd) val sb = SpannableStringBuilder() sb.append(src.subSequence(0, start)) if (!EmojiDecoder.canStartHashtag(sb, sb.length)) sb.append(' ') sb.append('#') val newSelection = sb.length if (end < srcLength) sb.append(src.subSequence(end, srcLength)) et.text = sb et.setSelection(newSelection) procTextChanged.run() } } } } } // final ActionMode.Callback action_mode_callback = new ActionMode.Callback() { // @Override public boolean onCreateActionMode( ActionMode actionMode, Menu menu ){ // actionMode.getMenuInflater().inflate(R.menu.toot_long_tap, menu); // return true; // } // @Override public void onDestroyActionMode( ActionMode actionMode ){ // // } // @Override public boolean onPrepareActionMode( ActionMode actionMode, Menu menu ){ // return false; // } // // @Override // public boolean onActionItemClicked( ActionMode actionMode, MenuItem item ){ // if (item.getItemId() == R.id.action_pick_emoji) { // actionMode.finish(); // EmojiPicker.open( activity, instance, new EmojiPicker.Callback() { // @Override public void onPickedEmoji( String name ){ // int end = et.getSelectionEnd(); // String src = et.getText().toString(); // CharSequence svInsert = ":" + name + ":"; // src = src.substring( 0, end ) + svInsert + " " + ( end >= src.length() ? "" : src.substring( end ) ); // et.setText( src ); // et.setSelection( end + svInsert.length() + 1 ); // // proc_text_changed.run(); // } // } ); // return true; // } // // return false; // } // }; }