選択してコピー画面にテキスト欄の検索機能を追加

This commit is contained in:
tateisu 2023-05-04 19:13:13 +09:00
parent 75e6001fcd
commit 1ccd05e08a
4 changed files with 277 additions and 54 deletions

View File

@ -4,9 +4,16 @@ import android.app.SearchManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.text.Spanned
import android.text.style.BackgroundColorSpan
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import jp.juggler.subwaytooter.api.entity.TootAccount
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.databinding.ActTextBinding
@ -19,12 +26,17 @@ import jp.juggler.subwaytooter.util.CustomShareTarget
import jp.juggler.subwaytooter.util.TootTextEncoder
import jp.juggler.subwaytooter.util.copyToClipboard
import jp.juggler.util.*
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.ui.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ActText : AppCompatActivity() {
@ -60,6 +72,8 @@ class ActText : AppCompatActivity() {
}
}
class SearchHilightSpan(color: Int) : BackgroundColorSpan(color)
private var account: SavedAccount? = null
private val views by lazy {
@ -79,54 +93,45 @@ class ActText : AppCompatActivity() {
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.btnCopy -> selection.copyToClipboard(this)
private val searchTextChannel = Channel<Long>(capacity = Channel.CONFLATED)
R.id.btnSearch -> search()
private var searchResult: List<Int> = emptyList()
private var searchKeywordLength = 0
R.id.btnSend -> send()
R.id.btnMuteWord -> muteWord()
R.id.btnTranslate -> CustomShare.invokeText(
CustomShareTarget.Translate,
this,
selection,
)
// MSP検索ボタン -> searchToot(RESULT_SEARCH_MSP)
// R.id.btnSearchTS -> searchToot(RESULT_SEARCH_TS)
R.id.btnSearchNotestock -> searchToot(RESULT_SEARCH_NOTESTOCK)
R.id.btnKeywordFilter -> keywordFilter()
R.id.btnHighlight -> highlight()
// If we got here, the user's action was not recognized.
// Invoke the superclass to handle it.
else -> return super.onOptionsItemSelected(item)
}
return true
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.act_text, menu)
return super.onCreateOptionsMenu(menu)
}
private val handler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
App1.setActivityTheme(this)
initUI()
setContentView(views.root)
setSupportActionBar(views.toolbar)
setNavigationBack(views.toolbar)
fixHorizontalMargin(views.etText)
views.etSearch.addTextChangedListener { postSearchText() }
views.btnSearchClear.setOnClickListener { views.etSearch.setText("") }
views.btnSearchPrev.setOnClickListener { searchPrev() }
views.btnSearchNext.setOnClickListener { searchNext() }
lifecycleScope.launch {
while (true) {
try {
searchTextChannel.receive()
searchTextImpl()
} catch (ex: Throwable) {
if (ex is CancellationException) break
log.e(ex, "searchTextChannel failed.")
}
}
}
launchAndShowError {
account = intent.long(EXTRA_ACCOUNT_DB_ID)
?.let { daoSavedAccount.loadAccount(it) }
if (savedInstanceState == null) {
views.llSearchResult.gone()
val sv = intent.string(EXTRA_TEXT) ?: ""
val contentStart = intent.int(EXTRA_CONTENT_START) ?: 0
val contentEnd = intent.int(EXTRA_CONTENT_END) ?: sv.length
@ -143,11 +148,38 @@ class ActText : AppCompatActivity() {
}
}
internal fun initUI() {
setContentView(views.root)
setSupportActionBar(views.toolbar)
setNavigationBack(views.toolbar)
fixHorizontalMargin(views.etText)
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
postSearchText()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.act_text, menu)
super.onCreateOptionsMenu(menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.miCopy -> selection.copyToClipboard(this)
R.id.miSearch -> search()
R.id.miSend -> send()
R.id.miMuteWord -> muteWord()
R.id.miSearchNotestock -> searchToot(RESULT_SEARCH_NOTESTOCK)
R.id.miKeywordFilter -> keywordFilter()
R.id.miHighlight -> highlight()
// MSP検索ボタン -> searchToot(RESULT_SEARCH_MSP)
// R.id.btnSearchTS -> searchToot(RESULT_SEARCH_TS)
R.id.miTranslate -> CustomShare.invokeText(
CustomShareTarget.Translate,
this,
selection,
)
else -> return super.onOptionsItemSelected(item)
}
return true
}
private fun send() {
@ -230,4 +262,122 @@ class ActText : AppCompatActivity() {
startActivity(ActHighlightWordEdit.createIntent(this, it))
}
}
private fun postSearchText() {
lifecycleScope.launch {
try {
searchTextChannel.send(SystemClock.elapsedRealtime())
} catch (ex: Throwable) {
log.e(ex, "postSearchText failed.")
}
}
}
private suspend fun searchTextImpl() {
val keyword = views.etSearch.text?.toString() ?: ""
val content = views.etText.text?.toString() ?: ""
val searchResult: List<Int> = withContext(AppDispatchers.IO) {
if (keyword.isEmpty()) {
emptyList()
} else {
buildList {
var nextStart = 0
while (nextStart < content.length) {
val pos = content.indexOf(
keyword,
startIndex = nextStart,
ignoreCase = true
)
if (pos == -1) break
add(pos)
nextStart = pos + keyword.length
}
}
}
}
this.searchResult = searchResult
this.searchKeywordLength = keyword.length
views.btnSearchClear.isEnabledAlpha = keyword.isNotEmpty()
views.btnSearchPrev.isEnabledAlpha = searchResult.isNotEmpty()
views.btnSearchNext.isEnabledAlpha = searchResult.isNotEmpty()
when {
searchResult.isEmpty() -> {
views.llSearchResult.gone()
searchHighlight(null)
}
else -> searchNext(byTextUpdate=true)
}
}
private fun searchNext(byTextUpdate: Boolean=false) {
try {
val curPos = views.etText.selectionStart
val newPos = when {
byTextUpdate -> searchResult.find { it >= curPos }
else -> searchResult.find { it > curPos }
} ?: searchResult.firstOrNull()
searchJump(newPos)
} catch (ex: Throwable) {
log.e(ex, "searchNext failed.")
}
}
private fun searchPrev() {
try {
val curPos = views.etText.selectionStart.takeIf { it >= 0 }
?: views.etText.text?.length
?: return
val newPos = searchResult.findLast { it < curPos }
?: searchResult.lastOrNull()
searchJump(newPos)
} catch (ex: Throwable) {
log.e(ex, "searchPrev failed.")
}
}
private fun searchJump(newPos: Int?) {
val idx = when (newPos) {
null -> null
else -> {
val end = views.etText.text?.length ?: 0
views.etText.setSelection(
newPos.clip(0, end),
(newPos + searchKeywordLength).clip(0, end),
)
searchResult.indexOf(newPos).takeIf { it >= 0 }?.plus(1)
}
}
views.llSearchResult.visible()
views.tvSearchCount.text = getString(
R.string.search_result,
idx?.toString() ?: "?",
searchResult.size
)
searchHighlight(newPos)
}
private fun searchHighlight(newPos: Int?) {
views.etText.text?.let { e ->
for (span in e.getSpans(0, e.length, SearchHilightSpan::class.java)) {
try {
e.removeSpan(span)
} catch (ignored: Throwable) {
}
}
for (pos in searchResult) {
val attrId = when (newPos) {
pos -> R.attr.colorSearchFormBackground
else -> R.attr.colorButtonBgCw
}
e.setSpan(
SearchHilightSpan(attrColor(attrId)),
pos,
pos + searchKeywordLength,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
}
}
}
}

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
@ -10,8 +11,79 @@
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@drawable/action_bar_bg"
app:navigationIcon="?attr/homeAsUpIndicator"
android:elevation="4dp" />
android:elevation="4dp"
app:navigationIcon="?attr/homeAsUpIndicator" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSearchFormBackground"
android:orientation="vertical"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal">
<jp.juggler.subwaytooter.view.MyEditText
android:id="@+id/etSearch"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/search"
android:inputType="text" />
<ImageButton
android:id="@+id/btnSearchClear"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:contentDescription="@string/clear"
android:src="@drawable/ic_close"
app:tint="?attr/colorTextContent" />
</LinearLayout>
<LinearLayout
android:id="@+id/llSearchResult"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tvSearchCount"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
tools:text="12/345" />
<ImageButton
android:id="@+id/btnSearchPrev"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:contentDescription="@string/previous"
android:src="@drawable/ic_arrow_drop_up"
app:tint="?attr/colorTextContent" />
<ImageButton
android:id="@+id/btnSearchNext"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:contentDescription="@string/next"
android:src="@drawable/ic_arrow_drop_down"
app:tint="?attr/colorTextContent" />
</LinearLayout>
</LinearLayout>
<jp.juggler.subwaytooter.view.MyEditText
android:id="@+id/etText"

