app/src/main/kotlin/space/subread/dictionary/LookupActivity.kt (10894 bytes)
1 package space.subread.dictionary 2 3 import android.annotation.SuppressLint 4 import android.app.Activity 5 import android.app.AlertDialog 6 import android.content.Intent 7 import android.graphics.Color 8 import android.graphics.Typeface 9 import android.os.Bundle 10 import android.text.Html 11 import android.text.SpannableString 12 import android.text.Spanned 13 import android.text.method.LinkMovementMethod 14 import android.text.style.BackgroundColorSpan 15 import android.view.Gravity 16 import android.view.MotionEvent 17 import android.view.View 18 import android.view.ViewGroup 19 import android.view.WindowManager 20 import android.widget.Button 21 import android.widget.LinearLayout 22 import android.widget.ScrollView 23 import android.widget.TextView 24 import android.widget.Toast 25 import space.subread.dictionary.core.Glossary 26 import space.subread.dictionary.core.Lookup 27 import space.subread.dictionary.core.Match 28 import kotlin.concurrent.thread 29 30 /** 31 * The pop-up. It takes a text from the text selection menu, from the share sheet, or from an 32 * intent, and shows the terms at the scan position: the longest first. A tap on a character of 33 * the text moves the scan position there. 34 */ 35 class LookupActivity : Activity() { 36 37 private lateinit var store: Store 38 private lateinit var dictionaries: Dictionaries 39 private lateinit var player: AudioPlayer 40 private lateinit var textView: TextView 41 private lateinit var results: LinearLayout 42 private var text = "" 43 private var offset = 0 44 private var generation = 0 45 46 // The touch listener finds the character under the finger; it calls performClick itself. 47 @SuppressLint("ClickableViewAccessibility") 48 override fun onCreate(savedInstanceState: Bundle?) { 49 super.onCreate(savedInstanceState) 50 store = Store(this) 51 dictionaries = Dictionaries(this) 52 player = AudioPlayer(this) 53 54 // A panel at the bottom, over the app that sent the text. The height is fixed, so the 55 // panel does not grow with the results: they scroll inside it. 56 val height = (resources.displayMetrics.heightPixels * 0.6).toInt() 57 window.attributes = window.attributes.apply { 58 width = ViewGroup.LayoutParams.MATCH_PARENT 59 this.height = height 60 gravity = Gravity.BOTTOM 61 } 62 window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) 63 64 textView = TextView(this).apply { 65 textSize = store.textSizeSp + 3 66 setTextColor(Color.BLACK) 67 maxLines = 4 68 setPadding(dp(16), dp(12), dp(16), dp(8)) 69 setOnTouchListener { view, event -> 70 if (event.action == MotionEvent.ACTION_UP) { 71 val at = (view as TextView).getOffsetForPosition(event.x, event.y) 72 if (at in text.indices) scan(at) 73 view.performClick() 74 } 75 true 76 } 77 } 78 results = LinearLayout(this).apply { 79 orientation = LinearLayout.VERTICAL 80 setPadding(dp(16), 0, dp(16), dp(16)) 81 } 82 val bar = LinearLayout(this).apply { 83 gravity = Gravity.END 84 addView(smallButton(getString(R.string.settings)) { startActivity(Intent(this@LookupActivity, MainActivity::class.java)) }) 85 addView(smallButton(getString(R.string.close)) { finish() }) 86 } 87 setContentView( 88 LinearLayout(this).apply { 89 orientation = LinearLayout.VERTICAL 90 setBackgroundColor(Color.WHITE) 91 addView(bar, LinearLayout.LayoutParams(-1, -2)) 92 addView(textView, LinearLayout.LayoutParams(-1, -2)) 93 addView(View(this@LookupActivity).apply { setBackgroundColor(Color.LTGRAY) }, LinearLayout.LayoutParams(-1, dp(1))) 94 addView(ScrollView(this@LookupActivity).apply { addView(results) }, LinearLayout.LayoutParams(-1, 0, 1f)) 95 }, 96 ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height), 97 ) 98 take(intent) 99 } 100 101 override fun onNewIntent(intent: Intent) { 102 super.onNewIntent(intent) 103 setIntent(intent) 104 take(intent) 105 } 106 107 override fun onDestroy() { 108 player.stop() 109 dictionaries.close() 110 super.onDestroy() 111 } 112 113 /** The text of the intent, from whichever of the three ways in. */ 114 private fun take(intent: Intent) { 115 text = (intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT) ?: intent.getCharSequenceExtra(Intent.EXTRA_TEXT) ?: "") 116 .toString().trim() 117 if (text.isEmpty()) { 118 Toast.makeText(this, R.string.no_text, Toast.LENGTH_SHORT).show() 119 finish() 120 return 121 } 122 scan(0) 123 } 124 125 private fun scan(at: Int) { 126 offset = at 127 val shown = SpannableString(text) 128 textView.text = shown 129 val run = ++generation 130 thread { 131 val found = if (dictionaries.anyEnabled()) Lookup.find(text, at, dictionaries) else null 132 val metas = found.orEmpty().map { it.stored.term.expression }.distinct().associateWith { dictionaries.meta(it) } 133 runOnUiThread { if (run == generation) show(found, metas) } 134 } 135 } 136 137 private fun show(found: List<Match>?, metas: Map<String, List<StoredMeta>>) { 138 results.removeAllViews() 139 if (found == null) { 140 note(getString(R.string.no_dictionaries)) 141 return 142 } 143 if (found.isEmpty()) { 144 note(getString(R.string.no_results)) 145 return 146 } 147 // The longest match is marked in the text. 148 val shown = SpannableString(text) 149 shown.setSpan(BackgroundColorSpan(0xFFFFE082.toInt()), offset, offset + found.first().length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) 150 textView.text = shown 151 152 // One card per expression and reading, with the entries of each dictionary inside it. 153 val groups = found.groupBy { it.stored.term.expression to it.stored.term.reading } 154 for ((key, matches) in groups) { 155 val (expression, reading) = key 156 results.addView(View(this).apply { setBackgroundColor(Color.LTGRAY) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(10) }) 157 results.addView(LinearLayout(this).apply { 158 gravity = Gravity.CENTER_VERTICAL 159 addView(TextView(this@LookupActivity).apply { 160 text = if (reading.isEmpty() || reading == expression) expression else "$expression【$reading】" 161 textSize = store.textSizeSp + 5 162 setTypeface(typeface, Typeface.BOLD) 163 setTextColor(Color.BLACK) 164 }, LinearLayout.LayoutParams(0, -2, 1f)) 165 addView(smallButton("▶ " + getString(R.string.audio_play)) { play(expression, reading, choose = false) }.apply { 166 setOnLongClickListener { play(expression, reading, choose = true); true } 167 }) 168 }, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(6) }) 169 170 val reasons = matches.first().reasons 171 val meta = metas[expression].orEmpty().filter { m -> 172 val forReading = when (m.meta.mode) { "freq" -> Glossary.frequencyReading(m.meta.data); "pitch" -> Glossary.pitchReading(m.meta.data); else -> null } 173 forReading == null || forReading == reading || reading.isEmpty() 174 } 175 val line = buildList { 176 if (reasons.isNotEmpty()) add(reasons.joinToString(" ‹ ")) 177 for (m in meta) when (m.meta.mode) { 178 "freq" -> add("${m.dictionaryTitle}: ${Glossary.frequencyText(m.meta.data)}") 179 "pitch" -> add(Glossary.pitchText(m.meta.data)) 180 } 181 } 182 if (line.isNotEmpty()) note(line.joinToString(" "), color = Color.DKGRAY, size = store.textSizeSp - 3) 183 184 for (match in matches) { 185 val term = match.stored.term 186 val tags = (term.definitionTags.split(' ') + term.termTags.split(' ')).filter { it.isNotEmpty() }.distinct() 187 note( 188 listOf(match.stored.dictionaryTitle).plus(tags).joinToString(" "), 189 color = Color.GRAY, 190 size = store.textSizeSp - 4, 191 top = 6, 192 ) 193 results.addView(TextView(this).apply { 194 text = Html.fromHtml(Glossary.toHtml(term.glossary), Html.FROM_HTML_MODE_COMPACT) 195 textSize = store.textSizeSp 196 setTextColor(Color.BLACK) 197 movementMethod = LinkMovementMethod.getInstance() 198 }, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(2) }) 199 } 200 } 201 } 202 203 /** Plays the first source that has audio: the local ones in their order, then the remote ones. */ 204 private fun play(expression: String, reading: String, choose: Boolean) { 205 thread { 206 val sources = LocalAudio(store.localAudioFile, store.localSources.split(',').map { it.trim() }).sources(expression, reading) + 207 if (store.remoteEnabled) RemoteAudio.sources(store.remoteUrls.lines().map { it.trim() }.filter { it.isNotEmpty() }, expression, reading) else emptyList() 208 if (choose) { 209 runOnUiThread { 210 if (sources.isEmpty()) toast(getString(R.string.no_audio)) else { 211 AlertDialog.Builder(this).setItems(sources.map { it.name }.toTypedArray()) { _, i -> thread { playFirst(listOf(sources[i])) } }.show() 212 } 213 } 214 } else { 215 playFirst(sources) 216 } 217 } 218 } 219 220 private fun playFirst(sources: List<AudioSource>) { 221 for (source in sources) { 222 val bytes = runCatching { source.load() }.getOrNull() ?: continue 223 runOnUiThread { 224 runCatching { player.play(bytes) }.onFailure { toast(getString(R.string.audio_failed, it.message ?: "")) } 225 } 226 return 227 } 228 runOnUiThread { toast(getString(R.string.no_audio)) } 229 } 230 231 private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show() 232 233 private fun note(value: String, color: Int = Color.BLACK, size: Float = store.textSizeSp, top: Int = 8) = results.addView( 234 TextView(this).apply { 235 text = value 236 textSize = size 237 setTextColor(color) 238 }, 239 LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(top) }, 240 ) 241 242 private fun smallButton(label: String, onClick: () -> Unit) = Button(this).apply { 243 text = label 244 isAllCaps = false 245 textSize = 13f 246 setOnClickListener { onClick() } 247 } 248 249 private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() 250 }