SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/actpost/CompletionHelper.kt

593 lines
21 KiB
Kotlin

package jp.juggler.subwaytooter.actpost
import android.content.SharedPreferences
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.AcctSet
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.TagSet
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 kotlin.math.min
// 入力補完機能
class CompletionHelper(
private val activity: AppCompatActivity,
private val pref: SharedPreferences,
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<CustomEmoji>) -> 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 = AcctSet.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 = TagSet.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<CharSequence>()
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<CharSequence> {
accessInfo ?: return@buildList
val customList = App1.custom_emoji_lister.getListNonBlocking(
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.getListNonBlocking(
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(pref)
) { 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(pref)
) { 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<TootTag>?) {
val ad = ActionsDialog()
list?.forEach { tag ->
ad.addAction("#${tag.name}") {
val et = this.et ?: return@addAction
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()
}
}
ad.addAction(activity.getString(R.string.input_sharp_itself)) {
val et = this.et ?: return@addAction
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()
}
ad.show(activity, activity.getString(R.string.featured_hashtags))
}
// 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;
// }
// };
}