View File

@ -3,47 +3,47 @@
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/btnCopy"
android:id="@+id/miCopy"
android:title="@string/copy_st"
app:showAsAction="ifRoom" />
<item
android:id="@+id/btnSend"
android:id="@+id/miSend"
android:title="@string/send"
app:showAsAction="never" />
<item
android:id="@+id/btnSearch"
android:id="@+id/miSearch"
android:title="@string/search_web"
app:showAsAction="never" />
<!-- <item-->
<!-- android:id="@+id/btnSearchTS"-->
<!-- android:title="@string/toot_search_ts"-->
<!-- app:showAsAction="never" />-->
<!-- <item-->
<!-- android:id="@+id/btnSearchTS"-->
<!-- android:title="@string/toot_search_ts"-->
<!-- app:showAsAction="never" />-->
<item
android:id="@+id/btnSearchNotestock"
android:id="@+id/miSearchNotestock"
android:title="@string/toot_search_notestock"
app:showAsAction="never" />
<item
android:id="@+id/btnTranslate"
android:id="@+id/miTranslate"
android:title="@string/translate"
app:showAsAction="never" />
<item
android:id="@+id/btnMuteWord"
android:id="@+id/miMuteWord"
android:title="@string/mute_word"
app:showAsAction="never" />
<item
android:id="@+id/btnKeywordFilter"
android:id="@+id/miKeywordFilter"
android:title="@string/keyword_filter"
app:showAsAction="never" />
<item
android:id="@+id/btnHighlight"
android:id="@+id/miHighlight"
android:title="@string/highlight_word"
app:showAsAction="never" />

View File

@ -1295,4 +1295,5 @@
<string name="save_to_local_folder">Save to local folder</string>
<string name="app_data_export_import">Export/Import app data</string>
<string name="translate_or_custom_share">Translate/Custom share buttons</string>
<string name="search_result" translatable="false">%1$s/%2$d</string>
</resources>