Integrate ASR/Omni fixes, API Route, Android IME and confirmation-based vocabulary learning. Complete settings transitions and macOS single-item credential storage. Synchronize all application versions to 2.0.0-Beta.4+build.20260930. Integrated PRs: #1116, #1120, #1121, #1123, #1124, #1067 and #1128. Linux #1117 remains independent and is excluded from automatic release. Validated: full cross-platform CI, 18 UI motion cases, 112 frontend test files, 1245 Core tests (1 ignored) and 572 macOS native tests. Co-authored-by: HKLHaoBin <we3q@qq.com>
5895 lines
298 KiB
Kotlin
5895 lines
298 KiB
Kotlin
package com.openless.app
|
||
|
||
import android.Manifest
|
||
import android.content.Context
|
||
import android.content.res.Configuration
|
||
import android.content.pm.PackageManager
|
||
import android.graphics.Canvas
|
||
import android.graphics.Color
|
||
import android.graphics.LinearGradient
|
||
import android.graphics.Paint
|
||
import android.graphics.Path
|
||
import android.graphics.Shader
|
||
import android.graphics.Typeface
|
||
import android.graphics.drawable.GradientDrawable
|
||
import android.icu.text.Transliterator
|
||
import android.inputmethodservice.InputMethodService
|
||
import android.os.Handler
|
||
import android.os.Looper
|
||
import android.os.Build
|
||
import android.os.VibrationEffect
|
||
import android.os.Vibrator
|
||
import android.os.VibratorManager
|
||
import android.text.InputType
|
||
import android.view.MotionEvent
|
||
import android.view.View
|
||
import android.view.ViewGroup
|
||
import android.view.inputmethod.EditorInfo
|
||
import android.view.inputmethod.InputMethodManager
|
||
import android.widget.FrameLayout
|
||
import android.widget.LinearLayout
|
||
import android.widget.TextView
|
||
import android.widget.Toast
|
||
|
||
/** Minimal system IME surface. Voice transport is intentionally added in a later phase. */
|
||
class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlayStateListener {
|
||
internal enum class InputMode { VOICE, STROKE, CLIPBOARD, ENGLISH }
|
||
private enum class ShiftState { OFF, SHIFT_ONCE, CAPS_LOCK }
|
||
// Mirrors iOS's own ABC/123/#+= three-layer model exactly (see
|
||
// buildKeyboardView()) instead of the old two-state symbolMode boolean,
|
||
// which only ever had room for one merged symbol page.
|
||
private enum class EnglishLayer { LETTERS, NUMBERS, SYMBOLS }
|
||
// The English QWERTY panel's letter-input semantics — ENGLISH commits
|
||
// each letter immediately (see commitEnglishChar()), PINYIN instead
|
||
// routes letters into LitePinyinController's own encoding buffer and
|
||
// only ever commits a chosen candidate. Long-press space toggles this
|
||
// (see buildKeyboardView()'s spaceButton); the panel/View tree itself
|
||
// is never rebuilt into a second layout for PINYIN — see
|
||
// docs/pinyin-lite/phase-0-audit.md section 7.
|
||
internal enum class LatinInputMode { ENGLISH, PINYIN }
|
||
|
||
private var lastCancelledImeRequest = 0L
|
||
private var imeRequestId = 0L
|
||
private var imeSessionId: String? = null
|
||
private var pendingCloudArm = false
|
||
private var pendingImeStop = false
|
||
private var cloudNoteDestination: Pair<String, String>? = null
|
||
|
||
private fun sendImeCommand(action: String): Boolean = try {
|
||
val request = org.json.JSONObject().apply {
|
||
put("action", action)
|
||
put("requestId", imeRequestId)
|
||
imeSessionId?.let { put("sessionId", it) }
|
||
put("raw", rawModeArmed)
|
||
put("quickNote", quickNoteArmed)
|
||
put("cloud", cloudNoteArmed)
|
||
}
|
||
val response = org.json.JSONObject(OpenLessNative.nativeImeCommand(request.toString()))
|
||
check(response.optBoolean("ok")) { response.optString("error", "IME command failed") }
|
||
true
|
||
} catch (_: Throwable) {
|
||
setState("error", ui("语音操作失败,请重试", "Voice operation failed; please retry"))
|
||
false
|
||
}
|
||
|
||
private fun cancelImeSession() {
|
||
if (imeRequestId != 0L) { lastCancelledImeRequest = imeRequestId; sendImeCommand("cancel") }
|
||
imeRequestId = 0L
|
||
imeSessionId = null
|
||
pendingImeStop = false
|
||
cloudNoteDestination = null
|
||
pendingCloudArm = false
|
||
}
|
||
|
||
private fun stopImeSession() {
|
||
if (imeSessionId == null) pendingImeStop = true
|
||
else if (!sendImeCommand("stop")) { recording = false; processing = false }
|
||
}
|
||
|
||
private fun onImeSessionEvent(json: String) {
|
||
val event = runCatching { org.json.JSONObject(json) }.getOrNull() ?: return
|
||
if (event.optLong("requestId") != imeRequestId || imeRequestId == 0L) {
|
||
if (event.optLong("requestId") == lastCancelledImeRequest && event.optString("error") == "Persistence") {
|
||
Toast.makeText(this, ui("录音清理失败,重新打开应用后重试", "Recording cleanup failed; reopen the app to retry"), Toast.LENGTH_LONG).show()
|
||
}
|
||
return
|
||
}
|
||
when (event.optString("kind")) {
|
||
"started" -> {
|
||
imeSessionId = event.getString("sessionId")
|
||
if (pendingCloudArm) { pendingCloudArm = false; cloudNoteArmed = true }
|
||
if (cloudNoteArmed && !sendImeCommand("cloud")) { cancelImeSession(); return }
|
||
if (pendingImeStop) { pendingImeStop = false; stopImeSession() }
|
||
}
|
||
"completed" -> {
|
||
if (event.optString("sessionId") != imeSessionId) return
|
||
val destination = cloudNoteDestination
|
||
imeSessionId = null
|
||
recording = false
|
||
processing = false
|
||
cloudNoteArmed = false
|
||
if (event.optBoolean("cloud")) submitCloudNoteText(event.optString("text"), destination, imeRequestId)
|
||
else if (event.optBoolean("quickNote")) setState("done", ui("笔记已记录", "Note saved"))
|
||
else commitImeText(event.optString("text"))
|
||
}
|
||
"error" -> {
|
||
recording = false
|
||
processing = false
|
||
imeSessionId = null
|
||
pendingImeStop = false
|
||
pendingCloudArm = false
|
||
setState("error", ui("听写失败,请重试", "Dictation failed; please retry"))
|
||
}
|
||
}
|
||
}
|
||
|
||
private var sessionEpoch = 0L
|
||
// Guards the delayed "settle back to Tap to speak" callback — see
|
||
// scheduleRevertToIdle().
|
||
private var statusRevertToken = 0L
|
||
internal var recording = false
|
||
private var processing = false
|
||
// Armed by the mic button's swipe-up gesture while recording is still
|
||
// in progress (see onCreateInputView()'s voice-panel branch) — recording
|
||
// itself keeps going, this only changes what happens once it eventually
|
||
// stops (skip the LLM polish step). Reset whenever a fresh recording
|
||
// starts or is cancelled, so it never leaks into a later utterance.
|
||
private var rawModeArmed = false
|
||
set(value) {
|
||
if (value) pendingCloudArm = false
|
||
if (value && cloudNoteArmed) { cloudNoteArmed = false; if (cloudNoteArmed) return }
|
||
field = value
|
||
voiceButton?.rawModeActive = value
|
||
// The RAW gesture changes the mode while the recording prompt is
|
||
// already visible. Refresh the text color immediately in both
|
||
// directions so yellow cannot leak into the next normal session.
|
||
status?.setTextColor(recordingAccentColor())
|
||
}
|
||
// Armed by the mic button's swipe-left gesture while recording is still
|
||
// in progress, or set directly by its idle-swipe-left start — mirrors
|
||
// rawModeArmed exactly (see that field's own doc comment), except the
|
||
// eventual stop calls nativeStopDictationAsQuickNote() instead of the
|
||
// normal/raw stop: the dictation ends, gets archived as a standalone
|
||
// note, and is never inserted into the input field (see
|
||
// quickNoteDictation()).
|
||
private var quickNoteArmed = false
|
||
set(value) {
|
||
if (value) pendingCloudArm = false
|
||
if (value && cloudNoteArmed) { cloudNoteArmed = false; if (cloudNoteArmed) return }
|
||
field = value
|
||
voiceButton?.quickNoteActive = value
|
||
status?.setTextColor(recordingAccentColor())
|
||
}
|
||
// The Core session owns retention; this field only displays the accepted destination.
|
||
private var cloudNoteArmed = false
|
||
set(value) {
|
||
if (!value) pendingCloudArm = false
|
||
if (value && imeRequestId != 0L && imeSessionId == null) {
|
||
pendingCloudArm = true
|
||
updateStatus(ui("正在准备云笔记", "Preparing cloud note"))
|
||
return
|
||
}
|
||
if (field == value) return
|
||
val previous = field
|
||
field = value
|
||
if (imeSessionId != null && !sendImeCommand("cloud")) { field = previous; return }
|
||
if (value) {
|
||
rawModeArmed = false
|
||
quickNoteArmed = false
|
||
val prefs = getSharedPreferences("openless_ime_ui", MODE_PRIVATE)
|
||
cloudNoteDestination = (prefs.getString("key_cloud_note_webhook_url", "") ?: "").trim() to
|
||
(prefs.getString("key_cloud_note_webhook_token", "") ?: "").trim()
|
||
}
|
||
voiceButton?.cloudNoteActive = value
|
||
status?.setTextColor(recordingAccentColor())
|
||
}
|
||
|
||
/** Status text color while a recording prompt is showing — orange for an armed Raw stop, green for an armed Quick notes stop, red for an armed Cloud notes submit, normal otherwise. Single source of truth for rawModeArmed/quickNoteArmed/cloudNoteArmed's setters and updateStatus() alike, so the three gestures can never disagree on which one currently owns the color. */
|
||
private fun recordingAccentColor(): Int = when {
|
||
state == "speaking" && rawModeArmed -> LINK_COLOR_RECORDING_RAW
|
||
state == "speaking" && quickNoteArmed -> LINK_COLOR_QUICK_NOTE
|
||
state == "speaking" && cloudNoteArmed -> LINK_COLOR_CLOUD_NOTE
|
||
else -> statusNormalColor
|
||
}
|
||
internal var inputMode = InputMode.VOICE
|
||
private var englishLayer = EnglishLayer.LETTERS
|
||
// Persisted across sessions the same way inputMode is (see
|
||
// restoreLatinInputMode()/saveLatinInputMode()) — long-pressing space
|
||
// toggles it (see buildKeyboardView()'s spaceButton). The space key's
|
||
// own small hint label always names the mode long-press would switch
|
||
// TO, not the current one — i.e. it reads "拼音" while latinInputMode
|
||
// is ENGLISH, and "英文" while it's PINYIN.
|
||
private var latinInputMode = LatinInputMode.ENGLISH
|
||
private val litePinyinController by lazy { LitePinyinController(this) }
|
||
// The word currently being typed on the English keyboard — appended to
|
||
// per letter, trimmed per backspace, cleared at every word boundary
|
||
// (space/return/punctuation/candidate tap/mode or panel switch). Never
|
||
// touches currentInputConnection itself: every letter is still
|
||
// committed immediately, same as before this feature: this buffer only
|
||
// tracks what to re-query/replace for candidates.
|
||
private val englishComposingWord = StringBuilder()
|
||
private var englishCandidateRow: LinearLayout? = null
|
||
// The row+divider bar as a whole (shown/hidden together when the
|
||
// suggestions setting is toggled) and its overflow "▼" button — see
|
||
// buildEnglishCandidateBar().
|
||
private var englishCandidateBarContainer: View? = null
|
||
private var englishExpandCandidatesButton: View? = null
|
||
private var englishCandidateQueryEpoch = 0L
|
||
// Lazy, not eager: the base dictionary/trie only gets built the first
|
||
// time the English keyboard is actually opened (see
|
||
// EnglishCandidateProvider.ensureLoaded()), so switching to English
|
||
// never pays for it until that mode is first used, and other modes
|
||
// never pay for it at all. Held as the raw Lazy (not just its .value)
|
||
// so onDestroy() below can skip shutdown() when it was never touched.
|
||
private val englishCandidateProviderLazy = lazy { EnglishCandidateProvider(this) }
|
||
private val englishCandidateProvider get() = englishCandidateProviderLazy.value
|
||
internal var traditionalOutput = false
|
||
// Clipboard panel state: whether ← → ↑ ↓ extend the selection (like
|
||
// holding Shift on a physical keyboard) instead of just moving the
|
||
// cursor, whether the history browser sub-panel is showing instead of
|
||
// the direction-pad grid, and which category tab is selected there.
|
||
private var clipboardSelectionMode = false
|
||
// The fixed end and the moving end of the in-progress selection, set the
|
||
// first time an arrow key is pressed after the "选择" (Select) key turns
|
||
// on; cleared
|
||
// whenever selection mode turns off so the next selection starts fresh
|
||
// from wherever the cursor happens to be then.
|
||
private var clipboardSelectionAnchor = -1
|
||
private var clipboardSelectionActive = -1
|
||
private var clipboardHistoryMode = false
|
||
private var clipboardHistoryCategory = OpenLessClipboardHistory.Category.ALL
|
||
private var shiftState = ShiftState.OFF
|
||
private var state = "idle"
|
||
private var currentMessage = "点击开始说话"
|
||
private var status: TextView? = null
|
||
private var voiceRawHint: TextView? = null
|
||
private var statusNormalColor = Color.GRAY
|
||
// Single shared top-level preview bubble for every key across the
|
||
// English and stroke keyboards (see wrapWithKeyPreviewOverlay()) — reset
|
||
// on every panel rebuild since it lives inside that panel's own root
|
||
// View tree, same lifecycle as voiceButton/status below. Null in panels
|
||
// that never show a key preview (voice, clipboard, edit); callers use
|
||
// the safe-call operator so that's a no-op rather than a crash.
|
||
private var keyPreviewOverlay: KeyPreviewOverlay? = null
|
||
// Last height_dp:raise_dp baked into the cached input view — compared in
|
||
// onStartInputView so leaving keyboard settings and refocusing rebuilds
|
||
// when the footprint prefs changed.
|
||
private var appliedKeyboardFootprintKey: String? = null
|
||
// LETTERS layer's swipe-up-bearing keys (buildEnglishCharKey()'s
|
||
// wrapper -> its symbol — row1's digits, row2's @#$%&-+(), row3's
|
||
// :;'.,!?), so a swipe-armed drag can retarget across a row the same
|
||
// way a plain tap-drag already retargets across letters. Cleared and
|
||
// repopulated once per LETTERS render (see addEnglishCharRow()'s own
|
||
// row1 call, which always runs first — row2/row3 add straight into
|
||
// that same fresh map), since a shift toggle etc. throws the whole
|
||
// view tree away via refreshInputView() and would otherwise leave
|
||
// stale View keys behind.
|
||
private val englishSwipeSymbols = HashMap<View, String>()
|
||
private var voiceButton: VoiceButton? = null
|
||
// Silence-detection for the main voice panel: if the mic capture never
|
||
// reports a meaningful level for a while after recording starts, the
|
||
// audio link is probably broken upstream (muted mic, dead capture
|
||
// session, etc.) even though the UI otherwise looks like it's recording.
|
||
private var voiceLinkWarning: TextView? = null
|
||
// Small always-on status dot next to the Logo (every panel's header
|
||
// rebuilds this via buildBrandView()) — a glance-able "is the backend
|
||
// voice link actually alive right now" signal, for the symptom where
|
||
// tapping the mic silently did nothing (no waveform movement) because
|
||
// the link had quietly dropped, then recovered on its own moments
|
||
// later. Reassigned on every panel rebuild, same pattern as
|
||
// voiceButton/status below.
|
||
private var backendLinkIndicator: View? = null
|
||
private var backendLinkPulseAnimator: android.animation.ObjectAnimator? = null
|
||
// Updated only by the heartbeat check (see startBackendHeartbeat()),
|
||
// not by the normal recording/processing/done states — those already
|
||
// take priority in updateBackendLinkIndicator(). False means the most
|
||
// recent heartbeat found isBackendReady() false.
|
||
private var backendLinkHealthy = true
|
||
private val backendHeartbeatHandler = Handler(Looper.getMainLooper())
|
||
private val backendHeartbeatRunnable = object : Runnable {
|
||
override fun run() {
|
||
runBackendHeartbeatCheck()
|
||
backendHeartbeatHandler.postDelayed(this, BACKEND_HEARTBEAT_INTERVAL_MS)
|
||
}
|
||
}
|
||
private var recordingStartedAtMs = 0L
|
||
private var maxObservedLevelThisSession = 0f
|
||
// The exact text this dictation session committed, so the undo/redo
|
||
// toggle can remove/restore precisely that span rather than guessing.
|
||
private var lastDictationText: String? = null
|
||
private var lastDictationEpoch: Long = -1
|
||
private var dictationTextUndone = false
|
||
private var editingDictationResult = false
|
||
// Set right before deleteBackward() edits the field, consumed by the
|
||
// very next invalidateDictationResultIfTextChanged() call — see
|
||
// deleteBackward()'s comment.
|
||
private var selfInitiatedTextChange = false
|
||
// True from the moment the edit mic starts recording until its result
|
||
// (or a cancel) resolves — independent of editingDictationResult, which
|
||
// only tracks which PANEL is currently shown. Stopping the edit mic
|
||
// switches back to the main voice panel immediately (so the "thinking"/
|
||
// polish animation plays there, not on the compact edit view), while
|
||
// this flag keeps commitImeText() routing the eventual result to
|
||
// finishEditWithSpokenReplacement() instead of a normal commit.
|
||
private var awaitingEditReplacement = false
|
||
// The exact span being replaced by the edit flow's "speak the correct
|
||
// word" mic: either whatever the user selected in the real input field,
|
||
// or (if nothing was selected) the whole last dictation result.
|
||
private var editingOriginalText: String? = null
|
||
private var editingReplacesWholeResult = false
|
||
// Whether finishEditWithSpokenReplacement() should write the spoken
|
||
// replacement to the global Dictionary. Defaults false — the edit
|
||
// panel's checkbox is an opt-in per-edit, not an opt-out, since most
|
||
// edits are just rewording rather than an actual new/misrecognized word
|
||
// worth remembering; ticking it is what makes the write happen at all.
|
||
private var addToDictionaryForEdit = false
|
||
private var undoRedoButton: TextView? = null
|
||
private var editResultButton: TextView? = null
|
||
// The row holding undoRedoButton/editResultButton, toggled as a whole so
|
||
// it stays visible any time there's a result to act on (not just while
|
||
// state == "done"), directly above the @/backspace footer row.
|
||
private var dictationResultRow: LinearLayout? = null
|
||
private var englishUi = false
|
||
private val simplifiedToTraditional by lazy { Transliterator.getInstance("Hans-Hant") }
|
||
// Packaged as an asset (not a drawable resource) so it survives the
|
||
// gen/android scaffolding copy step the same way the stroke dictionaries do.
|
||
private val brandLogoBitmap: android.graphics.Bitmap? by lazy {
|
||
try {
|
||
assets.open("openless_wordmark.png").use { android.graphics.BitmapFactory.decodeStream(it) }
|
||
} catch (error: Exception) {
|
||
android.util.Log.w("OpenLessIme", "failed to load brand logo asset", error)
|
||
null
|
||
}
|
||
}
|
||
// Owns everything specific to the stroke panel — encode entry, the
|
||
// 字候选/联想候选 pipeline, and the number/symbol sub-panel — split out of
|
||
// this class into its own file; see StrokeInputController's own doc
|
||
// comment for the shared-vs-owned boundary.
|
||
private val strokeController by lazy { StrokeInputController(this) }
|
||
|
||
// Single source of truth for the encode row's blue text, reused as-is
|
||
// (not a new similar blue) for the selected/first candidate. A property,
|
||
// not a val, since it must track the live system theme, not whatever it
|
||
// resolved to when the service was first created.
|
||
internal val strokeEncodeAccentColor: Int
|
||
get() = tone(Color.rgb(120, 190, 255), Color.rgb(20, 110, 220))
|
||
|
||
/**
|
||
* True when the keyboard should render its dark palette — follows the
|
||
* OpenLess app's OWN Settings > Appearance choice (mirrored into
|
||
* `openless_ime_ui`'s "theme_mode" by OpenLessApplication's WebView
|
||
* poll), not the raw OS dark-mode setting. Falls back to the OS setting
|
||
* only if the app's WebView has never resolved a theme yet (fresh
|
||
* install before Settings was opened once) — the app's own default
|
||
* preference is itself "system", so this fallback agrees with it.
|
||
* Re-read on every call, never cached, so a change is picked up on the
|
||
* next panel rebuild.
|
||
*/
|
||
private val isDarkTheme: Boolean
|
||
get() {
|
||
val mirrored = getSharedPreferences("openless_ime_ui", MODE_PRIVATE).getString("theme_mode", null)
|
||
return when (mirrored) {
|
||
"light" -> false
|
||
"dark" -> true
|
||
else -> (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) != Configuration.UI_MODE_NIGHT_NO
|
||
}
|
||
}
|
||
|
||
/** Picks `dark` or `light` for the current system theme — the one place every themed color in this file goes through. */
|
||
internal fun tone(dark: Int, light: Int): Int = if (isDarkTheme) dark else light
|
||
// Mirrors whatever's currently in strokeCandidates (word/stroke matches
|
||
// or phrase associations) as plain (label, action) pairs, so the "show
|
||
// more" overlay can replay the exact same set without duplicating the
|
||
// stroke-match vs. association branching logic.
|
||
internal var candidateOverlayEntries: List<Pair<String, () -> Unit>> = emptyList()
|
||
|
||
internal fun ui(zh: String, en: String) = if (englishUi) en else zh
|
||
|
||
internal fun outputScript(text: String): String {
|
||
if (!traditionalOutput) return text
|
||
return runCatching { simplifiedToTraditional.transliterate(text) }.getOrDefault(text)
|
||
}
|
||
|
||
// The 5th stroke has no plain-text glyph in the encode preview — it's
|
||
// drawn as the same shape as the "5" key's own icon (an ImageSpan), so
|
||
// the preview and the key read as the same stroke instead of the bare
|
||
// "乙" character.
|
||
internal fun displayStrokeCode(code: String): CharSequence {
|
||
val builder = android.text.SpannableStringBuilder()
|
||
code.forEach { stroke ->
|
||
when (stroke) {
|
||
'h' -> builder.append('一')
|
||
's' -> builder.append('丨')
|
||
'p' -> builder.append('丿')
|
||
'n' -> builder.append('丶')
|
||
'z' -> {
|
||
val start = builder.length
|
||
builder.append(' ')
|
||
builder.setSpan(
|
||
android.text.style.ImageSpan(strokeFifthGlyphDrawable(), android.text.style.ImageSpan.ALIGN_BASELINE),
|
||
start,
|
||
builder.length,
|
||
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
|
||
)
|
||
}
|
||
'*' -> builder.append('*')
|
||
else -> builder.append(stroke)
|
||
}
|
||
}
|
||
return builder
|
||
}
|
||
|
||
/** Same diagonal-then-horizontal shape as the "5" key's own icon, sized and colored to sit inline in strokePreview's text. */
|
||
private fun strokeFifthGlyphDrawable(): android.graphics.drawable.Drawable {
|
||
val sizePx = (16.5f * resources.displayMetrics.scaledDensity).toInt().coerceAtLeast(dp(14))
|
||
val glyphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = strokeEncodeAccentColor
|
||
style = Paint.Style.STROKE
|
||
strokeCap = Paint.Cap.ROUND
|
||
strokeJoin = Paint.Join.ROUND
|
||
strokeWidth = sizePx * 0.09f
|
||
}
|
||
return object : android.graphics.drawable.Drawable() {
|
||
override fun draw(canvas: Canvas) {
|
||
// The path's ink only ever spanned y=[34.67, 68] of its
|
||
// original 100-unit design space (a bare third of the box).
|
||
// Rescaled + repositioned here so the ink fills the box with
|
||
// a small, even margin top and bottom — matching how a
|
||
// plain character glyph (e.g. the "丨" stroke's own) sits
|
||
// within its line — instead of either that ~33%-tall island
|
||
// or (an earlier attempt) flush against the bottom edge with
|
||
// all the blank space pushed to the top.
|
||
val inkTop = 34.67f
|
||
val inkBottom = 68f
|
||
val marginFraction = 0.12f
|
||
val boxHeight = bounds.height().toFloat()
|
||
val unit = (boxHeight * (1f - 2f * marginFraction)) / (inkBottom - inkTop)
|
||
val topPixels = bounds.top + boxHeight * marginFraction
|
||
val cx = bounds.left + bounds.width() / 2f
|
||
val path = Path().apply {
|
||
moveTo(cx + 1f * unit, topPixels)
|
||
lineTo(cx - 14f * unit, topPixels + (inkBottom - inkTop) * unit)
|
||
lineTo(cx + 16f * unit, topPixels + (inkBottom - inkTop) * unit)
|
||
}
|
||
canvas.drawPath(path, glyphPaint)
|
||
}
|
||
override fun setAlpha(alpha: Int) { glyphPaint.alpha = alpha }
|
||
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) { glyphPaint.colorFilter = colorFilter }
|
||
@Deprecated("Deprecated in Java", ReplaceWith("PixelFormat.TRANSLUCENT", "android.graphics.PixelFormat"))
|
||
override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT
|
||
}.apply { setBounds(0, 0, sizePx, sizePx) }
|
||
}
|
||
|
||
private fun restoreScriptPreference() {
|
||
val preferences = getSharedPreferences("openless_ime_ui", MODE_PRIVATE)
|
||
traditionalOutput = if (preferences.contains("stroke_traditional_output")) {
|
||
preferences.getBoolean("stroke_traditional_output", false)
|
||
} else {
|
||
OpenLessAndroidPreferences.chineseScriptPreference(this) == "traditional"
|
||
}
|
||
}
|
||
|
||
internal fun toggleScriptPreference() {
|
||
traditionalOutput = !traditionalOutput
|
||
getSharedPreferences("openless_ime_ui", MODE_PRIVATE).edit()
|
||
.putBoolean("stroke_traditional_output", traditionalOutput)
|
||
.apply()
|
||
refreshInputView()
|
||
}
|
||
|
||
private fun restoreInputMode() {
|
||
inputMode = when (getSharedPreferences("openless_ime_ui", MODE_PRIVATE).getString("input_mode", "voice")) {
|
||
"stroke" -> InputMode.STROKE
|
||
"clipboard" -> InputMode.CLIPBOARD
|
||
"english" -> InputMode.ENGLISH
|
||
else -> InputMode.VOICE
|
||
}
|
||
}
|
||
|
||
internal fun saveInputMode(mode: InputMode) {
|
||
getSharedPreferences("openless_ime_ui", MODE_PRIVATE).edit()
|
||
.putString("input_mode", mode.name.lowercase())
|
||
.apply()
|
||
}
|
||
|
||
private fun restoreLatinInputMode() {
|
||
latinInputMode = when (getSharedPreferences("openless_ime_ui", MODE_PRIVATE).getString("latin_input_mode", "english")) {
|
||
"pinyin" -> LatinInputMode.PINYIN
|
||
else -> LatinInputMode.ENGLISH
|
||
}
|
||
}
|
||
|
||
private fun saveLatinInputMode(mode: LatinInputMode) {
|
||
getSharedPreferences("openless_ime_ui", MODE_PRIVATE).edit()
|
||
.putString("latin_input_mode", mode.name.lowercase())
|
||
.apply()
|
||
}
|
||
|
||
/**
|
||
* Long-press-space entry point (see buildKeyboardView()'s spaceButton).
|
||
* Per the lite-pinyin plan's 2026-09-25 revision: switching modes never
|
||
* saves a half-typed pinyin encoding (matches englishComposingWord's own
|
||
* "clear, don't carry over" behavior at every other boundary), and never
|
||
* commits anything on its own — only a candidate tap or the English
|
||
* path's own commitEnglishChar() ever calls commitText().
|
||
*/
|
||
private fun toggleLatinInputMode() {
|
||
englishComposingWord.clear()
|
||
englishCandidateQueryEpoch++
|
||
litePinyinController.clear()
|
||
latinInputMode = if (latinInputMode == LatinInputMode.ENGLISH) LatinInputMode.PINYIN else LatinInputMode.ENGLISH
|
||
saveLatinInputMode(latinInputMode)
|
||
refreshLatinCandidateBar()
|
||
performKeyHaptic()
|
||
refreshInputView()
|
||
}
|
||
|
||
/**
|
||
* Populates/hides the shared candidate bar for whichever of
|
||
* English/Pinyin mode is current — used both on a cold panel build and
|
||
* right after toggleLatinInputMode() switches modes. Pinyin's own bar
|
||
* visibility isn't gated by englishSuggestionsEnabled() (a separate,
|
||
* English-word-completion-specific preference); its candidates are core
|
||
* to the mode, not an optional suggestion feature.
|
||
*/
|
||
private fun refreshLatinCandidateBar() {
|
||
if (latinInputMode == LatinInputMode.PINYIN) {
|
||
litePinyinController.preloadAsync()
|
||
englishCandidateBarContainer?.visibility = View.VISIBLE
|
||
renderPinyinCandidates(emptyList())
|
||
} else {
|
||
updateEnglishCandidates()
|
||
}
|
||
}
|
||
|
||
internal fun refreshLanguage() {
|
||
val locale = getSharedPreferences("openless_ime_ui", MODE_PRIVATE)
|
||
.getString("locale", null) ?: resources.configuration.locales[0].toLanguageTag()
|
||
englishUi = !locale.startsWith("zh", ignoreCase = true)
|
||
}
|
||
|
||
private val clipboardManager by lazy { getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager }
|
||
private val clipboardHistoryListener = android.content.ClipboardManager.OnPrimaryClipChangedListener {
|
||
val text = clipboardManager.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)
|
||
?.coerceToText(this)?.toString()
|
||
if (!text.isNullOrBlank()) {
|
||
OpenLessClipboardHistory.recordCopy(this, text)
|
||
}
|
||
}
|
||
|
||
private var learningMigrationComplete = false
|
||
internal fun personalizedLearningAllowed(): Boolean {
|
||
val editor = currentInputEditorInfo ?: return false
|
||
return learningMigrationComplete && ImeLearningPolicy.allowsLearning(editor.inputType, editor.imeOptions)
|
||
}
|
||
|
||
override fun onCreate() {
|
||
super.onCreate()
|
||
learningMigrationComplete = ImeLearningPolicy.migrate(this)
|
||
restoreInputMode()
|
||
restoreScriptPreference()
|
||
restoreLatinInputMode()
|
||
activeInstance = java.lang.ref.WeakReference(this)
|
||
OpenLessOverlayBridge.imeListener = this
|
||
OpenLessOverlayBridge.imeSessionListener = ::onImeSessionEvent
|
||
startRuntimeService()
|
||
// Load the offline stroke dictionary and the (much larger, ~220k-
|
||
// phrase) association dictionary while the IME is idle — the latter
|
||
// used to load lazily on whichever word's commit was the first to
|
||
// ever need an association, which is exactly the moment a user is
|
||
// sitting there waiting for the candidate row to update.
|
||
strokeController.preloadAsync()
|
||
clipboardManager.addPrimaryClipChangedListener(clipboardHistoryListener)
|
||
backendHeartbeatHandler.post(backendHeartbeatRunnable)
|
||
}
|
||
|
||
override fun onDestroy() {
|
||
cancelImeSession()
|
||
if (activeInstance?.get() === this) {
|
||
activeInstance = null
|
||
}
|
||
if (OpenLessOverlayBridge.imeListener === this) {
|
||
OpenLessOverlayBridge.imeListener = null
|
||
OpenLessOverlayBridge.imeSessionListener = null
|
||
}
|
||
backendHeartbeatHandler.removeCallbacks(backendHeartbeatRunnable)
|
||
backendLinkPulseAnimator?.cancel()
|
||
clipboardManager.removePrimaryClipChangedListener(clipboardHistoryListener)
|
||
stopRuntimeService()
|
||
strokeController.shutdown()
|
||
if (englishCandidateProviderLazy.isInitialized()) englishCandidateProviderLazy.value.shutdown()
|
||
litePinyinController.shutdown()
|
||
// Marks this as a clean end-of-session for
|
||
// OpenLessApplication.recordUncleanShutdownIfAny() — an abrupt
|
||
// process kill (native crash, OOM) never reaches this line, which
|
||
// is exactly the "unclean" case that helper is trying to detect.
|
||
getSharedPreferences("openless_runtime", MODE_PRIVATE).edit().putBoolean("session_alive", false).apply()
|
||
super.onDestroy()
|
||
}
|
||
|
||
// Panel content height comes from keyboard settings (stretch); optional
|
||
// raise adds empty space below so the whole keyboard sits higher without
|
||
// stretching keys. Keep Android's fullscreen-extract heuristic off.
|
||
override fun onEvaluateFullscreenMode(): Boolean = false
|
||
|
||
/** Key-panel height in px (prefs `keyboard_height_dp`, default 300). */
|
||
internal fun keyboardPanelHeightPx(): Int = panelHeightPx(this)
|
||
|
||
internal fun keyboardPanelHeightDp(): Int = panelHeightDp(this)
|
||
|
||
/** Empty space below the key panel that lifts keys (prefs `keyboard_raise_dp`). */
|
||
internal fun keyboardRaiseHeightPx(): Int = raiseHeightPx(this)
|
||
|
||
/**
|
||
* Appends a bottom spacer so the key panel sits higher on screen.
|
||
* Always wraps (raise may be 0) so later pref changes can grow the
|
||
* spacer without rebuilding the whole IME view tree.
|
||
*/
|
||
private fun applyKeyboardRaise(content: View): View {
|
||
val panelPx = keyboardPanelHeightPx()
|
||
val raisePx = keyboardRaiseHeightPx()
|
||
return RaisedKeyboardHost(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
layoutParams = ViewGroup.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||
panelPx + raisePx,
|
||
)
|
||
addView(
|
||
content,
|
||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, panelPx),
|
||
)
|
||
addView(
|
||
View(this@OpenLessImeService).apply {
|
||
setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246)))
|
||
visibility = if (raisePx > 0) View.VISIBLE else View.GONE
|
||
},
|
||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, raisePx),
|
||
)
|
||
}
|
||
}
|
||
|
||
override fun onCreateInputView(): View {
|
||
refreshLanguage()
|
||
startRuntimeService()
|
||
// Checked before any inputMode branch: the clipboard swipe-left
|
||
// correction flow opens this panel while inputMode is still
|
||
// InputMode.CLIPBOARD (never changed), so if the CLIPBOARD branch
|
||
// below ran first, it would win and rebuild the clipboard history
|
||
// list instead — exactly the "swipe left does nothing visible" bug
|
||
// this ordering fixes. The original edit-result flow only ever
|
||
// triggered from Voice mode, so this ordering issue never showed up
|
||
// before the clipboard flow started reusing the same panel.
|
||
val content = when {
|
||
editingDictationResult -> buildEditPanel()
|
||
inputMode == InputMode.ENGLISH -> wrapWithKeyPreviewOverlay(buildKeyboardView())
|
||
inputMode == InputMode.STROKE -> wrapWithKeyPreviewOverlay(
|
||
if (strokeController.strokeNumberMode) strokeController.buildStrokeNumberView()
|
||
else strokeController.buildStrokeView(),
|
||
)
|
||
inputMode == InputMode.CLIPBOARD ->
|
||
if (clipboardHistoryMode) buildClipboardHistoryView() else buildClipboardView()
|
||
else -> buildVoiceInputView()
|
||
}
|
||
val view = applyKeyboardRaise(content)
|
||
appliedKeyboardFootprintKey = keyboardFootprintKey()
|
||
return view
|
||
}
|
||
|
||
/** Voice panel + floating dictation-result overlay (former onCreateInputView body). */
|
||
private fun buildVoiceInputView(): View {
|
||
val panel = SwipeModeContainer(this) { direction -> swipeInputMode(direction) }.apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx())
|
||
minimumHeight = keyboardPanelHeightPx()
|
||
setPadding(dp(16), dp(8), dp(16), dp(4))
|
||
setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246)))
|
||
clipChildren = false
|
||
clipToPadding = false
|
||
}
|
||
panel.addView(buildVoiceHeader(), LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||
dp(38),
|
||
))
|
||
statusNormalColor = tone(Color.rgb(190, 190, 190), Color.rgb(110, 110, 115))
|
||
status = TextView(this).apply {
|
||
text = displayStatus(currentMessage)
|
||
textSize = 16f
|
||
if (englishUi) typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
|
||
gravity = android.view.Gravity.CENTER
|
||
setTextColor(statusNormalColor)
|
||
setPadding(0, dp(6), 0, dp(4))
|
||
}
|
||
panel.addView(status, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||
dp(38),
|
||
))
|
||
|
||
voiceButton = VoiceButton(this, isDarkTheme).apply {
|
||
isClickable = true
|
||
setOnClickListener { toggleDictation() }
|
||
contentDescription = ui("OpenLess 语音听写", "OpenLess dictation")
|
||
// All four swipe gestures follow the same pattern: a LIVE
|
||
// boolean re-evaluated every ACTION_MOVE (not a one-way latch),
|
||
// a matching live visual on VoiceButton (idle capsule orange for
|
||
// swipe-up-to-raw / green for swipe-left-to-quick-notes / red for
|
||
// swipe-right-to-cloud-notes, waveform light red for swipe-down-to-
|
||
// cancel — all four ease back the moment the finger drops back
|
||
// below their own threshold), a single haptic tick on every
|
||
// crossing in either direction, and the actual action (cancel /
|
||
// enter raw mode / enter quick-notes mode / enter cloud-notes mode)
|
||
// only committed on release, gated on whichever one is still
|
||
// active at that instant. Nothing commits mid-drag — swipe down
|
||
// used to cancel the moment the threshold was first crossed
|
||
// during the move, but that gave no chance to back out of an
|
||
// accidental trigger, same reasoning as the swipe-up threshold's
|
||
// own move-to-release change earlier.
|
||
//
|
||
// requestDisallowInterceptTouchEvent is still claimed
|
||
// unconditionally on DOWN (not only once a threshold is
|
||
// crossed), matching buildEnglishCharKey()'s same fix earlier
|
||
// this session: without it, a downward drag here is
|
||
// indistinguishable from the enclosing SwipeModeContainer's own
|
||
// "long downward drag dismisses the keyboard" gesture, and that
|
||
// ancestor can steal the sequence before our own dp(30) check
|
||
// ever sees the full distance.
|
||
var downX = 0f
|
||
var downY = 0f
|
||
var swipeUpActive = false
|
||
var swipeDownActive = false
|
||
// Same shape as swipeUpActive (no `recording` gate on the MOVE
|
||
// check — armable both from idle and mid-recording), just on the
|
||
// horizontal axis — see cloudNoteArmed's own doc comment for what
|
||
// release does in each case.
|
||
var swipeRightActive = false
|
||
// Same shape again, mirrored to the other horizontal direction —
|
||
// see quickNoteArmed's own doc comment for what release does.
|
||
var swipeLeftActive = false
|
||
setOnTouchListener { view, event ->
|
||
when (event.actionMasked) {
|
||
MotionEvent.ACTION_DOWN -> {
|
||
downX = event.x
|
||
downY = event.y
|
||
swipeUpActive = false
|
||
swipeDownActive = false
|
||
swipeRightActive = false
|
||
swipeLeftActive = false
|
||
view.parent?.requestDisallowInterceptTouchEvent(true)
|
||
}
|
||
MotionEvent.ACTION_MOVE -> {
|
||
if (recording) {
|
||
val active = event.y - downY >= dp(30)
|
||
if (active != swipeDownActive) {
|
||
swipeDownActive = active
|
||
voiceButton?.armedForCancel = active
|
||
performKeyHaptic()
|
||
}
|
||
}
|
||
val active = downY - event.y >= dp(30)
|
||
if (active != swipeUpActive) {
|
||
swipeUpActive = active
|
||
voiceButton?.armedForRawSwipe = active
|
||
// One tick every time the threshold is
|
||
// crossed, either direction — entering AND
|
||
// leaving the armed zone both get felt, not
|
||
// just the eventual release.
|
||
performKeyHaptic()
|
||
}
|
||
val activeRight = event.x - downX >= dp(30)
|
||
if (activeRight != swipeRightActive) {
|
||
swipeRightActive = activeRight
|
||
voiceButton?.armedForCloudNote = activeRight
|
||
performKeyHaptic()
|
||
}
|
||
val activeLeft = downX - event.x >= dp(30)
|
||
if (activeLeft != swipeLeftActive) {
|
||
swipeLeftActive = activeLeft
|
||
voiceButton?.armedForQuickNote = activeLeft
|
||
performKeyHaptic()
|
||
}
|
||
}
|
||
MotionEvent.ACTION_UP -> {
|
||
if (swipeDownActive) {
|
||
performDoubleKeyHaptic()
|
||
cancelDictation()
|
||
} else if (swipeUpActive) {
|
||
if (recording && !rawModeArmed) {
|
||
rawModeArmed = true
|
||
performDoubleKeyHaptic()
|
||
updateStatus(currentMessage)
|
||
} else if (!recording && !processing) {
|
||
toggleDictation()
|
||
if (recording) {
|
||
rawModeArmed = true
|
||
performDoubleKeyHaptic()
|
||
updateStatus(currentMessage)
|
||
}
|
||
}
|
||
} else if (swipeRightActive) {
|
||
if (recording && !cloudNoteArmed) {
|
||
cloudNoteArmed = true
|
||
performDoubleKeyHaptic()
|
||
updateStatus(currentMessage)
|
||
} else if (!recording && !processing) {
|
||
toggleDictation(initialCloud = true)
|
||
if (recording) {
|
||
cloudNoteArmed = true
|
||
performDoubleKeyHaptic()
|
||
updateStatus(currentMessage)
|
||
}
|
||
}
|
||
} else if (swipeLeftActive) {
|
||
if (recording && !quickNoteArmed) {
|
||
quickNoteArmed = true
|
||
performDoubleKeyHaptic()
|
||
updateStatus(currentMessage)
|
||
} else if (!recording && !processing) {
|
||
toggleDictation()
|
||
if (recording) {
|
||
quickNoteArmed = true
|
||
performDoubleKeyHaptic()
|
||
updateStatus(currentMessage)
|
||
}
|
||
}
|
||
}
|
||
swipeUpActive = false
|
||
swipeDownActive = false
|
||
swipeRightActive = false
|
||
swipeLeftActive = false
|
||
voiceButton?.armedForRawSwipe = false
|
||
voiceButton?.armedForCancel = false
|
||
voiceButton?.armedForQuickNote = false
|
||
voiceButton?.armedForCloudNote = false
|
||
}
|
||
MotionEvent.ACTION_CANCEL -> {
|
||
swipeUpActive = false
|
||
swipeDownActive = false
|
||
swipeRightActive = false
|
||
swipeLeftActive = false
|
||
voiceButton?.armedForRawSwipe = false
|
||
voiceButton?.armedForCancel = false
|
||
voiceButton?.armedForQuickNote = false
|
||
voiceButton?.armedForCloudNote = false
|
||
}
|
||
}
|
||
false
|
||
}
|
||
}
|
||
// FrameLayout, not LinearLayout: voiceButton is centered purely by
|
||
// its own Gravity.CENTER, independent of whatever else shares this
|
||
// holder — a LinearLayout would instead center the *whole stack* of
|
||
// children as one block, which pushed the mic icon itself upward
|
||
// (away from the status line above) once voiceRawHint became a
|
||
// permanently-participating sibling instead of an occasional one.
|
||
// voiceRawHint/voiceLinkWarning use that same CENTER anchor plus a
|
||
// fixed topMargin, so they sit a fixed distance below the mic
|
||
// without perturbing its own position at all.
|
||
val buttonHolder = FrameLayout(this).apply {
|
||
setBackgroundColor(Color.TRANSPARENT)
|
||
clipChildren = false
|
||
clipToPadding = false
|
||
}
|
||
buttonHolder.addView(voiceButton!!, FrameLayout.LayoutParams(dp(176), dp(72), android.view.Gravity.CENTER))
|
||
|
||
// Small companion hint — deliberately muted (low size + alpha) so it
|
||
// doesn't compete with "Tap to speak" for attention, but
|
||
// discoverable enough that a user notices the three swipe gestures
|
||
// exist at all. Sits just above the footer row (right above the
|
||
// Return key, which is horizontally centered there the same way
|
||
// this hint is) rather than directly under the mic — out of the way
|
||
// of the mic capsule and its own waveform/pill animations, but still
|
||
// close enough to read while a hand hovers over the mic. Tap or
|
||
// long-press explains what Raw mode actually does via a Toast,
|
||
// rather than building a dedicated tooltip bubble for a single
|
||
// one-off explanation. Hidden entirely while recording/thinking in
|
||
// an ordinary (non-Raw) dictation — see rawModeHintVisible() —
|
||
// since at that point it's neither teaching a still-relevant
|
||
// gesture nor confirming anything, just noise.
|
||
val rawModeTooltip = ui("Raw模式,语音原样转写,不做AI润色整理", "Raw mode, verbatim transcription without AI polishing")
|
||
voiceRawHint = TextView(this).apply {
|
||
text = rawModeHintText()
|
||
textSize = 11f
|
||
if (englishUi) typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
|
||
gravity = android.view.Gravity.CENTER
|
||
setTextColor(rawModeHintColor())
|
||
visibility = if (rawModeHintVisible()) View.VISIBLE else View.GONE
|
||
isClickable = true
|
||
setOnClickListener { Toast.makeText(this@OpenLessImeService, rawModeTooltip, Toast.LENGTH_SHORT).show() }
|
||
setOnLongClickListener {
|
||
Toast.makeText(this@OpenLessImeService, rawModeTooltip, Toast.LENGTH_SHORT).show()
|
||
true
|
||
}
|
||
}
|
||
buttonHolder.addView(
|
||
voiceRawHint,
|
||
FrameLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||
android.view.Gravity.BOTTOM or android.view.Gravity.CENTER_HORIZONTAL,
|
||
).apply {
|
||
bottomMargin = dp(6)
|
||
},
|
||
)
|
||
voiceLinkWarning = TextView(this).apply {
|
||
text = ui("检测到麦克风无声音,点击重启应用", "No mic audio detected — tap to restart the app")
|
||
textSize = 16f
|
||
if (englishUi) {
|
||
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
|
||
} else {
|
||
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
||
}
|
||
gravity = android.view.Gravity.CENTER
|
||
setTextColor(Color.rgb(255, 90, 90))
|
||
setPadding(dp(12), 0, dp(12), 0)
|
||
visibility = View.GONE
|
||
isClickable = true
|
||
setOnClickListener { restartApp() }
|
||
}
|
||
buttonHolder.addView(
|
||
voiceLinkWarning,
|
||
FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, android.view.Gravity.CENTER).apply {
|
||
// Below where the Raw hint sits, so the two never overlap on
|
||
// the rare occasion both are visible at once (Raw-armed
|
||
// recording with a simultaneous mic-silence warning).
|
||
topMargin = dp(70)
|
||
},
|
||
)
|
||
panel.addView(buttonHolder, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||
0,
|
||
1f,
|
||
))
|
||
|
||
panel.addView(buildVoiceFooter(), LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||
dp(84),
|
||
))
|
||
|
||
// The undo/redo/edit controls float over the panel instead of taking
|
||
// a reserved slot in its own layout — that way showing or hiding
|
||
// them can never nudge the mic (or anything else) out of its
|
||
// original centered position, regardless of visibility state.
|
||
val overlayHost = FrameLayout(this).apply {
|
||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx())
|
||
}
|
||
overlayHost.addView(panel, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||
overlayHost.addView(buildDictationResultOverlay(), FrameLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||
android.view.Gravity.BOTTOM,
|
||
).apply {
|
||
marginStart = dp(16)
|
||
marginEnd = dp(16)
|
||
// Panel's own bottom padding (4dp) + footer height (84dp) + a
|
||
// small gap (6dp), so this lands just above @ and backspace.
|
||
bottomMargin = dp(4) + dp(84) + dp(6)
|
||
})
|
||
return overlayHost
|
||
}
|
||
|
||
private fun buildVoiceHeader(): LinearLayout {
|
||
val header = LinearLayout(this).apply {
|
||
gravity = android.view.Gravity.CENTER_VERTICAL
|
||
}
|
||
header.addView(buildBrandView(), LinearLayout.LayoutParams(0, dp(38), 1f))
|
||
header.addView(buildModeToggle(), LinearLayout.LayoutParams(dp(240), dp(38)))
|
||
return header
|
||
}
|
||
|
||
/** Flat pill fill (no elevation) shared by every footer-row button — @, return, backspace, undo/redo, edit. */
|
||
private fun flattenFooterButton(view: TextView, height: Int) {
|
||
view.elevation = 0f
|
||
view.translationZ = 0f
|
||
// A single flat fill, not roundedButton()'s layered "keycap" look
|
||
// (gradient face + exposed darker step) — genuinely flat, matching
|
||
// the mic capsule's own plain drawRoundRect fill exactly.
|
||
view.background = GradientDrawable().apply {
|
||
shape = GradientDrawable.RECTANGLE
|
||
cornerRadius = (height / 2).toFloat()
|
||
setColor(tone(Color.rgb(60, 60, 60), Color.rgb(225, 225, 228)))
|
||
}
|
||
}
|
||
|
||
/** @ / return / backspace — the plain footer row, unaffected by the undo/redo/edit overlay above it. */
|
||
private fun buildVoiceFooter(): LinearLayout {
|
||
val footerButtonHeight = dp(48)
|
||
val footer = LinearLayout(this).apply {
|
||
gravity = android.view.Gravity.CENTER_VERTICAL
|
||
clipChildren = false
|
||
clipToPadding = false
|
||
}
|
||
val atButton = keyboardKey("@", 1f, action = { currentInputConnection?.commitText("@", 1) }).apply {
|
||
textSize = 20f
|
||
contentDescription = ui("输入 @", "Insert at sign")
|
||
layoutParams = LinearLayout.LayoutParams(dp(84), footerButtonHeight)
|
||
flattenFooterButton(this, footerButtonHeight)
|
||
}
|
||
// Chinese UI shows "换行" (line break) instead of the English word
|
||
// "Return" — a real Chinese label reads better here than the
|
||
// borrowed English term, so it gets its own larger, bold treatment
|
||
// rather than reusing "Return"'s smaller size.
|
||
val returnLabel = if (englishUi) "Return" else "换行"
|
||
val returnButton = keyboardKey(returnLabel, 1f, action = { sendEnterKey() }).apply {
|
||
textSize = if (englishUi) 18f else 20f
|
||
if (englishUi) {
|
||
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
|
||
} else {
|
||
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
||
}
|
||
contentDescription = ui("回车", "Return")
|
||
layoutParams = LinearLayout.LayoutParams(dp(120), footerButtonHeight)
|
||
flattenFooterButton(this, footerButtonHeight)
|
||
}
|
||
val backspaceButton = keyboardKey(
|
||
"⌫",
|
||
1f,
|
||
action = { deleteBackward() },
|
||
repeatOnLongPress = true,
|
||
repeatAction = { deleteBackward() },
|
||
).apply {
|
||
textSize = 22f
|
||
contentDescription = ui("退格", "Backspace")
|
||
layoutParams = LinearLayout.LayoutParams(dp(84), footerButtonHeight)
|
||
flattenFooterButton(this, footerButtonHeight)
|
||
}
|
||
val returnHolder = LinearLayout(this).apply {
|
||
gravity = android.view.Gravity.CENTER
|
||
addView(returnButton)
|
||
}
|
||
footer.addView(atButton)
|
||
footer.addView(returnHolder, LinearLayout.LayoutParams(0, dp(84), 1f))
|
||
footer.addView(backspaceButton)
|
||
return footer
|
||
}
|
||
|
||
/**
|
||
* Undo/redo + edit controls for the last dictation result, same size and
|
||
* style as the footer's @/backspace keys. Built standalone (not part of
|
||
* buildVoiceFooter()'s layout flow) so it can be positioned as a
|
||
* FrameLayout overlay above the footer — showing or hiding it can never
|
||
* shift the mic or footer since it never occupies space in their own
|
||
* LinearLayout.
|
||
*/
|
||
private fun buildDictationResultOverlay(): View {
|
||
val footerButtonHeight = dp(48)
|
||
undoRedoButton = keyboardKey("✕", 1f, action = { toggleUndoRedoDictation() }).apply {
|
||
textSize = 20f
|
||
contentDescription = ui("撤销本次听写结果", "Undo this dictation")
|
||
layoutParams = LinearLayout.LayoutParams(dp(84), footerButtonHeight)
|
||
flattenFooterButton(this, footerButtonHeight)
|
||
}
|
||
editResultButton = keyboardKey("✎", 1f, action = { openEditDictationResult() }).apply {
|
||
textSize = 20f
|
||
contentDescription = ui("编辑听写结果", "Edit dictation result")
|
||
layoutParams = LinearLayout.LayoutParams(dp(84), footerButtonHeight)
|
||
flattenFooterButton(this, footerButtonHeight)
|
||
}
|
||
val row = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL }
|
||
row.addView(undoRedoButton!!, LinearLayout.LayoutParams(dp(84), footerButtonHeight))
|
||
row.addView(View(this), LinearLayout.LayoutParams(0, footerButtonHeight, 1f))
|
||
row.addView(editResultButton!!, LinearLayout.LayoutParams(dp(84), footerButtonHeight))
|
||
dictationResultRow = row
|
||
updateDictationResultControls()
|
||
return row
|
||
}
|
||
|
||
internal fun refreshInputView() {
|
||
setInputView(onCreateInputView())
|
||
}
|
||
|
||
/**
|
||
* Same as refreshInputView(), but slides the freshly built panel's
|
||
* content in from the side matching [slideDirection] (+1 = from the
|
||
* right, -1 = from the left) — used for swipe-triggered mode switches
|
||
* and toggle-segment taps so the transition reads as a continuation of
|
||
* the gesture, not an instant swap. Only the content BELOW the header
|
||
* slides; the header itself (every panel builder addView()'s it first,
|
||
* so it's always childAt(0) — Logo, mode toggle, backend-link
|
||
* indicator) stays anchored in place across the transition instead of
|
||
* sliding off and back with the rest, which previously read as the
|
||
* Logo "jumping" on every switch. The starting offset uses the screen
|
||
* width rather than each child's own (not yet measured at this point)
|
||
* width.
|
||
*/
|
||
private fun refreshInputView(slideDirection: Int) {
|
||
val newView = onCreateInputView()
|
||
setInputView(newView)
|
||
val offset = resources.displayMetrics.widthPixels.toFloat() * slideDirection
|
||
// RaisedKeyboardHost wraps [panel, raiseSpacer] — slide the panel's
|
||
// own children (skip header at 0), not the raise spacer.
|
||
val container = when (newView) {
|
||
is RaisedKeyboardHost -> newView.getChildAt(0) as? ViewGroup
|
||
is ViewGroup -> newView
|
||
else -> null
|
||
} ?: return
|
||
for (index in 1 until container.childCount) {
|
||
val child = container.getChildAt(index)
|
||
child.translationX = offset
|
||
child.animate()
|
||
.translationX(0f)
|
||
.setDuration(180L)
|
||
.setInterpolator(android.view.animation.DecelerateInterpolator())
|
||
.start()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* "Speak to fix a word" panel, styled after the Typeless reference: same
|
||
* brand+toggle header as every other panel, a divider, the selected-text
|
||
* preview (with its own cancel button), a second divider, then a wide
|
||
* mic + status row along the bottom. Like every other input mode this
|
||
* replaces the whole panel content rather than floating on top of it —
|
||
* no card background, just the same panel surface split by
|
||
* hairline dividers.
|
||
*/
|
||
private fun buildEditPanel(): View {
|
||
// Same SwipeModeContainer + exact 300dp constraint as every other
|
||
// panel (voice/stroke/English) — not a plain LinearLayout — so this
|
||
// is structurally identical to the main voice panel instead of
|
||
// risking a taller/"fullscreen"-looking measurement.
|
||
val root = SwipeModeContainer(this) { direction -> swipeInputMode(direction) }.apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx())
|
||
minimumHeight = keyboardPanelHeightPx()
|
||
setPadding(dp(16), dp(8), dp(16), dp(10))
|
||
setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246)))
|
||
clipChildren = false
|
||
clipToPadding = false
|
||
}
|
||
|
||
// Same brand logo + mode toggle as the normal voice panel — not a
|
||
// custom header with the toggle swapped for a close button — so the
|
||
// IME's own identity/state stays visibly unchanged while editing,
|
||
// exactly like the number panel keeps the same header as the letter
|
||
// panel underneath it.
|
||
root.addView(buildVoiceHeader(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(38)).apply {
|
||
topMargin = dp(6)
|
||
})
|
||
|
||
root.addView(buildDivider(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)).apply {
|
||
topMargin = dp(6)
|
||
})
|
||
|
||
val chipRow = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL }
|
||
val chip = TextView(this).apply {
|
||
// Plain text, no button/pill background — this is a preview of
|
||
// text already sitting in the real field, not a tappable control.
|
||
text = editingOriginalText.orEmpty()
|
||
textSize = 15f
|
||
setTextColor(strokeEncodeAccentColor)
|
||
maxLines = 2
|
||
ellipsize = android.text.TextUtils.TruncateAt.END
|
||
}
|
||
chipRow.addView(chip, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
// Cancel-editing control lives here instead of the header, since the
|
||
// header is now identical to every other panel's.
|
||
chipRow.addView(flatCircleButton("✕", 34, ui("取消编辑", "Cancel editing")) {
|
||
closeEditDictationResult()
|
||
}, LinearLayout.LayoutParams(dp(38), dp(38)))
|
||
root.addView(chipRow, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||
topMargin = dp(14)
|
||
})
|
||
|
||
// Flexible filler mirrors the empty middle area in the reference,
|
||
// pushing the divider + mic row down to the bottom of the panel.
|
||
root.addView(View(this), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
|
||
|
||
// Placed low in the panel, just above the bottom divider — not
|
||
// right under the text chip — and sized 1.5x (checkbox glyph and
|
||
// label both) so it reads as a deliberate decision, not a small
|
||
// afterthought easy to miss/mis-tap.
|
||
val correctionToggleRow = LinearLayout(this).apply {
|
||
gravity = android.view.Gravity.CENTER_VERTICAL
|
||
isClickable = true
|
||
setOnClickListener {
|
||
addToDictionaryForEdit = !addToDictionaryForEdit
|
||
refreshInputView()
|
||
}
|
||
}
|
||
correctionToggleRow.addView(
|
||
TextView(this).apply {
|
||
text = if (addToDictionaryForEdit) "☑" else "☐"
|
||
textSize = 24f
|
||
setTextColor(if (addToDictionaryForEdit) strokeEncodeAccentColor else tone(Color.rgb(140, 140, 140), Color.rgb(150, 150, 154)))
|
||
},
|
||
LinearLayout.LayoutParams(dp(33), ViewGroup.LayoutParams.WRAP_CONTENT).apply { marginEnd = dp(9) },
|
||
)
|
||
correctionToggleRow.addView(
|
||
TextView(this).apply {
|
||
text = ui("加入到字典中", "Add to dictionary")
|
||
textSize = 18f
|
||
setTextColor(tone(Color.rgb(180, 180, 180), Color.rgb(120, 120, 125)))
|
||
},
|
||
LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f),
|
||
)
|
||
root.addView(
|
||
correctionToggleRow,
|
||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = dp(10) },
|
||
)
|
||
|
||
root.addView(buildDivider(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)).apply {
|
||
bottomMargin = dp(10)
|
||
})
|
||
|
||
val bottomRow = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL }
|
||
voiceButton = VoiceButton(this, isDarkTheme).apply {
|
||
isClickable = true
|
||
setOnClickListener { toggleDictation() }
|
||
contentDescription = ui("说出正确的词", "Speak the correct word")
|
||
}
|
||
// Noticeably wider than a plain circular icon so the recording
|
||
// waveform animation has real room to play.
|
||
bottomRow.addView(voiceButton!!, LinearLayout.LayoutParams(dp(170), dp(46)))
|
||
|
||
val textStack = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||
val title = TextView(this).apply {
|
||
text = ui("说出正确的词", "Speak to edit")
|
||
textSize = 15f
|
||
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
||
setTextColor(tone(Color.WHITE, Color.rgb(30, 30, 34)))
|
||
}
|
||
statusNormalColor = tone(Color.rgb(160, 160, 160), Color.rgb(120, 120, 125))
|
||
status = TextView(this).apply {
|
||
text = displayStatus(currentMessage)
|
||
textSize = 12f
|
||
setTextColor(statusNormalColor)
|
||
setPadding(0, dp(2), 0, 0)
|
||
}
|
||
textStack.addView(title, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||
textStack.addView(status, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||
bottomRow.addView(textStack, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||
marginStart = dp(12)
|
||
})
|
||
root.addView(bottomRow, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||
|
||
return root
|
||
}
|
||
|
||
private fun buildDivider(): View = View(this).apply { setBackgroundColor(tone(Color.rgb(68, 68, 68), Color.rgb(215, 215, 218))) }
|
||
|
||
// Tapping a toggle segment now slides the panel the same way a
|
||
// left/right swipe does (same refreshInputView(slideDirection)
|
||
// mechanics) — direction follows the segments' own left-to-right
|
||
// InputMode.entries order (see ModeToggle.onDraw()), so tapping a
|
||
// segment to the right slides in from the right, matching what a
|
||
// swipe in that direction would already do. Tapping the
|
||
// already-selected segment has no direction to slide from.
|
||
internal fun buildModeToggle(): View = ModeToggle(this, inputMode, isDarkTheme) { selected ->
|
||
val direction = when {
|
||
selected.ordinal > inputMode.ordinal -> 1
|
||
selected.ordinal < inputMode.ordinal -> -1
|
||
else -> null
|
||
}
|
||
selectInputMode(selected, slideDirection = direction)
|
||
}
|
||
|
||
/**
|
||
* White-on-transparent wordmark used in every panel header, replacing
|
||
* the old text label, plus the backend-link status dot right after it.
|
||
* A plain horizontal LinearLayout (not a FrameLayout) so the dot just
|
||
* lands after the logo/text in sequence with a fixed gap, regardless of
|
||
* which of the two logo/text branches below actually renders — the
|
||
* existing call sites all pass their own LinearLayout.LayoutParams for
|
||
* this whole row.
|
||
*/
|
||
internal fun buildBrandView(): View {
|
||
val bitmap = brandLogoBitmap
|
||
val wrapper = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = android.view.Gravity.START or android.view.Gravity.CENTER_VERTICAL
|
||
isClickable = true
|
||
contentDescription = ui("打开 OpenLess 设置", "Open OpenLess settings")
|
||
setOnClickListener { openSettings() }
|
||
// Long-press opens a full-screen native settings window for
|
||
// keyboard-only preferences (starting with vibration) that don't
|
||
// need the full OpenLess app — separate from the short-tap,
|
||
// which still opens the main app's own settings.
|
||
setOnLongClickListener {
|
||
openKeyboardSettings()
|
||
true
|
||
}
|
||
}
|
||
if (bitmap != null) {
|
||
// Half the wordmark's previous rendered size — at full size it
|
||
// crowded the header row next to the mode toggle/close button.
|
||
val heightPx = dp(19)
|
||
val widthPx = (heightPx.toFloat() * bitmap.width / bitmap.height).toInt()
|
||
wrapper.addView(android.widget.ImageView(this).apply {
|
||
setImageBitmap(bitmap)
|
||
scaleType = android.widget.ImageView.ScaleType.FIT_XY
|
||
// The wordmark asset is white-on-transparent; on the light
|
||
// theme's light panel that would be invisible, so it's
|
||
// recolored dark via a tint rather than shipping a second
|
||
// asset.
|
||
if (!isDarkTheme) {
|
||
colorFilter = android.graphics.PorterDuffColorFilter(Color.rgb(30, 30, 34), android.graphics.PorterDuff.Mode.SRC_IN)
|
||
}
|
||
}, LinearLayout.LayoutParams(widthPx, heightPx))
|
||
} else {
|
||
wrapper.addView(TextView(this).apply {
|
||
text = "OpenLess"
|
||
textSize = 18f
|
||
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
||
setTextColor(tone(Color.WHITE, Color.rgb(30, 30, 34)))
|
||
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||
}
|
||
// Status dot: see backendLinkIndicator's field comment. Reassigned
|
||
// here on every panel rebuild (buildBrandView() is shared by every
|
||
// panel's own header), then immediately colored so it never shows a
|
||
// stale state (e.g. still green right after a rebuild that happened
|
||
// while actually recording) — visible everywhere per product
|
||
// request, not just the Voice panel.
|
||
backendLinkIndicator = View(this).apply {
|
||
background = GradientDrawable().apply {
|
||
shape = GradientDrawable.OVAL
|
||
setColor(LINK_COLOR_READY)
|
||
}
|
||
}
|
||
// Continuous breathing pulse so the dot reads as "alive" rather
|
||
// than a static badge — cancels whatever animator was running on
|
||
// the previous panel's now-discarded indicator first, since each
|
||
// rebuild creates a brand new View instance.
|
||
backendLinkPulseAnimator?.cancel()
|
||
backendLinkPulseAnimator = android.animation.ObjectAnimator.ofFloat(backendLinkIndicator, View.ALPHA, 1f, 0.35f).apply {
|
||
duration = BACKEND_LINK_READY_PULSE_DURATION_MS
|
||
repeatMode = android.animation.ValueAnimator.REVERSE
|
||
repeatCount = android.animation.ValueAnimator.INFINITE
|
||
start()
|
||
}
|
||
wrapper.addView(
|
||
backendLinkIndicator,
|
||
LinearLayout.LayoutParams(dp(12), dp(12)).apply { marginStart = dp(5) },
|
||
)
|
||
updateBackendLinkIndicator()
|
||
return wrapper
|
||
}
|
||
|
||
private fun selectInputMode(selected: InputMode, slideDirection: Int? = null) {
|
||
if (recording || processing) cancelDictation()
|
||
// Leaving Voice mode mid-edit would otherwise leave editingDictationResult
|
||
// stuck true, so switching back to Voice later would wrongly reopen the
|
||
// edit sub-view instead of the normal panel.
|
||
editingDictationResult = false
|
||
awaitingEditReplacement = false
|
||
editingOriginalText = null
|
||
inputMode = selected
|
||
saveInputMode(selected)
|
||
englishLayer = EnglishLayer.LETTERS
|
||
englishComposingWord.clear()
|
||
englishCandidateQueryEpoch++
|
||
litePinyinController.clear()
|
||
litePinyinController.resetAssociationContext()
|
||
strokeController.resetForModeSwitch()
|
||
clipboardSelectionMode = false
|
||
clipboardSelectionAnchor = -1
|
||
clipboardSelectionActive = -1
|
||
clipboardHistoryMode = false
|
||
clipboardHistoryCategory = OpenLessClipboardHistory.Category.ALL
|
||
shiftState = ShiftState.OFF
|
||
if (slideDirection != null) refreshInputView(slideDirection) else refreshInputView()
|
||
}
|
||
|
||
/**
|
||
* Left/right swipe on any panel steps through the same Voice-Stroke-English
|
||
* order as the toggle switch, clamped at both ends (no wraparound) —
|
||
* swiping left keeps landing on Voice, right keeps landing on English.
|
||
* The new panel slides in from the side matching the ordinal direction
|
||
* (not necessarily the raw finger direction — see SwipeModeContainer).
|
||
*/
|
||
internal fun swipeInputMode(direction: Int) {
|
||
val modes = InputMode.entries
|
||
val next = modes[(inputMode.ordinal + direction).coerceIn(0, modes.lastIndex)]
|
||
if (next != inputMode) selectInputMode(next, slideDirection = direction)
|
||
}
|
||
|
||
/**
|
||
* English ABC/123/#+= keyboard — key relationships (rows, per-row key
|
||
* count, relative widths, and the 123/ABC/#+= switching logic) follow
|
||
* iOS 17's own English keyboard; colors, key surface, and every other
|
||
* visual detail are OpenLess's existing key styling (roundedButton()/
|
||
* tone()), matching the stroke panel. See EnglishLayer for the ABC/123/
|
||
* #+= state and README's "英文键盘 iOS 17 布局" entry for the full
|
||
* rationale.
|
||
*/
|
||
/**
|
||
* Wraps a panel root in a plain FrameLayout with a KeyPreviewOverlay as
|
||
* its second (top-drawn) child, so every key preview bubble in that
|
||
* panel renders above the whole keyboard — unclipped by the panel's own
|
||
* clipChildren/candidate-row/mode-toggle bounds — without taking part in
|
||
* its layout at all. The wrapper takes on the panel's own layoutParams
|
||
* (every panel already sets its own fixed MATCH_PARENT x keyboardPanelHeightPx()) so
|
||
* this changes nothing about the panel's measured size; the panel then
|
||
* fills the wrapper exactly as it used to fill the IME window directly.
|
||
*/
|
||
private fun wrapWithKeyPreviewOverlay(content: View): View {
|
||
val host = FrameLayout(this).apply {
|
||
layoutParams = content.layoutParams ?: ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx())
|
||
clipChildren = false
|
||
clipToPadding = false
|
||
}
|
||
host.addView(content, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||
val overlay = KeyPreviewOverlay(this, isDarkTheme)
|
||
keyPreviewOverlay = overlay
|
||
host.addView(overlay, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||
return host
|
||
}
|
||
|
||
private fun buildKeyboardView(): View {
|
||
val root = SwipeModeContainer(this) { direction -> swipeInputMode(direction) }.apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx())
|
||
minimumHeight = keyboardPanelHeightPx()
|
||
setPadding(dp(8), dp(8), dp(8), dp(8))
|
||
setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246)))
|
||
}
|
||
val header = LinearLayout(this).apply {
|
||
gravity = android.view.Gravity.CENTER_VERTICAL
|
||
}
|
||
header.addView(buildBrandView(), LinearLayout.LayoutParams(0, dp(38), 1f))
|
||
header.addView(buildModeToggle(), LinearLayout.LayoutParams(dp(240), dp(38)))
|
||
// This panel's own root padding (8dp) is narrower than the voice panel's
|
||
// (16dp), which it needs for its body rows. Compensate with margins so
|
||
// the header/toggle still land at the same canonical 16dp/8dp inset as
|
||
// every other panel — otherwise the logo and toggle visibly jump left
|
||
// and up when switching modes.
|
||
root.addView(header, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(38)).apply {
|
||
marginStart = dp(8)
|
||
marginEnd = dp(8)
|
||
})
|
||
|
||
root.addView(buildEnglishCandidateBar(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||
refreshLatinCandidateBar()
|
||
|
||
when (englishLayer) {
|
||
EnglishLayer.LETTERS -> {
|
||
addEnglishCharRow(
|
||
root,
|
||
listOf("q", "w", "e", "r", "t", "y", "u", "i", "o", "p"),
|
||
swipeSymbols = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0"),
|
||
)
|
||
// iOS insets this row by half a key on each side (9 keys
|
||
// spanning the same width as the 10-key rows above/below);
|
||
// 0.5f spacers on either side of 1f-weight letter keys
|
||
// reproduce that without a second, differently-measured row.
|
||
val row2 = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER }
|
||
row2.addView(View(this), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 0.5f))
|
||
listOf("a", "s", "d", "f", "g", "h", "j", "k", "l")
|
||
.zip(listOf("@", "#", "$", "%", "&", "-", "+", "(", ")"))
|
||
.forEach { (key, symbol) -> row2.addView(buildEnglishCharKey(key, 1f, swipeSymbol = symbol)) }
|
||
row2.addView(View(this), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 0.5f))
|
||
root.addView(row2, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
|
||
|
||
val row3 = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER }
|
||
row3.addView(keyboardKey("⇧", 1.5f, action = { cycleEnglishShift() }))
|
||
listOf("z", "x", "c", "v", "b", "n", "m")
|
||
.zip(listOf(":", ";", "'", ".", ",", "!", "?"))
|
||
.forEach { (key, symbol) -> row3.addView(buildEnglishCharKey(key, 1f, swipeSymbol = symbol)) }
|
||
row3.addView(
|
||
keyboardKey("⌫", 1.5f, action = { englishDeleteBackward() }, repeatOnLongPress = true, repeatAction = { englishDeleteBackward() }).apply {
|
||
// Same cherry red as the stroke panel's own
|
||
// right-side action rail, matching the Return key
|
||
// right next to it.
|
||
background = roundedButton(Color.rgb(153, 26, 40), dp(5))
|
||
setTextColor(Color.WHITE)
|
||
},
|
||
)
|
||
root.addView(row3, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
|
||
}
|
||
EnglishLayer.NUMBERS -> {
|
||
addEnglishCharRow(root, listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0"))
|
||
addEnglishCharRow(root, listOf("-", "/", ":", ";", "(", ")", "$", "&", "@", "\""))
|
||
addEnglishSymbolActionRow(root, listOf(".", ",", "?", "!", "'"))
|
||
}
|
||
EnglishLayer.SYMBOLS -> {
|
||
addEnglishCharRow(root, listOf("[", "]", "{", "}", "#", "%", "^", "*", "+", "="))
|
||
addEnglishCharRow(root, listOf("_", "\\", "|", "~", "<", ">", "€", "£", "¥", "•"))
|
||
addEnglishSymbolActionRow(root, listOf(".", ",", "?", "!", "'"))
|
||
}
|
||
}
|
||
|
||
val bottom = LinearLayout(this).apply {
|
||
gravity = android.view.Gravity.CENTER_VERTICAL
|
||
}
|
||
// Same sans-serif-medium as the letter grid (buildEnglishCharKey()'s
|
||
// letterView), applied after the fact rather than as a keyboardKey()
|
||
// parameter — keyboardKey() is shared by every panel's keys, and
|
||
// only these two English bottom-row labels are meant to match the
|
||
// letters' new weight for now. Size/color/position left exactly as
|
||
// keyboardKey() already set them.
|
||
val modeButton = keyboardKey(if (englishLayer == EnglishLayer.LETTERS) "123" else "ABC", 1.3f, action = {
|
||
handleEnglishBottomModeToggle()
|
||
}).apply { typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) }
|
||
// "Space" stays keyboardKey()'s own plain centered label (own
|
||
// View, own vertical centering, untouched by the hint) — per
|
||
// real-device feedback, side-by-side (hint right-aligned next to
|
||
// "Space", both 12sp) reads much better than the two stacked-line
|
||
// approaches tried first (a single two-line SpannableString label,
|
||
// and before that a padding-pushed overlay), both of which fought
|
||
// this row's actual (tighter than assumed) real height.
|
||
val spaceButton = keyboardKey("Space", 5f, action = {
|
||
// Space never commits a pinyin candidate (see
|
||
// toggleLatinInputMode()'s own doc comment) — a short press
|
||
// always just inserts a literal space, in both modes, and
|
||
// abandons whatever was mid-composition. This is deliberate:
|
||
// it's a software keyboard, not a physical one where "space
|
||
// selects the first candidate" is a decades-old muscle-memory
|
||
// convention worth preserving.
|
||
finalizeEnglishComposingWord()
|
||
litePinyinController.clear()
|
||
currentInputConnection?.commitText(" ", 1)
|
||
}, longPressAction = { toggleLatinInputMode() }).apply {
|
||
// Same size as Return (17sp — keyboardKey()'s own special case
|
||
// for that label), per real-device feedback.
|
||
textSize = 17f
|
||
}
|
||
// Own View, overlaid on top of spaceButton (not inside its own
|
||
// text) — left-aligned, vertically centered, non-interactive so
|
||
// touches still reach the key beneath it. Shows BOTH labels
|
||
// stacked (拼音 always on top, 英文 always on the bottom — fixed
|
||
// position, per user request) rather than just naming the target
|
||
// mode — whichever is currently active is cherry red, the other
|
||
// gray, so long-press's effect is legible at a glance either way.
|
||
val spaceHint = TextView(this).apply {
|
||
val pinyinLabel = ui("拼音", "PINYIN")
|
||
val englishLabel = ui("英文", "ENGLISH")
|
||
val hintText = "$pinyinLabel\n$englishLabel"
|
||
val englishStart = hintText.length - englishLabel.length
|
||
val activeColor = if (isDarkTheme) Color.rgb(190, 45, 60) else Color.rgb(153, 26, 40)
|
||
val inactiveColor = tone(Color.rgb(150, 150, 150), Color.rgb(140, 140, 145))
|
||
text = android.text.SpannableString(hintText).apply {
|
||
setSpan(
|
||
android.text.style.ForegroundColorSpan(if (latinInputMode == LatinInputMode.PINYIN) activeColor else inactiveColor),
|
||
0, pinyinLabel.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
|
||
)
|
||
setSpan(
|
||
android.text.style.ForegroundColorSpan(if (latinInputMode == LatinInputMode.ENGLISH) activeColor else inactiveColor),
|
||
englishStart, hintText.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
|
||
)
|
||
}
|
||
textSize = 12f
|
||
gravity = android.view.Gravity.CENTER
|
||
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
||
isClickable = false
|
||
// The actual bug behind every earlier "text is just missing"
|
||
// attempt at a FrameLayout overlay on a keyboardKey(): that key
|
||
// has its own non-zero elevation+translationZ (dp(5)+dp(1), see
|
||
// keyboardKey()'s own body) for its raised-surface look, and on
|
||
// API 21+ ViewGroup draws children in Z order — not insertion
|
||
// order — whenever their Z values differ. spaceButton's Z was
|
||
// silently winning and painting straight over this View despite
|
||
// being added to the FrameLayout first. A higher Z here forces
|
||
// this View to actually draw on top.
|
||
translationZ = dp(10).toFloat()
|
||
}
|
||
val spaceWrapper = FrameLayout(this).apply {
|
||
addView(spaceButton, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||
addView(
|
||
spaceHint,
|
||
FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, android.view.Gravity.CENTER_VERTICAL or android.view.Gravity.START).apply {
|
||
marginStart = dp(14)
|
||
},
|
||
)
|
||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 5f)
|
||
}
|
||
val returnButton = keyboardKey("Return", 1.7f, action = {
|
||
// Plan 3.3: "Enter 有候选时提交第一候选,无编码时使用原有回车行为"
|
||
// — reuses candidateOverlayEntries (already the current row's
|
||
// (label, action) pairs, pinyin or English, set by whichever
|
||
// render*Candidates() last ran) rather than a second field
|
||
// duplicating the same list.
|
||
val firstPinyinCandidate = candidateOverlayEntries.firstOrNull()
|
||
if (latinInputMode == LatinInputMode.PINYIN && firstPinyinCandidate != null) {
|
||
firstPinyinCandidate.second.invoke()
|
||
} else {
|
||
finalizeEnglishComposingWord()
|
||
sendEnterKey()
|
||
}
|
||
}).apply {
|
||
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
|
||
// Same cherry red as the stroke panel's own right-side action
|
||
// rail (Color.rgb(153, 26, 40)), per product request; fixed
|
||
// white text regardless of theme since keyboardKey()'s own
|
||
// default text color reads poorly against red in light theme.
|
||
background = roundedButton(Color.rgb(153, 26, 40), dp(5))
|
||
setTextColor(Color.WHITE)
|
||
}
|
||
bottom.addView(modeButton)
|
||
bottom.addView(spaceWrapper)
|
||
bottom.addView(returnButton)
|
||
root.addView(bottom, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
|
||
return root
|
||
}
|
||
|
||
/**
|
||
* A plain character row (digits/symbols, no shift-sensitivity) built
|
||
* from buildEnglishCharKey() — key preview, no drag-to-adjacent-key
|
||
* distinction from a letter row since neither cares about shift.
|
||
*
|
||
* @param swipeSymbols Same size as [keys] when given (the QWERTY top
|
||
* row's own q..p -> 1..0 mapping) — forwarded 1:1 as each key's own
|
||
* swipe-up symbol. Left null for every other row (NUMBERS/SYMBOLS
|
||
* layers are already digits/symbols themselves; nothing to swipe up
|
||
* to). Clears englishSwipeSymbols first — this is always the first
|
||
* row built for a fresh LETTERS render (see buildKeyboardView()), so
|
||
* row2/row3's own direct buildEnglishCharKey() calls right after can
|
||
* add straight into the same now-fresh map without re-clearing it.
|
||
*/
|
||
private fun addEnglishCharRow(parent: LinearLayout, keys: List<String>, swipeSymbols: List<String>? = null) {
|
||
if (swipeSymbols != null) englishSwipeSymbols.clear()
|
||
val row = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER }
|
||
keys.forEachIndexed { index, key ->
|
||
row.addView(buildEnglishCharKey(key, 1f, swipeSymbol = swipeSymbols?.getOrNull(index)))
|
||
}
|
||
parent.addView(row, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
|
||
}
|
||
|
||
/**
|
||
* 123 page's row 3 (`#+= . , ? ! ' ⌫`) and #+= page's row 3
|
||
* (`123 . , ? ! ' ⌫`) — same shape, only the leading toggle
|
||
* key's label/action differs, so both layers share this one builder.
|
||
*/
|
||
private fun addEnglishSymbolActionRow(parent: LinearLayout, middleKeys: List<String>) {
|
||
val row = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER }
|
||
val toggleLabel = if (englishLayer == EnglishLayer.NUMBERS) "#+=" else "123"
|
||
row.addView(keyboardKey(toggleLabel, 1.5f, action = { handleEnglishRow3ModeToggle() }))
|
||
middleKeys.forEach { row.addView(buildEnglishCharKey(it, 1f)) }
|
||
row.addView(
|
||
keyboardKey("⌫", 1.5f, action = { englishDeleteBackward() }, repeatOnLongPress = true, repeatAction = { englishDeleteBackward() }).apply {
|
||
background = roundedButton(Color.rgb(153, 26, 40), dp(5))
|
||
setTextColor(Color.WHITE)
|
||
},
|
||
)
|
||
parent.addView(row, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
|
||
}
|
||
|
||
/** Bottom-row toggle: always jumps straight to/from Letters, regardless of which of Numbers/Symbols was showing (matches iOS's own "ABC" key). */
|
||
private fun handleEnglishBottomModeToggle() {
|
||
englishLayer = if (englishLayer == EnglishLayer.LETTERS) EnglishLayer.NUMBERS else EnglishLayer.LETTERS
|
||
shiftState = ShiftState.OFF
|
||
finalizeEnglishComposingWord()
|
||
refreshInputView()
|
||
}
|
||
|
||
/** Row-3 toggle: only ever switches between Numbers and Symbols, never touches Letters (matches iOS's own "#+="/"123" key). */
|
||
private fun handleEnglishRow3ModeToggle() {
|
||
englishLayer = if (englishLayer == EnglishLayer.NUMBERS) EnglishLayer.SYMBOLS else EnglishLayer.NUMBERS
|
||
refreshInputView()
|
||
}
|
||
|
||
private fun cycleEnglishShift() {
|
||
// Cycles lowercase -> capitalize-next -> caps-lock -> lowercase.
|
||
shiftState = when (shiftState) {
|
||
ShiftState.OFF -> ShiftState.SHIFT_ONCE
|
||
ShiftState.SHIFT_ONCE -> ShiftState.CAPS_LOCK
|
||
ShiftState.CAPS_LOCK -> ShiftState.OFF
|
||
}
|
||
refreshInputView()
|
||
}
|
||
|
||
/** Backspace on the English keyboard: same field edit as everywhere else, plus keeping englishComposingWord (and therefore the candidate bar) in sync. In Pinyin mode, deletes from the (never-committed) encoding instead — falls back to the normal field edit only once the encoding is already empty (plan 3.3: "编码为空时 Backspace 恢复现有普通删除"). */
|
||
private fun englishDeleteBackward() {
|
||
if (latinInputMode == LatinInputMode.PINYIN && litePinyinController.backspace { renderPinyinCandidates(it) }) {
|
||
return
|
||
}
|
||
deleteBackward()
|
||
if (englishComposingWord.isNotEmpty()) {
|
||
englishComposingWord.deleteCharAt(englishComposingWord.length - 1)
|
||
}
|
||
updateEnglishCandidates()
|
||
}
|
||
|
||
/**
|
||
* The selected/first candidate is marked by color+weight only (the same
|
||
* red as the right-hand action rail's ←/↵/清除/123 keys, bold) — no
|
||
* size, background, border or shadow change, so it can't shift
|
||
* candidate width/spacing or row height. A real else branch (not just
|
||
* skipping the isFirst==false case) matters once populateCandidateRow()
|
||
* can reuse a view that used to be first-candidate for one that isn't.
|
||
*/
|
||
internal fun styleCandidateFirstState(view: TextView, isFirst: Boolean) {
|
||
if (isFirst) {
|
||
// Same red as the action rail's own background
|
||
// (Color.rgb(153, 26, 40)), brightened a touch for dark
|
||
// theme only — same hue, just a bit lighter so it reads
|
||
// more clearly against a dark panel; light theme keeps the
|
||
// exact action-rail red.
|
||
view.setTextColor(if (isDarkTheme) Color.rgb(190, 45, 60) else Color.rgb(153, 26, 40))
|
||
view.setTypeface(view.typeface, android.graphics.Typeface.BOLD)
|
||
} else {
|
||
view.setTextColor(tone(Color.rgb(245, 245, 245), Color.rgb(30, 30, 34)))
|
||
view.setTypeface(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.NORMAL)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Plain-text candidate item — no independent keycap background, just the
|
||
* label, matching a stroke candidate bar rather than a row of separate
|
||
* buttons. Height always comes from the parent row (MATCH_PARENT) so it
|
||
* can never itself grow the fixed 36dp candidate row.
|
||
*/
|
||
internal fun candidateItemView(
|
||
label: String,
|
||
isFirst: Boolean,
|
||
onLongPress: (() -> Unit)? = null,
|
||
action: () -> Unit,
|
||
): TextView {
|
||
return keyboardKey(label, 1f, action = action, longPressAction = onLongPress).apply {
|
||
// Was 20f/dp(9); briefly tried 17f/dp(12) to fix a clipped-glyph
|
||
// report on a Xiaomi MIUI device (that OEM's default system
|
||
// font renders visibly wider per character than stock Android
|
||
// at the same sp), but 20sp reads noticeably better across
|
||
// devices — settled on keeping the original text size and
|
||
// widening padding a bit instead (dp(9) -> dp(11)), which still
|
||
// gives MIUI's wider glyphs a bit more breathing room without
|
||
// shrinking the text everyone else sees.
|
||
textSize = 20f
|
||
setSingleLine(true)
|
||
maxLines = 1
|
||
background = null
|
||
elevation = 0f
|
||
translationZ = 0f
|
||
setPadding(dp(11), 0, dp(11), 0)
|
||
styleCandidateFirstState(this, isFirst)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* "Show more candidates" overlay — a PopupWindow anchored below the
|
||
* candidate row, wrapping the current full candidate/association list
|
||
* (whichever is showing) into a flow of rows. A PopupWindow floats over
|
||
* the existing panel without resizing or displacing it, matching the
|
||
* "must not push the keys below or change the keyboard height" rule.
|
||
*/
|
||
internal fun showCandidateOverlay(anchor: View) {
|
||
if (candidateOverlayEntries.isEmpty()) return
|
||
var activePopup: android.widget.PopupWindow? = null
|
||
val content = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(8), dp(8), dp(8), dp(8))
|
||
}
|
||
var currentRow: LinearLayout? = null
|
||
var usedWidth = 0
|
||
val maxRowWidth = resources.displayMetrics.widthPixels - dp(32)
|
||
candidateOverlayEntries.forEach { (label, action) ->
|
||
val itemWidth = dp((label.codePointCount(0, label.length) * 24 + 20).coerceAtLeast(52))
|
||
if (currentRow == null || usedWidth + itemWidth > maxRowWidth) {
|
||
currentRow = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
|
||
content.addView(currentRow)
|
||
usedWidth = 0
|
||
}
|
||
currentRow?.addView(
|
||
TextView(this).apply {
|
||
text = label
|
||
textSize = 21f
|
||
setSingleLine(true)
|
||
gravity = android.view.Gravity.CENTER
|
||
setTextColor(tone(Color.rgb(245, 245, 245), Color.rgb(30, 30, 34)))
|
||
setPadding(dp(10), dp(10), dp(10), dp(10))
|
||
setOnClickListener {
|
||
action()
|
||
activePopup?.dismiss()
|
||
}
|
||
},
|
||
LinearLayout.LayoutParams(itemWidth, dp(46)),
|
||
)
|
||
usedWidth += itemWidth
|
||
}
|
||
val scroll = android.widget.ScrollView(this).apply {
|
||
isVerticalScrollBarEnabled = false
|
||
addView(content, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||
}
|
||
val card = LinearLayout(this).apply {
|
||
addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||
background = roundedButton(tone(Color.rgb(45, 45, 45), Color.rgb(238, 238, 241)), dp(8))
|
||
}
|
||
val popup = android.widget.PopupWindow(
|
||
card,
|
||
resources.displayMetrics.widthPixels,
|
||
dp(150),
|
||
true,
|
||
)
|
||
activePopup = popup
|
||
popup.isOutsideTouchable = true
|
||
popup.elevation = dp(8).toFloat()
|
||
popup.showAsDropDown(anchor, -anchor.left, dp(2))
|
||
}
|
||
|
||
/**
|
||
* Sends a cursor-movement key. Left/right while clipboardSelectionMode
|
||
* is on extend the selection directly via InputConnection.setSelection()
|
||
* instead of a synthetic Shift+Arrow KeyEvent — many host apps (e.g.
|
||
* WeCom) use a custom edit widget that never wires KeyEvent-driven
|
||
* selection extension up to Android's built-in ArrowKeyMovementMethod,
|
||
* so the Shift+Arrow approach silently did nothing there. setSelection()
|
||
* talks to the same InputConnection API the rest of this IME already
|
||
* relies on for text manipulation, so it works regardless of how the
|
||
* host widget itself handles key events.
|
||
*/
|
||
private fun sendClipboardCursorKey(code: Int) {
|
||
val connection = currentInputConnection ?: return
|
||
val isLeft = code == android.view.KeyEvent.KEYCODE_DPAD_LEFT
|
||
val isRight = code == android.view.KeyEvent.KEYCODE_DPAD_RIGHT
|
||
if (clipboardSelectionMode && (isLeft || isRight)) {
|
||
if (clipboardSelectionAnchor < 0) {
|
||
val extracted = connection.getExtractedText(android.view.inputmethod.ExtractedTextRequest(), 0)
|
||
val start = extracted?.selectionStart?.coerceAtLeast(0) ?: 0
|
||
clipboardSelectionAnchor = start
|
||
clipboardSelectionActive = start
|
||
}
|
||
clipboardSelectionActive = (clipboardSelectionActive + if (isRight) 1 else -1).coerceAtLeast(0)
|
||
connection.setSelection(
|
||
minOf(clipboardSelectionAnchor, clipboardSelectionActive),
|
||
maxOf(clipboardSelectionAnchor, clipboardSelectionActive),
|
||
)
|
||
return
|
||
}
|
||
val now = android.os.SystemClock.uptimeMillis()
|
||
connection.sendKeyEvent(android.view.KeyEvent(now, now, android.view.KeyEvent.ACTION_DOWN, code, 0))
|
||
connection.sendKeyEvent(android.view.KeyEvent(now, now, android.view.KeyEvent.ACTION_UP, code, 0))
|
||
}
|
||
|
||
/**
|
||
* Clipboard panel: a direction pad (arrow keys double as selection
|
||
* extension when "选择" is toggled on) plus select-all/copy/paste and a
|
||
* button into the clipboard history browser. Styled like the stroke
|
||
* panel's own key grid.
|
||
*/
|
||
private fun buildClipboardView(): View {
|
||
val root = SwipeModeContainer(this) { direction -> swipeInputMode(direction) }.apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx())
|
||
minimumHeight = keyboardPanelHeightPx()
|
||
setPadding(dp(8), dp(8), dp(8), dp(8))
|
||
setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246)))
|
||
}
|
||
root.addView(buildVoiceHeader(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(38)).apply {
|
||
marginStart = dp(8)
|
||
marginEnd = dp(8)
|
||
})
|
||
|
||
// Most-recent-three-clips quick-tap strip (was four rows — reduced
|
||
// per user request, the freed space redistributed evenly across the
|
||
// remaining three so RECENT_CLIPS_COLUMN_HEIGHT_DP's own total is
|
||
// unchanged; see that constant's doc comment for the exact math).
|
||
// The rows use circled Unicode digits (①②③) for their prefixes; the
|
||
// glyphs come from Android's normal font fallback rather than a
|
||
// hand-drawn canvas shape. Flat gray now (was white prefix + cherry
|
||
// red content, before that a blue accent) — per user request, no
|
||
// color distinction between the prefix and the clip text anymore.
|
||
// The action grid below shrinks by the strip's fixed height
|
||
// automatically (LinearLayout's own weight=1f on `grid` further
|
||
// down already absorbs however much space this strip doesn't use —
|
||
// no separate shrink logic needed). Thin divider lines separate the
|
||
// rows rather than a colored background, matching the plain-divider
|
||
// treatment already used for the English candidate bar.
|
||
val circledIndices = listOf('①', '②', '③')
|
||
val recentClipTextColor = tone(Color.rgb(190, 190, 190), Color.rgb(140, 140, 145))
|
||
fun recentClipRow(index: Int, entry: ClipboardEntry?): TextView = TextView(this).apply {
|
||
text = entry?.let { "${circledIndices[index]} ${it.text}" } ?: ""
|
||
textSize = 16f
|
||
maxLines = 1
|
||
ellipsize = android.text.TextUtils.TruncateAt.END
|
||
gravity = android.view.Gravity.CENTER_VERTICAL
|
||
setTextColor(recentClipTextColor)
|
||
setPadding(dp(10), 0, dp(10), 0)
|
||
if (entry != null) {
|
||
isClickable = true
|
||
setOnClickListener {
|
||
currentInputConnection?.commitText(entry.text, 1)
|
||
OpenLessClipboardHistory.recordCopy(this@OpenLessImeService, entry.text)
|
||
refreshInputView()
|
||
}
|
||
}
|
||
}
|
||
// distinctBy(text): recordCopy() already dedupes going forward (an
|
||
// existing entry is moved to the front instead of a second row being
|
||
// added), but this guards the display itself against any duplicate
|
||
// already sitting in an existing history file (e.g. one written
|
||
// before that dedup existed) rather than trusting the stored data.
|
||
val recentClips = OpenLessClipboardHistory.load(this).distinctBy { it.text }
|
||
val recentClipsColumn = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||
recentClipsColumn.addView(buildDivider(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)))
|
||
for (index in 0 until 3) {
|
||
recentClipsColumn.addView(
|
||
recentClipRow(index, recentClips.getOrNull(index)),
|
||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f),
|
||
)
|
||
recentClipsColumn.addView(buildDivider(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)))
|
||
}
|
||
// Added to root below the action grid, not here — per user request,
|
||
// the keyboard/action grid sits above the recent-clips strip now
|
||
// (was the other way around).
|
||
|
||
// Arrow keys and the Select toggle work as one unit: while selection
|
||
// mode is on, the arrows extend the selection instead of just moving
|
||
// the cursor, so they share the same active-highlight background as
|
||
// the Select button itself — and lose it the moment it's toggled off.
|
||
val gap = dp(6)
|
||
val mediumTypeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||
val softWhite = tone(Color.rgb(224, 224, 224), Color.rgb(40, 40, 44))
|
||
|
||
// Same keycap background as the stroke panel's own keys (plain
|
||
// keyboardKey() default background/elevation for the normal state,
|
||
// roundedButton() with the same rose highlight the stroke panel's
|
||
// own "繁" toggle uses when active) — only the label/icon size and
|
||
// grid spacing are new, not the button's edge style or palette.
|
||
fun quickActionLabel(label: String, action: () -> Unit, textSizeSp: Float, highlighted: Boolean = false, midDivider: Boolean = false) =
|
||
keyboardKey(label, 1f, action = action, midDivider = midDivider).apply {
|
||
textSize = textSizeSp
|
||
typeface = mediumTypeface
|
||
setTextColor(softWhite)
|
||
if (highlighted) background = roundedButton(tone(Color.rgb(112, 78, 92), Color.rgb(232, 205, 213)), dp(5))
|
||
attachPressScale(this)
|
||
}
|
||
|
||
fun quickActionIcon(
|
||
graphicActionCode: String,
|
||
action: () -> Unit,
|
||
highlighted: Boolean = false,
|
||
rotationDegrees: Float = 0f,
|
||
repeatOnLongPress: Boolean = false,
|
||
// Unlike the stroke panel's always-dark-red action keys (white
|
||
// in both themes, StrokeActionView's own default), these icons
|
||
// normally sit on a normal or rose key that flips with the
|
||
// theme — pass an explicit color for a caller whose key is red
|
||
// regardless of theme (e.g. backspaceKey below), same reasoning
|
||
// as keyboardKey()'s own graphicIconColor doc comment.
|
||
iconColor: Int = tone(Color.WHITE, Color.rgb(30, 30, 34)),
|
||
) = keyboardKey(
|
||
"",
|
||
1f,
|
||
action = action,
|
||
repeatOnLongPress = repeatOnLongPress,
|
||
repeatAction = if (repeatOnLongPress) action else null,
|
||
graphicActionCode = graphicActionCode,
|
||
// Rotates only the icon drawn on the canvas, not the button
|
||
// View itself — rotating the whole View would also distort its
|
||
// rectangular background in a non-square cell.
|
||
graphicRotation = rotationDegrees,
|
||
graphicIconColor = iconColor,
|
||
).apply {
|
||
if (highlighted) background = roundedButton(tone(Color.rgb(112, 78, 92), Color.rgb(232, 205, 213)), dp(5))
|
||
attachPressScale(this)
|
||
}
|
||
|
||
fun cell(): LinearLayout.LayoutParams {
|
||
val params = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)
|
||
params.marginStart = gap / 2
|
||
params.marginEnd = gap / 2
|
||
return params
|
||
}
|
||
|
||
val grid = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||
val row1 = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER }
|
||
val selectKey = quickActionLabel(ui("选择", "Range"), {
|
||
clipboardSelectionMode = !clipboardSelectionMode
|
||
clipboardSelectionAnchor = -1
|
||
clipboardSelectionActive = -1
|
||
refreshInputView()
|
||
}, 20f, highlighted = clipboardSelectionMode)
|
||
val leftKey = quickActionIcon("dir-up", { sendClipboardCursorKey(android.view.KeyEvent.KEYCODE_DPAD_LEFT) }, clipboardSelectionMode, rotationDegrees = 270f)
|
||
val rightKey = quickActionIcon("dir-up", { sendClipboardCursorKey(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) }, clipboardSelectionMode, rotationDegrees = 90f)
|
||
val upKey = quickActionIcon("dir-up", { sendClipboardCursorKey(android.view.KeyEvent.KEYCODE_DPAD_UP) }, clipboardSelectionMode, rotationDegrees = 0f)
|
||
val downKey = quickActionIcon("dir-up", { sendClipboardCursorKey(android.view.KeyEvent.KEYCODE_DPAD_DOWN) }, clipboardSelectionMode, rotationDegrees = 180f)
|
||
for (key in listOf(selectKey, leftKey, rightKey, upKey, downKey)) {
|
||
row1.addView(key, cell())
|
||
}
|
||
grid.addView(row1, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f).apply {
|
||
bottomMargin = gap / 2
|
||
topMargin = gap / 2
|
||
})
|
||
|
||
val row2 = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER }
|
||
val selectAllKey = quickActionLabel(ui("全选", "Select"), {
|
||
currentInputConnection?.performContextMenuAction(android.R.id.selectAll)
|
||
}, 20f)
|
||
val copyKey = quickActionLabel(ui("复制", "Copy"), {
|
||
currentInputConnection?.performContextMenuAction(android.R.id.copy)
|
||
}, 20f)
|
||
val pasteKey = quickActionLabel(ui("粘贴", "Paste"), {
|
||
currentInputConnection?.performContextMenuAction(android.R.id.paste)
|
||
}, 20f)
|
||
// Two lines ("History" / "Dict") since this button now opens both
|
||
// the clipboard history browser (tap) and the voice-correction flow
|
||
// for whatever's selected in the real input field (long-press) —
|
||
// labeled "Dict" since the long-press flow writes to the global
|
||
// Dictionary now, not a correction rule.
|
||
// quickActionLabel()->keyboardKey()'s own '\n' handling shrinks only
|
||
// the label's first character (built for single-char rows like
|
||
// "1\n!"), which would leave just one letter undersized here, so the
|
||
// spanned text it sets is overwritten with a plain two-line string
|
||
// right after construction.
|
||
// Same cherry red as the stroke panel's own right-side action rail
|
||
// (Color.rgb(153, 26, 40)) — per product request, so these two keys
|
||
// read as the equivalent "action" keys on this panel.
|
||
val clipboardKey = quickActionLabel(ui("历史\n字典", "History\nDict"), {
|
||
clipboardHistoryMode = true
|
||
refreshInputView()
|
||
}, 17f, midDivider = true).apply {
|
||
text = ui("历史\n字典", "History\nDict")
|
||
background = roundedButton(Color.rgb(153, 26, 40), dp(5))
|
||
// quickActionLabel()'s own softWhite text color reads fine on a
|
||
// normal/rose key but not on this now-red one (dark gray in
|
||
// light theme, on dark red) — fixed white regardless of theme,
|
||
// same reasoning as backspaceKey's iconColor override below.
|
||
setTextColor(Color.WHITE)
|
||
setOnLongClickListener {
|
||
openSelectedTextCorrectionViaVoice()
|
||
true
|
||
}
|
||
}
|
||
val backspaceKey = quickActionIcon(
|
||
"backspace-icon",
|
||
{ deleteBackward() },
|
||
repeatOnLongPress = true,
|
||
iconColor = Color.WHITE,
|
||
).apply {
|
||
background = roundedButton(Color.rgb(153, 26, 40), dp(5))
|
||
}
|
||
for (key in listOf(selectAllKey, copyKey, pasteKey, clipboardKey, backspaceKey)) {
|
||
row2.addView(key, cell())
|
||
}
|
||
grid.addView(row2, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f).apply {
|
||
topMargin = gap / 2
|
||
})
|
||
|
||
root.addView(grid, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f).apply {
|
||
topMargin = dp(8)
|
||
})
|
||
root.addView(recentClipsColumn, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(RECENT_CLIPS_COLUMN_HEIGHT_DP)).apply {
|
||
topMargin = dp(8)
|
||
})
|
||
return root
|
||
}
|
||
|
||
/**
|
||
* Clipboard history browser: category tabs (全部/最近/文本/数字/链接) over a
|
||
* scrollable list of past clips. Tapping an entry commits it to the field
|
||
* and bumps it back to the front of the history, then returns to the
|
||
* direction-pad panel.
|
||
*/
|
||
private fun buildClipboardHistoryView(): View {
|
||
// Assigned once `scroll` itself is built, further down — captured
|
||
// by reference here so SwipeModeContainer's verticalDismissAllowed
|
||
// lambda reads whatever scroll position exists at actual touch
|
||
// time (the lambda only ever runs from a real user gesture, well
|
||
// after this whole view is fully constructed).
|
||
var historyScroll: android.widget.ScrollView? = null
|
||
// Rows handle their own left/right swipe (favorite / correction
|
||
// rule); the panel-switch swipe would otherwise compete for the
|
||
// exact same gesture. A downward drag only dismisses the keyboard
|
||
// once the list below is already scrolled to its own top — per
|
||
// user request, so scrolling up through history doesn't get
|
||
// mistaken for "swipe down to hide" partway through.
|
||
val root = SwipeModeContainer(
|
||
this,
|
||
horizontalSwipeEnabled = false,
|
||
verticalDismissAllowed = { (historyScroll?.scrollY ?: 0) == 0 },
|
||
) { direction -> swipeInputMode(direction) }.apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, keyboardPanelHeightPx())
|
||
minimumHeight = keyboardPanelHeightPx()
|
||
setPadding(dp(16), dp(8), dp(16), dp(8))
|
||
setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246)))
|
||
clipChildren = false
|
||
clipToPadding = false
|
||
}
|
||
root.addView(buildVoiceHeader(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(38)))
|
||
root.addView(buildDivider(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)).apply {
|
||
topMargin = dp(6)
|
||
})
|
||
|
||
val tabsRow = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL }
|
||
val tabs = listOf(
|
||
OpenLessClipboardHistory.Category.ALL to ui("全部", "All"),
|
||
OpenLessClipboardHistory.Category.RECENT to ui("最近", "Recent"),
|
||
OpenLessClipboardHistory.Category.TEXT to ui("文本", "Text"),
|
||
OpenLessClipboardHistory.Category.NUMBER to ui("数字", "Number"),
|
||
OpenLessClipboardHistory.Category.LINK to ui("链接", "Link"),
|
||
)
|
||
tabs.forEach { (category, label) ->
|
||
val selected = category == clipboardHistoryCategory
|
||
tabsRow.addView(TextView(this).apply {
|
||
text = label
|
||
textSize = 14f
|
||
gravity = android.view.Gravity.CENTER
|
||
setTextColor(if (selected) Color.rgb(153, 26, 40) else tone(Color.rgb(190, 190, 190), Color.rgb(140, 140, 145)))
|
||
if (englishUi) {
|
||
typeface = Typeface.create("sans-serif-medium", if (selected) Typeface.BOLD else Typeface.NORMAL)
|
||
} else {
|
||
setTypeface(typeface, if (selected) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL)
|
||
}
|
||
setOnClickListener {
|
||
clipboardHistoryCategory = category
|
||
refreshInputView()
|
||
}
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f))
|
||
}
|
||
// Not a category filter like the five tabs above — an action:
|
||
// deletes every non-favorited history entry outright (favorites are
|
||
// kept). Never shows "selected" styling since it isn't one of
|
||
// clipboardHistoryCategory's own values. No confirmation step per
|
||
// the request as given; only this app's own local history is
|
||
// affected, not anything external.
|
||
tabsRow.addView(TextView(this).apply {
|
||
text = ui("清空", "Clear")
|
||
textSize = 14f
|
||
gravity = android.view.Gravity.CENTER
|
||
setTextColor(tone(Color.rgb(190, 190, 190), Color.rgb(140, 140, 145)))
|
||
if (englishUi) {
|
||
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
|
||
}
|
||
setOnClickListener {
|
||
OpenLessClipboardHistory.clearNonFavorites(this@OpenLessImeService)
|
||
refreshInputView()
|
||
}
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f))
|
||
tabsRow.addView(flatCircleButton("✕", 26, ui("关闭粘贴板", "Close clipboard")) {
|
||
clipboardHistoryMode = false
|
||
refreshInputView()
|
||
}, LinearLayout.LayoutParams(dp(30), dp(30)))
|
||
root.addView(tabsRow, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(34)).apply {
|
||
topMargin = dp(8)
|
||
})
|
||
|
||
root.addView(buildDivider(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)).apply {
|
||
topMargin = dp(6)
|
||
bottomMargin = dp(6)
|
||
})
|
||
|
||
val listContainer = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||
val entries = OpenLessClipboardHistory.filter(OpenLessClipboardHistory.load(this), clipboardHistoryCategory)
|
||
// The Dictionary lives in the Rust backend (CORE_BACKEND), which is
|
||
// only registered once mobile_runtime::run()'s setup() has actually
|
||
// executed — not guaranteed just because the IME is showing. Without
|
||
// this, nativeVocabularyPhrases()/nativeAddVocabularyWord() below
|
||
// silently no-op against an unregistered backend: the star-like icon
|
||
// never appears and the swipe zone's "add" never takes, with no
|
||
// visible error either place.
|
||
ensureBackendReady()
|
||
// Fetched once per panel build, not per row: a native round trip
|
||
// per row would be wasted work when a single JSON snapshot already
|
||
// answers "is this text already in the dictionary" for all of them.
|
||
val vocabularyPhrases: Set<String> = try {
|
||
val array = org.json.JSONArray(OpenLessNative.nativeVocabularyPhrases())
|
||
(0 until array.length()).mapTo(mutableSetOf()) { array.getString(it) }
|
||
} catch (error: Exception) {
|
||
emptySet()
|
||
}
|
||
if (entries.isEmpty()) {
|
||
listContainer.addView(TextView(this).apply {
|
||
text = ui("暂无粘贴板记录", "No clipboard history yet")
|
||
textSize = 14f
|
||
if (englishUi) typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
|
||
gravity = android.view.Gravity.CENTER
|
||
setTextColor(tone(Color.rgb(140, 140, 140), Color.rgb(140, 140, 145)))
|
||
setPadding(0, dp(20), 0, 0)
|
||
})
|
||
} else {
|
||
entries.forEachIndexed { index, entry ->
|
||
// Two fixed-width zones behind the row, revealed left-to-right
|
||
// as it's dragged right: favorite first, delete beyond it. A
|
||
// third zone behind the row's end, revealed right-to-left as
|
||
// it's dragged left, toggles a correction rule for this text.
|
||
val entryWrapper = FrameLayout(this)
|
||
val zoneWidth = dp(88)
|
||
val revealRow = LinearLayout(this)
|
||
val favoriteZone = TextView(this).apply {
|
||
textSize = 13f
|
||
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
||
gravity = android.view.Gravity.CENTER
|
||
setTextColor(Color.WHITE)
|
||
setBackgroundColor(Color.rgb(181, 136, 32))
|
||
setPadding(dp(6), 0, dp(6), 0)
|
||
}
|
||
val deleteZone = TextView(this).apply {
|
||
text = ui("删除", "Delete")
|
||
textSize = 13f
|
||
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
||
gravity = android.view.Gravity.CENTER
|
||
setTextColor(Color.WHITE)
|
||
setBackgroundColor(Color.rgb(153, 26, 40))
|
||
setPadding(dp(6), 0, dp(6), 0)
|
||
}
|
||
revealRow.addView(favoriteZone, LinearLayout.LayoutParams(zoneWidth, ViewGroup.LayoutParams.MATCH_PARENT))
|
||
revealRow.addView(deleteZone, LinearLayout.LayoutParams(zoneWidth, ViewGroup.LayoutParams.MATCH_PARENT))
|
||
entryWrapper.addView(revealRow, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||
val correctionZone = TextView(this).apply {
|
||
textSize = 13f
|
||
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
||
gravity = android.view.Gravity.CENTER
|
||
setTextColor(Color.WHITE)
|
||
setBackgroundColor(Color.rgb(46, 108, 168))
|
||
setPadding(dp(6), 0, dp(6), 0)
|
||
}
|
||
entryWrapper.addView(
|
||
correctionZone,
|
||
FrameLayout.LayoutParams(zoneWidth, ViewGroup.LayoutParams.MATCH_PARENT, android.view.Gravity.END),
|
||
)
|
||
|
||
val row = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL }
|
||
val star = TextView(this).apply {
|
||
text = if (entry.favorite) "★" else "☆"
|
||
textSize = 14f
|
||
setTextColor(
|
||
if (entry.favorite) Color.rgb(153, 26, 40) else tone(Color.rgb(120, 120, 120), Color.rgb(180, 180, 184)),
|
||
)
|
||
}
|
||
row.addView(star, LinearLayout.LayoutParams(dp(22), ViewGroup.LayoutParams.WRAP_CONTENT).apply { marginEnd = dp(4) })
|
||
row.addView(
|
||
TextView(this).apply {
|
||
text = entry.text
|
||
textSize = 14f
|
||
setTextColor(tone(Color.rgb(230, 230, 230), Color.rgb(30, 30, 34)))
|
||
maxLines = 2
|
||
ellipsize = android.text.TextUtils.TruncateAt.END
|
||
},
|
||
LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f),
|
||
)
|
||
// After the weighted text view, not before it, so this lands
|
||
// at the row's far right edge instead of crowding the star.
|
||
if (vocabularyPhrases.contains(entry.text)) {
|
||
row.addView(
|
||
TextView(this).apply {
|
||
text = "✎"
|
||
textSize = 13f
|
||
setTextColor(Color.rgb(90, 156, 224))
|
||
},
|
||
LinearLayout.LayoutParams(dp(16), ViewGroup.LayoutParams.WRAP_CONTENT).apply { marginStart = dp(4) },
|
||
)
|
||
}
|
||
// Plain flat row — no keycap-style background/press effect —
|
||
// but still opaque, so it fully hides the reveal zones until dragged.
|
||
row.setPadding(dp(12), dp(10), dp(12), dp(10))
|
||
row.setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246)))
|
||
entryWrapper.addView(row, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||
|
||
var suppressRowClick = false
|
||
row.setOnClickListener {
|
||
if (suppressRowClick) {
|
||
suppressRowClick = false
|
||
return@setOnClickListener
|
||
}
|
||
currentInputConnection?.commitText(entry.text, 1)
|
||
OpenLessClipboardHistory.recordCopy(this@OpenLessImeService, entry.text)
|
||
clipboardHistoryMode = false
|
||
refreshInputView()
|
||
}
|
||
attachClipboardRowSwipe(
|
||
row = row,
|
||
zoneWidth = zoneWidth,
|
||
favoriteZone = favoriteZone,
|
||
correctionZone = correctionZone,
|
||
isFavorite = { entry.favorite },
|
||
hasCorrectionRule = { vocabularyPhrases.contains(entry.text) },
|
||
addLabel = ui("加入收藏", "Add"),
|
||
removeLabel = ui("取消收藏", "Remove"),
|
||
addCorrectionLabel = ui("加入词典", "Add to dictionary"),
|
||
removeCorrectionLabel = ui("移出词典", "Remove from dictionary"),
|
||
onSuppressClick = { suppressRowClick = true },
|
||
onToggleFavorite = {
|
||
OpenLessClipboardHistory.toggleFavorite(this, entry.text)
|
||
refreshInputView()
|
||
},
|
||
onDelete = {
|
||
OpenLessClipboardHistory.delete(this, entry.text)
|
||
refreshInputView()
|
||
},
|
||
onAddCorrection = {
|
||
runNativeAction("加入词典") { OpenLessNative.nativeAddVocabularyWord(entry.text) }
|
||
refreshInputView()
|
||
},
|
||
onRemoveCorrection = {
|
||
OpenLessNative.nativeRemoveVocabularyWord(entry.text)
|
||
refreshInputView()
|
||
},
|
||
)
|
||
listContainer.addView(entryWrapper, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||
if (index < entries.lastIndex) {
|
||
listContainer.addView(buildDivider(), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)))
|
||
}
|
||
}
|
||
}
|
||
val scroll = android.widget.ScrollView(this).apply {
|
||
isVerticalScrollBarEnabled = false
|
||
overScrollMode = View.OVER_SCROLL_NEVER
|
||
addView(listContainer, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||
}
|
||
historyScroll = scroll
|
||
root.addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
|
||
return root
|
||
}
|
||
|
||
/**
|
||
* Left/right swipe on one clipboard history row: the row itself tracks
|
||
* the finger 1:1 via translationX, revealing fixed-width zones behind it
|
||
* — favorite then delete to the right as it's dragged right, a single
|
||
* correction-rule zone to the left as it's dragged left — each clamped
|
||
* so the drag can't pull the row past its own side's zone width(s).
|
||
* Releasing inside a zone commits that zone's action; releasing short
|
||
* of it just springs the row back with no effect, on either side.
|
||
*
|
||
* Uses rawX/rawY, not the view-local x/y: once translationX starts
|
||
* moving the row mid-gesture, view-local coordinates from the same
|
||
* ongoing touch stream are reported relative to the view's shifting
|
||
* transform and drift, while raw screen coordinates stay stable for the
|
||
* whole gesture.
|
||
*
|
||
* onSuppressClick runs synchronously inside this same ACTION_UP handling
|
||
* — not deferred — because the row's own click (paste + close panel)
|
||
* gets evaluated by the View's default onTouchEvent immediately after
|
||
* this listener returns, within the very same event. Deferring the
|
||
* suppress flag (as a first cut of this did, bundled inside the action
|
||
* callback) let that stale click fire first: it pasted the row's text,
|
||
* reordered it to the front via recordCopy(), and closed back to the
|
||
* main clipboard view — exactly the "jumps back / reorders" symptom.
|
||
* onToggleFavorite/onDelete/onAddCorrection/onRemoveCorrection can still
|
||
* be posted, since only *those* (which rebuild the whole panel) need to
|
||
* avoid the panel flickering mid-gesture.
|
||
*
|
||
* The panel's own SwipeModeContainer has `horizontalSwipeEnabled = false`
|
||
* for this sub-panel specifically, so there is no competing ancestor
|
||
* gesture to race against here — recognizing the drag on ACTION_MOVE and
|
||
* calling requestDisallowInterceptTouchEvent is enough to keep the
|
||
* ScrollView from also treating it as a scroll attempt.
|
||
*/
|
||
private fun attachClipboardRowSwipe(
|
||
row: View,
|
||
zoneWidth: Int,
|
||
favoriteZone: TextView,
|
||
correctionZone: TextView,
|
||
isFavorite: () -> Boolean,
|
||
hasCorrectionRule: () -> Boolean,
|
||
addLabel: String,
|
||
removeLabel: String,
|
||
addCorrectionLabel: String,
|
||
removeCorrectionLabel: String,
|
||
onSuppressClick: () -> Unit,
|
||
onToggleFavorite: () -> Unit,
|
||
onDelete: () -> Unit,
|
||
onAddCorrection: () -> Unit,
|
||
onRemoveCorrection: () -> Unit,
|
||
) {
|
||
val touchSlop = android.view.ViewConfiguration.get(this).scaledTouchSlop
|
||
val maxDragRight = zoneWidth * 2f
|
||
val maxDragLeft = zoneWidth.toFloat()
|
||
var startRawX = 0f
|
||
var startRawY = 0f
|
||
var horizontalDrag = false
|
||
row.setOnTouchListener { view, event ->
|
||
when (event.actionMasked) {
|
||
MotionEvent.ACTION_DOWN -> {
|
||
startRawX = event.rawX
|
||
startRawY = event.rawY
|
||
horizontalDrag = false
|
||
favoriteZone.text = if (isFavorite()) removeLabel else addLabel
|
||
correctionZone.text = if (hasCorrectionRule()) removeCorrectionLabel else addCorrectionLabel
|
||
}
|
||
MotionEvent.ACTION_MOVE -> {
|
||
val dx = event.rawX - startRawX
|
||
val dy = event.rawY - startRawY
|
||
if (!horizontalDrag && kotlin.math.abs(dx) > touchSlop && kotlin.math.abs(dx) > kotlin.math.abs(dy) * 1.5f) {
|
||
horizontalDrag = true
|
||
view.parent?.requestDisallowInterceptTouchEvent(true)
|
||
}
|
||
if (horizontalDrag) view.translationX = dx.coerceIn(-maxDragLeft, maxDragRight)
|
||
}
|
||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||
if (horizontalDrag) {
|
||
val dx = event.rawX - startRawX
|
||
if (dx > 0f) {
|
||
val clamped = dx.coerceIn(0f, maxDragRight)
|
||
when {
|
||
clamped >= maxDragRight -> {
|
||
onSuppressClick()
|
||
view.post { onDelete() }
|
||
}
|
||
clamped >= zoneWidth -> {
|
||
onSuppressClick()
|
||
view.post { onToggleFavorite() }
|
||
}
|
||
}
|
||
} else if (dx < 0f) {
|
||
val clamped = dx.coerceIn(-maxDragLeft, 0f)
|
||
// Only past the full zone width — the label is
|
||
// fully revealed by then — does releasing commit
|
||
// anything; short of that just springs back.
|
||
if (clamped <= -zoneWidth) {
|
||
onSuppressClick()
|
||
if (hasCorrectionRule()) {
|
||
view.post { onRemoveCorrection() }
|
||
} else {
|
||
view.post { onAddCorrection() }
|
||
}
|
||
}
|
||
}
|
||
view.animate().translationX(0f).setDuration(150L).start()
|
||
}
|
||
horizontalDrag = false
|
||
}
|
||
}
|
||
// Never consumed: a plain tap (no horizontal drag recognized)
|
||
// still needs to fall through to the row's own click listener.
|
||
false
|
||
}
|
||
}
|
||
|
||
internal fun keyboardKey(
|
||
label: String,
|
||
weight: Float,
|
||
action: () -> Unit = {},
|
||
repeatOnLongPress: Boolean = false,
|
||
repeatAction: (() -> Unit)? = null,
|
||
swipeUpAction: (() -> Unit)? = null,
|
||
swipePreview: String? = null,
|
||
// A single-shot delayed action, separate from
|
||
// repeatOnLongPress/repeatAction — used by the voice/mic key, whose
|
||
// long-press (switch to Voice mode) needs a longer fuse than the
|
||
// system's fixed ~500ms long-click timeout so a swipe-up gesture
|
||
// that pauses briefly before moving isn't preempted by it (see
|
||
// ACTION_MOVE below, which cancels this the moment the swipe-up
|
||
// threshold is crossed). setOnLongClickListener's timeout isn't
|
||
// adjustable per-view, so this schedules/cancels its own Handler
|
||
// callback instead of relying on it.
|
||
longPressAction: (() -> Unit)? = null,
|
||
longPressDelayMs: Long = 500L,
|
||
microphoneIcon: Boolean = false,
|
||
strokeIconCode: String? = null,
|
||
graphicCode: String? = null,
|
||
graphicActionCode: String? = null,
|
||
// Rotates only the icon drawn inside StrokeActionView's canvas, not
|
||
// the whole button (which would also rotate — and for a non-square
|
||
// cell, distort — its background).
|
||
graphicRotation: Float = 0f,
|
||
// Only meaningful with graphicActionCode: StrokeActionView's icon
|
||
// color. Left null for the always-dark-red action keys (white in
|
||
// both themes, StrokeActionView's own default); the clipboard
|
||
// panel's direction/undo icons — which sit on a normal or rose key,
|
||
// not red — pass the theme-appropriate color explicitly.
|
||
graphicIconColor: Int? = null,
|
||
// Draws a thin horizontal divider between the two lines of a
|
||
// two-line label (e.g. the clipboard panel's "History"/"Dict"
|
||
// key, which does two unrelated things depending on tap vs.
|
||
// long-press) — a plain '\n' alone read as one cramped label.
|
||
midDivider: Boolean = false,
|
||
): TextView {
|
||
val keyView = when {
|
||
microphoneIcon -> MicrophoneKeyView(this, isDarkTheme)
|
||
strokeIconCode != null -> StrokeKeyView(this, strokeIconCode, isDarkTheme)
|
||
graphicCode != null -> StrokeGlyphView(this, graphicCode, isDarkTheme)
|
||
graphicActionCode != null -> if (graphicIconColor != null) {
|
||
StrokeActionView(this, graphicActionCode, graphicRotation, graphicIconColor)
|
||
} else {
|
||
StrokeActionView(this, graphicActionCode, graphicRotation)
|
||
}
|
||
label == "←" || label == "↵" -> ActionSymbolView(this, label, isDarkTheme)
|
||
label == "⇧" -> ShiftKeyView(this, shiftState, isDarkTheme)
|
||
midDivider -> MidDividerTextView(this, tone(Color.rgb(100, 100, 100), Color.rgb(205, 205, 209)))
|
||
else -> TextView(this)
|
||
}
|
||
return keyView.apply {
|
||
text = if (strokeIconCode != null || graphicCode != null || graphicActionCode != null || label == "⇧") {
|
||
""
|
||
} else if ('\n' in label) {
|
||
android.text.SpannableString(label).apply {
|
||
setSpan(android.text.style.RelativeSizeSpan(0.55f), 0, 1, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||
}
|
||
} else label
|
||
textSize = if (microphoneIcon) 10f else if (strokeIconCode != null) 1f else if (label == "Return") 17f else 22f
|
||
gravity = if (microphoneIcon) android.view.Gravity.TOP or android.view.Gravity.CENTER_HORIZONTAL else android.view.Gravity.CENTER
|
||
if (microphoneIcon) setPadding(0, dp(2), 0, 0)
|
||
setTextColor(tone(Color.rgb(245, 245, 245), Color.rgb(30, 30, 34)))
|
||
background = roundedButton(tone(Color.rgb(52, 52, 54), Color.rgb(255, 255, 255)), dp(5))
|
||
// Keep the existing palette and geometry, but give each key a subtle raised surface.
|
||
elevation = dp(5).toFloat()
|
||
translationZ = dp(1).toFloat()
|
||
contentDescription = label.ifBlank { ui("空格", "Space") }
|
||
// Retargetable like buildEnglishCharKey()'s tap preview: any
|
||
// sibling key built with its own swipeUpAction/swipePreview
|
||
// carries that pair here as its tag, so a swipe-up gesture that
|
||
// drags sideways onto a different key (e.g. across the stroke
|
||
// grid's digit shortcuts) can look up and commit THAT key's own
|
||
// digit instead of the one originally pressed.
|
||
tag = if (swipeUpAction != null && swipePreview != null) Pair(swipeUpAction, swipePreview) else null
|
||
var suppressNextClick = false
|
||
var downX = 0f
|
||
var downY = 0f
|
||
var swipePreviewShown = false
|
||
// A one-way latch, same as swipePreviewShown: once the finger
|
||
// has dragged sideways far enough from where it started, the
|
||
// gesture is treated as a deliberate retarget-drag for the
|
||
// rest of this touch sequence, not re-checked drag-by-drag.
|
||
// Before that latch trips, trackedView never changes even if
|
||
// the finger's raw position happens to land inside another
|
||
// key's real hit bounds — a plain swipe straight up (whose own
|
||
// vertical travel easily reaches a neighboring row) must not
|
||
// silently relabel itself just because it crossed into that
|
||
// row's territory with no real sideways intent.
|
||
var horizontalDragArmed = false
|
||
var trackedView: View = this
|
||
@Suppress("UNCHECKED_CAST")
|
||
fun swipeSpecOf(view: View): Pair<() -> Unit, String>? = view.tag as? Pair<() -> Unit, String>
|
||
setOnClickListener {
|
||
if (suppressNextClick) {
|
||
suppressNextClick = false
|
||
} else {
|
||
action()
|
||
}
|
||
}
|
||
val repeatHandler = if (repeatOnLongPress && repeatAction != null) {
|
||
Handler(Looper.getMainLooper())
|
||
} else null
|
||
val repeatRunnable = if (repeatHandler != null && repeatAction != null) {
|
||
object : Runnable {
|
||
override fun run() {
|
||
repeatAction.invoke()
|
||
repeatHandler.postDelayed(this, keyRepeatIntervalMs())
|
||
}
|
||
}
|
||
} else null
|
||
if (repeatHandler != null && repeatRunnable != null && repeatAction != null) {
|
||
setOnLongClickListener {
|
||
repeatAction.invoke()
|
||
repeatHandler.postDelayed(repeatRunnable, keyRepeatIntervalMs())
|
||
true
|
||
}
|
||
}
|
||
val longPressHandler = if (longPressAction != null) Handler(Looper.getMainLooper()) else null
|
||
var longPressRunnable: Runnable? = null
|
||
|
||
// Hit-tests every swipe-capable sibling in this key's own row
|
||
// group, climbing to the row's own parent so a drag can
|
||
// retarget across rows too — same approach as
|
||
// buildEnglishCharKey()'s findTrackTargetAt, just keyed off a
|
||
// Pair(swipeUpAction, swipePreview) tag instead of a char tag.
|
||
//
|
||
// Vertical slop is deliberately tiny here (unlike the English
|
||
// version's generous dp(24)): the grid's rows sit almost flush
|
||
// against each other (~1dp margin, each row only ~1/4 of the
|
||
// grid's own height), so a large slop makes adjacent rows'
|
||
// hit zones overlap deeply — a plain straight swipe UP (whose
|
||
// whole point is to travel well past the pressed key, still
|
||
// within the same key's column) would then immediately
|
||
// "retarget" to the row above just from that expected vertical
|
||
// travel, with no real leftward/rightward drag at all. A
|
||
// near-zero vertical slop means only a finger that has
|
||
// genuinely reached a different row's real bounds retargets;
|
||
// once the finger is past every row (still swiping straight
|
||
// up), no row matches and findSwipeTargetAt falls back to
|
||
// returning trackedView — i.e. the original key stays selected
|
||
// for a simple swipe, exactly matching a single-key swipe-up's
|
||
// intent.
|
||
fun findSwipeTargetAt(rawX: Float, rawY: Float): View {
|
||
val row = parent as? ViewGroup ?: return this
|
||
val group = row.parent as? ViewGroup
|
||
val candidateRows = if (group != null) {
|
||
(0 until group.childCount).mapNotNull { index ->
|
||
(group.getChildAt(index) as? ViewGroup)?.takeIf { candidate ->
|
||
(0 until candidate.childCount).any { candidate.getChildAt(it).tag is Pair<*, *> }
|
||
}
|
||
}
|
||
} else {
|
||
listOf(row)
|
||
}
|
||
for (candidateRow in candidateRows) {
|
||
for (index in 0 until candidateRow.childCount) {
|
||
val sibling = candidateRow.getChildAt(index)
|
||
if (sibling.tag !is Pair<*, *>) continue
|
||
val loc = IntArray(2)
|
||
sibling.getLocationOnScreen(loc)
|
||
if (rawX >= loc[0] - dp(6) && rawX < loc[0] + sibling.width + dp(6) &&
|
||
rawY >= loc[1] - dp(2) && rawY < loc[1] + sibling.height + dp(2)
|
||
) {
|
||
return sibling
|
||
}
|
||
}
|
||
}
|
||
return trackedView
|
||
}
|
||
|
||
setOnTouchListener { view, event ->
|
||
when (event.actionMasked) {
|
||
MotionEvent.ACTION_DOWN -> {
|
||
downX = event.x
|
||
downY = event.y
|
||
horizontalDragArmed = false
|
||
trackedView = view
|
||
view.animate()
|
||
.scaleX(0.97f)
|
||
.scaleY(0.97f)
|
||
.translationZ(dp(3).toFloat())
|
||
.alpha(0.90f)
|
||
.setDuration(65L)
|
||
.start()
|
||
performKeyHaptic()
|
||
if (longPressHandler != null && longPressAction != null) {
|
||
val runnable = Runnable {
|
||
longPressAction.invoke()
|
||
suppressNextClick = true
|
||
}
|
||
longPressRunnable = runnable
|
||
longPressHandler.postDelayed(runnable, longPressDelayMs)
|
||
}
|
||
}
|
||
MotionEvent.ACTION_MOVE -> {
|
||
if (swipeUpAction != null && swipePreview != null) {
|
||
if (!swipePreviewShown && downY - event.y >= dp(10)) {
|
||
// Armed only once the initial swipe-up
|
||
// actually clears the threshold, not on
|
||
// every key's plain DOWN (that would also
|
||
// swallow the panel-switch swipe that
|
||
// legitimately starts on top of a
|
||
// non-swiping key) — so dragging across the
|
||
// grid to retarget isn't stolen by
|
||
// SwipeModeContainer's own swipe intercept
|
||
// mid-gesture.
|
||
view.parent?.requestDisallowInterceptTouchEvent(true)
|
||
// Cancels the voice/mic key's own delayed
|
||
// longPressAction (switch to Voice mode) the
|
||
// moment a swipe-up is recognized — a no-op
|
||
// for every other key (longPressRunnable is
|
||
// only ever set when longPressAction != null).
|
||
longPressRunnable?.let { longPressHandler?.removeCallbacks(it) }
|
||
swipePreviewShown = true
|
||
}
|
||
if (swipePreviewShown) {
|
||
// Retargeting itself is gated behind a
|
||
// second, separate threshold: the finger
|
||
// must have moved at least dp(10)
|
||
// horizontally from where it first went
|
||
// down, not merely landed inside another
|
||
// key's hit bounds. A plain swipe straight
|
||
// up travels well past the pressed key's
|
||
// own row by design (that vertical distance
|
||
// is the whole gesture), which — with no
|
||
// sideways intent at all — would otherwise
|
||
// still land inside a neighboring row's real
|
||
// bounds and silently relabel the bubble
|
||
// (e.g. "0" swiped straight up briefly
|
||
// reading as "8", the key directly above
|
||
// it). Once this latches on, though, any
|
||
// further drag — down, sideways, or across
|
||
// rows — keeps retargeting and following
|
||
// the finger for the rest of the gesture;
|
||
// release always commits whichever key is
|
||
// currently tracked (see ACTION_UP).
|
||
if (!horizontalDragArmed && kotlin.math.abs(event.x - downX) >= dp(10)) {
|
||
horizontalDragArmed = true
|
||
}
|
||
if (horizontalDragArmed) {
|
||
val target = findSwipeTargetAt(event.rawX, event.rawY)
|
||
if (target !== trackedView) {
|
||
trackedView = target
|
||
// Same haptic as a normal key press,
|
||
// so retargeting onto a new key
|
||
// reads as landing on that key, not
|
||
// just a silent label swap.
|
||
performKeyHaptic()
|
||
}
|
||
}
|
||
val spec = swipeSpecOf(trackedView)
|
||
if (spec != null) {
|
||
// Pinned to the tracked key's own actual
|
||
// on-screen position, a fixed gap above
|
||
// it (see KeyPreviewOverlay.showSwipePreview()) —
|
||
// not to the finger's current height.
|
||
keyPreviewOverlay?.showSwipePreview(spec.second, trackedView)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||
if (event.actionMasked == MotionEvent.ACTION_UP && swipePreviewShown) {
|
||
swipeSpecOf(trackedView)?.first?.invoke()
|
||
suppressNextClick = true
|
||
}
|
||
longPressRunnable?.let { longPressHandler?.removeCallbacks(it) }
|
||
repeatHandler?.let { handler ->
|
||
repeatRunnable?.let { handler.removeCallbacks(it) }
|
||
}
|
||
if (swipePreviewShown) {
|
||
keyPreviewOverlay?.hide()
|
||
swipePreviewShown = false
|
||
}
|
||
view.animate()
|
||
.scaleX(1f)
|
||
.scaleY(1f)
|
||
.translationZ(0f)
|
||
.alpha(1f)
|
||
.setDuration(90L)
|
||
.start()
|
||
}
|
||
}
|
||
false
|
||
}
|
||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, weight).apply {
|
||
setMargins(dp(3), dp(3), dp(3), dp(3))
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun keyRepeatIntervalMs(): Long {
|
||
return getSharedPreferences("openless_ime_ui", MODE_PRIVATE)
|
||
.getLong("key_haptic_repeat_interval_ms", 60L)
|
||
.coerceIn(30L, 200L)
|
||
}
|
||
|
||
private fun performKeyHaptic() {
|
||
val preferences = getSharedPreferences("openless_ime_ui", MODE_PRIVATE)
|
||
if (!preferences.getBoolean("key_haptic_enabled", true)) return
|
||
val durationMs = preferences.getLong("key_haptic_duration_ms", 12L).coerceIn(1L, 500L)
|
||
val amplitude = preferences.getInt("key_haptic_amplitude", 55).coerceIn(1, 255)
|
||
runCatching {
|
||
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||
(getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator
|
||
} else {
|
||
@Suppress("DEPRECATION")
|
||
getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||
}
|
||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||
vibrator.vibrate(VibrationEffect.createOneShot(durationMs, amplitude))
|
||
} else {
|
||
@Suppress("DEPRECATION")
|
||
vibrator.vibrate(durationMs)
|
||
}
|
||
}.onFailure { error ->
|
||
android.util.Log.w("OpenLessImeService", "key haptic failed durationMs=$durationMs amplitude=$amplitude", error)
|
||
}
|
||
}
|
||
|
||
// Two short ticks instead of one — used for the mic button's swipe
|
||
// gestures (cancel-recording, arm raw mode) so they read as distinctly
|
||
// different from a plain key press's single tick. The gap between them
|
||
// reuses the same user-configurable repeat interval as key-repeat
|
||
// haptics, since that's already tuned to feel like separate pulses
|
||
// rather than one long buzz.
|
||
private fun performDoubleKeyHaptic() {
|
||
performKeyHaptic()
|
||
android.os.Handler(Looper.getMainLooper()).postDelayed({ performKeyHaptic() }, keyRepeatIntervalMs())
|
||
}
|
||
|
||
/**
|
||
* A single ABC/123/#+= character-emitting key with an iOS-style press
|
||
* preview: a bubble above the key showing the character that will
|
||
* actually be committed (respecting shift for letters), with an arrow
|
||
* pointing at the key (KeyPreviewBubbleView, plain Canvas drawing — no
|
||
* new dependency). Dragging onto a horizontally adjacent sibling in the
|
||
* same row re-targets the preview and the eventual commit to that key
|
||
* instead (a "sloppy key", same idea as most predictive keyboards).
|
||
*
|
||
* @param swipeSymbol Any key that has one — top-row digits (q..p ->
|
||
* 1..0), row2's @#$%&-+() and row3's :;'.,!? — swiping up commits
|
||
* this symbol instead of the letter, same interaction as the stroke
|
||
* panel's own swipe-up keys (see keyboardKey()'s swipeUpAction/
|
||
* swipePreview). Shown as a small standalone hint pinned to the very
|
||
* top of the key, NOT a second line of the letter's own text (that
|
||
* was the first version of this — a single TextView with "symbol\n
|
||
* letter" and block-centered gravity, which visibly shoved the
|
||
* letter down off-center by about half the hint line's height).
|
||
* Two independent views layered in a FrameLayout instead: the letter
|
||
* stays exactly where it always was, completely unaffected by
|
||
* whether a swipe-up hint exists at all.
|
||
*
|
||
* This is a dedicated builder, not a keyboardKey() variant, precisely
|
||
* so none of this touch handling can affect any other panel's keys.
|
||
*/
|
||
private fun buildEnglishCharKey(baseChar: String, weight: Float, swipeSymbol: String? = null): View {
|
||
fun charFor(view: View): String {
|
||
val base = view.tag as? String ?: return ""
|
||
return if (shiftState != ShiftState.OFF && base.length == 1 && base[0].isLetter()) base.uppercase() else base
|
||
}
|
||
val letterView = TextView(this).apply {
|
||
textSize = 22f
|
||
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
|
||
gravity = android.view.Gravity.CENTER
|
||
setTextColor(tone(Color.rgb(245, 245, 245), Color.rgb(30, 30, 34)))
|
||
isClickable = false
|
||
// A small nudge off dead-center, down toward the digit/symbol
|
||
// hint's baseline — a plain translationY (not padding) so it's
|
||
// a pure render-time shift that doesn't touch this view's
|
||
// measured layout at all.
|
||
translationY = dp(LETTER_VERTICAL_NUDGE_DP).toFloat()
|
||
}
|
||
return FrameLayout(this).apply {
|
||
tag = baseChar
|
||
if (swipeSymbol != null) englishSwipeSymbols[this] = swipeSymbol
|
||
letterView.text = charFor(this)
|
||
addView(letterView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||
if (swipeSymbol != null) {
|
||
addView(
|
||
TextView(this@OpenLessImeService).apply {
|
||
text = swipeSymbol
|
||
textSize = SWIPE_SYMBOL_HINT_TEXT_SIZE_SP
|
||
gravity = android.view.Gravity.TOP or android.view.Gravity.CENTER_HORIZONTAL
|
||
// Muted/secondary, not the same bright/near-black
|
||
// tone as the letter — a small top-corner hint
|
||
// should read as secondary at a glance, not
|
||
// compete with the actual letter for attention.
|
||
// (Tried matching the swipe-preview bubble's red
|
||
// here — read as less legible at this size than
|
||
// the plain gray, so reverted.)
|
||
setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(140, 140, 145)))
|
||
setPadding(0, dp(SWIPE_SYMBOL_HINT_TOP_PADDING_DP), 0, 0)
|
||
isClickable = false
|
||
},
|
||
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, android.view.Gravity.TOP),
|
||
)
|
||
}
|
||
background = roundedButton(tone(Color.rgb(52, 52, 54), Color.rgb(255, 255, 255)), dp(5))
|
||
elevation = dp(5).toFloat()
|
||
translationZ = dp(1).toFloat()
|
||
contentDescription = charFor(this)
|
||
var trackedView: View = this
|
||
var downY = 0f
|
||
var swipePreviewShown = false
|
||
|
||
// Shown via the shared top-level KeyPreviewOverlay (see
|
||
// wrapWithKeyPreviewOverlay()) instead of a per-key PopupWindow —
|
||
// a single overlay reused by every key means dragging onto a
|
||
// different key just repositions the same bubble instead of
|
||
// tearing one popup down and standing up another.
|
||
fun showBubbleFor(target: View) {
|
||
keyPreviewOverlay?.showTapPreview(charFor(target), target)
|
||
}
|
||
|
||
// Hit-tests every char-key row in this key's own row group (not
|
||
// just its immediate row siblings) by their actual on-screen
|
||
// bounds — a little slop added on both axes, since a real
|
||
// finger drifts while sliding. Climbing to the row's own parent
|
||
// and collecting every child row that has at least one
|
||
// String-tagged (char) key lets a vertical drag retarget across
|
||
// rows (e.g. row2's "a" up to row1's "q") the same way a
|
||
// horizontal drag already retargets within one row — the
|
||
// candidate bar, header, and bottom row (space/return/mode
|
||
// toggle) never have any String-tagged children, so they're
|
||
// simply never included. Functional keys within an included row
|
||
// (shift/backspace, tag == null) are still never matched.
|
||
fun findTrackTargetAt(rawX: Float, rawY: Float): View {
|
||
val row = parent as? ViewGroup ?: return this
|
||
val group = row.parent as? ViewGroup
|
||
val candidateRows = if (group != null) {
|
||
(0 until group.childCount).mapNotNull { index ->
|
||
(group.getChildAt(index) as? ViewGroup)?.takeIf { candidate ->
|
||
(0 until candidate.childCount).any { candidate.getChildAt(it).tag is String }
|
||
}
|
||
}
|
||
} else {
|
||
listOf(row)
|
||
}
|
||
for (candidateRow in candidateRows) {
|
||
for (index in 0 until candidateRow.childCount) {
|
||
val sibling = candidateRow.getChildAt(index)
|
||
if (sibling.tag !is String) continue
|
||
val loc = IntArray(2)
|
||
sibling.getLocationOnScreen(loc)
|
||
if (rawX >= loc[0] - dp(6) && rawX < loc[0] + sibling.width + dp(6) &&
|
||
rawY >= loc[1] - dp(24) && rawY < loc[1] + sibling.height + dp(24)
|
||
) {
|
||
return sibling
|
||
}
|
||
}
|
||
}
|
||
return trackedView
|
||
}
|
||
|
||
// Same idea as findTrackTargetAt above, but only among this
|
||
// row's own swipe-symbol-bearing keys (englishSwipeSymbols) —
|
||
// once a swipe is armed, sliding sideways still retargets which
|
||
// symbol release will commit, and showSwipePreview() re-anchors
|
||
// to that key so the bubble visibly follows the finger instead
|
||
// of freezing above the key the gesture started on. A generous
|
||
// vertical band since an armed drag has already moved well
|
||
// above the row.
|
||
fun findSwipeTargetAt(rawX: Float, rawY: Float): View {
|
||
val row = parent as? ViewGroup ?: return trackedView
|
||
for (index in 0 until row.childCount) {
|
||
val sibling = row.getChildAt(index)
|
||
if (sibling !in englishSwipeSymbols) continue
|
||
val loc = IntArray(2)
|
||
sibling.getLocationOnScreen(loc)
|
||
if (rawX >= loc[0] - dp(6) && rawX < loc[0] + sibling.width + dp(6) &&
|
||
rawY >= loc[1] - dp(60) && rawY < loc[1] + sibling.height + dp(24)
|
||
) {
|
||
return sibling
|
||
}
|
||
}
|
||
return trackedView
|
||
}
|
||
|
||
setOnTouchListener { view, event ->
|
||
when (event.actionMasked) {
|
||
MotionEvent.ACTION_DOWN -> {
|
||
// Without this, a same-row drag to an adjacent key
|
||
// (a clearly horizontal drag, same shape as a
|
||
// panel-switch swipe) gets stolen mid-gesture by the
|
||
// enclosing SwipeModeContainer's own horizontal-swipe
|
||
// intercept once it crosses touchSlop — the key would
|
||
// stop receiving MOVE/UP entirely, so the bubble
|
||
// never follows the finger and nothing ever commits
|
||
// on release. Claimed unconditionally on DOWN, not
|
||
// only once a drag is detected: any movement that
|
||
// starts on a key belongs to that key's own
|
||
// press/retarget/commit handling, never to a
|
||
// panel-switch gesture.
|
||
view.parent?.requestDisallowInterceptTouchEvent(true)
|
||
trackedView = view
|
||
downY = event.y
|
||
swipePreviewShown = false
|
||
view.animate().scaleX(0.97f).scaleY(0.97f).translationZ(dp(3).toFloat()).alpha(0.90f).setDuration(65L).start()
|
||
performKeyHaptic()
|
||
showBubbleFor(view)
|
||
}
|
||
MotionEvent.ACTION_MOVE -> {
|
||
// Swipe-up-for-symbol (swipeSymbol != null; the
|
||
// digit row, and now row2/row3's punctuation) —
|
||
// same dp(10) arm threshold as keyboardKey()'s own
|
||
// swipe handling. Once armed, sliding sideways
|
||
// still retargets across the row's other
|
||
// symbol-bearing keys via findSwipeTargetAt (the
|
||
// bubble follows the finger to whichever symbol is
|
||
// now under it), separate from findTrackTargetAt's
|
||
// own retargeting used for the un-armed tap case.
|
||
//
|
||
// Live, not a one-way latch: armed is re-evaluated
|
||
// against the finger's *current* height every move,
|
||
// not just the first time it crosses the threshold
|
||
// — dragging back down below it un-arms and falls
|
||
// through to the normal tap-preview/retarget path
|
||
// below, giving a way to back out of the symbol
|
||
// mid-gesture without lifting the finger.
|
||
if (swipeSymbol != null) {
|
||
val armed = downY - event.y >= dp(10)
|
||
if (armed != swipePreviewShown) {
|
||
view.parent?.requestDisallowInterceptTouchEvent(true)
|
||
swipePreviewShown = armed
|
||
}
|
||
if (swipePreviewShown) {
|
||
val target = findSwipeTargetAt(event.rawX, event.rawY)
|
||
if (target !== trackedView) trackedView = target
|
||
val symbol = englishSwipeSymbols[trackedView] ?: swipeSymbol
|
||
keyPreviewOverlay?.showSwipePreview(symbol, trackedView)
|
||
return@setOnTouchListener true
|
||
}
|
||
}
|
||
val target = findTrackTargetAt(event.rawX, event.rawY)
|
||
if (target !== trackedView) trackedView = target
|
||
showBubbleFor(trackedView)
|
||
}
|
||
MotionEvent.ACTION_UP -> {
|
||
keyPreviewOverlay?.hide()
|
||
view.animate().scaleX(1f).scaleY(1f).translationZ(0f).alpha(1f).setDuration(90L).start()
|
||
if (swipeSymbol != null && swipePreviewShown) {
|
||
commitEnglishChar(englishSwipeSymbols[trackedView] ?: swipeSymbol)
|
||
} else {
|
||
commitEnglishChar(charFor(trackedView))
|
||
}
|
||
}
|
||
MotionEvent.ACTION_CANCEL -> {
|
||
keyPreviewOverlay?.hide()
|
||
view.animate().scaleX(1f).scaleY(1f).translationZ(0f).alpha(1f).setDuration(90L).start()
|
||
}
|
||
}
|
||
true
|
||
}
|
||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, weight).apply {
|
||
setMargins(dp(3), dp(3), dp(3), dp(3))
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun commitEnglishChar(char: String) {
|
||
if (char.isEmpty()) return
|
||
if (latinInputMode == LatinInputMode.PINYIN && !ImeLearningPolicy.isPassword(currentInputEditorInfo?.inputType ?: 0)) {
|
||
if (char.length == 1 && char[0].isLetter()) {
|
||
// Never calls commitText() — a letter only feeds the
|
||
// encoding buffer in Pinyin mode; see
|
||
// LitePinyinController's own doc comment.
|
||
litePinyinController.appendLetter(char[0]) { renderPinyinCandidates(it) }
|
||
if (shiftState == ShiftState.SHIFT_ONCE) {
|
||
shiftState = ShiftState.OFF
|
||
refreshInputView()
|
||
}
|
||
return
|
||
}
|
||
// A digit/punctuation typed mid-encoding (e.g. from the 123/
|
||
// symbols layer) abandons the encoding the same way space does
|
||
// (plan 3.2 point 9) rather than leaving it stranded, then
|
||
// falls through to commit the symbol itself normally below.
|
||
litePinyinController.clear()
|
||
renderPinyinCandidates(emptyList())
|
||
}
|
||
if (currentInputConnection?.commitText(char, 1) != true) return
|
||
if (personalizedLearningAllowed() && char.length == 1 && char[0].isLetter()) {
|
||
englishComposingWord.append(char.lowercase())
|
||
updateEnglishCandidates()
|
||
} else {
|
||
// Digits/punctuation end whatever word was being tracked, same
|
||
// as space/return already do — "it's" typed via the symbols
|
||
// page still finalizes cleanly, just with no candidate query
|
||
// benefit for that particular boundary.
|
||
finalizeEnglishComposingWord()
|
||
}
|
||
if (shiftState == ShiftState.SHIFT_ONCE) {
|
||
shiftState = ShiftState.OFF
|
||
refreshInputView()
|
||
}
|
||
}
|
||
|
||
/** Ends the current word: records it for user-frequency learning (if long enough to be a real word) and clears the tracking buffer. */
|
||
private fun finalizeEnglishComposingWord() {
|
||
if (englishComposingWord.isNotEmpty()) {
|
||
val word = englishComposingWord.toString()
|
||
if (personalizedLearningAllowed() && englishSuggestionsEnabled() && word.length >= 2) {
|
||
englishCandidateProvider.recordCommit(word)
|
||
}
|
||
englishComposingWord.clear()
|
||
}
|
||
updateEnglishCandidates()
|
||
}
|
||
|
||
private fun englishSuggestionsEnabled(): Boolean =
|
||
getSharedPreferences("openless_ime_ui", MODE_PRIVATE).getBoolean("english_suggestions_enabled", true)
|
||
|
||
/**
|
||
* Same presentation as the stroke panel's own candidate row: a
|
||
* HorizontalScrollView (so as many words as actually exist can be
|
||
* scrolled through, not a fixed 3-slot row) with the same
|
||
* touch-intercept override and the same overflow-triggered "▼ show
|
||
* more" button opening showCandidateOverlay() — the exact mechanism
|
||
* and candidateItemView() styling the stroke panel itself uses, reused
|
||
* as-is rather than a parallel implementation.
|
||
*/
|
||
private fun buildEnglishCandidateBar(): View {
|
||
// Outer vertical wrapper — divider, the actual bar, divider — so
|
||
// hiding it (see updateEnglishCandidates()) when suggestions are
|
||
// off takes both border lines with it instead of leaving two empty
|
||
// lines with nothing between them.
|
||
val wrapper = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||
englishCandidateBarContainer = wrapper
|
||
wrapper.addView(
|
||
buildDivider(),
|
||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)).apply {
|
||
marginStart = dp(1)
|
||
marginEnd = dp(1)
|
||
},
|
||
)
|
||
val container = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL }
|
||
wrapper.addView(container, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(36)))
|
||
wrapper.addView(
|
||
buildDivider(),
|
||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)).apply {
|
||
marginStart = dp(1)
|
||
marginEnd = dp(1)
|
||
bottomMargin = dp(2)
|
||
},
|
||
)
|
||
val scroll = object : android.widget.HorizontalScrollView(this) {
|
||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
|
||
parent?.requestDisallowInterceptTouchEvent(true)
|
||
}
|
||
return super.onInterceptTouchEvent(ev)
|
||
}
|
||
}.apply {
|
||
isHorizontalScrollBarEnabled = false
|
||
isFillViewport = false
|
||
overScrollMode = View.OVER_SCROLL_NEVER
|
||
}
|
||
val row = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER_VERTICAL }
|
||
englishCandidateRow = row
|
||
scroll.addView(row, ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||
container.addView(scroll, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f))
|
||
val expandButton = StrokeActionView(
|
||
this,
|
||
"triangle-down",
|
||
iconColor = tone(Color.rgb(180, 180, 180), Color.rgb(130, 130, 135)),
|
||
).apply {
|
||
contentDescription = ui("展开更多候选", "Show more candidates")
|
||
visibility = View.GONE
|
||
setOnClickListener { showCandidateOverlay(this) }
|
||
}
|
||
englishExpandCandidatesButton = expandButton
|
||
container.addView(expandButton, LinearLayout.LayoutParams(dp(28), ViewGroup.LayoutParams.MATCH_PARENT))
|
||
// Same idea as the stroke candidate row's own listener: only shown
|
||
// once the candidates actually overflow the visible scroll width.
|
||
scroll.viewTreeObserver.addOnGlobalLayoutListener {
|
||
expandButton.visibility = if (row.width > scroll.width) View.VISIBLE else View.GONE
|
||
}
|
||
return wrapper
|
||
}
|
||
|
||
/**
|
||
* Re-queries EnglishCandidateProvider for the current englishComposingWord
|
||
* prefix (or clears the bar if suggestions are off / nothing is being
|
||
* typed) — called after every letter, backspace, and word boundary.
|
||
* englishCandidateQueryEpoch discards a stale async result that comes
|
||
* back after a faster subsequent keystroke already moved the prefix on.
|
||
*/
|
||
private fun updateEnglishCandidates() {
|
||
val epoch = ++englishCandidateQueryEpoch
|
||
if (englishCandidateRow == null) return
|
||
if (!personalizedLearningAllowed() || !englishSuggestionsEnabled()) {
|
||
renderEnglishCandidates(emptyList())
|
||
englishCandidateBarContainer?.visibility = View.GONE
|
||
return
|
||
}
|
||
englishCandidateBarContainer?.visibility = View.VISIBLE
|
||
val prefix = englishComposingWord.toString()
|
||
if (prefix.isEmpty()) {
|
||
renderEnglishCandidates(emptyList())
|
||
return
|
||
}
|
||
englishCandidateProvider.queryTopN(prefix, ENGLISH_CANDIDATE_QUERY_LIMIT) { results ->
|
||
if (epoch == englishCandidateQueryEpoch) renderEnglishCandidates(results)
|
||
}
|
||
}
|
||
|
||
private fun renderEnglishCandidates(words: List<String>) {
|
||
val row = englishCandidateRow ?: return
|
||
row.removeAllViews()
|
||
val overlayEntries = mutableListOf<Pair<String, () -> Unit>>()
|
||
words.forEachIndexed { index, word ->
|
||
row.addView(
|
||
candidateItemView(
|
||
word,
|
||
isFirst = index == 0,
|
||
action = { selectEnglishCandidate(word) },
|
||
onLongPress = { forgetEnglishCandidate(word) },
|
||
),
|
||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT),
|
||
)
|
||
overlayEntries.add(word to { selectEnglishCandidate(word) })
|
||
}
|
||
// Shared with the stroke panel's own candidate row (only one of the
|
||
// two is ever visible at a time) — see showCandidateOverlay().
|
||
candidateOverlayEntries = overlayEntries
|
||
if (words.isEmpty()) englishExpandCandidatesButton?.visibility = View.GONE
|
||
}
|
||
|
||
/**
|
||
* Tapping a candidate replaces the just-typed prefix with the full word
|
||
* plus a trailing space. Verifies the actual field content matches
|
||
* englishComposingWord (trying progressively shorter suffixes, same
|
||
* defensive technique as invalidateDictationResultIfTextChanged())
|
||
* before deleting, rather than trusting the buffer's length blindly —
|
||
* without this, any drift between the two left the old prefix letters
|
||
* sitting in front of the inserted word instead of being replaced by it.
|
||
*/
|
||
private fun selectEnglishCandidate(word: String) {
|
||
if (!personalizedLearningAllowed()) return
|
||
val connection = currentInputConnection ?: return
|
||
val typed = englishComposingWord.toString()
|
||
for (length in typed.length downTo 1) {
|
||
val actual = connection.getTextBeforeCursor(length, 0)?.toString()
|
||
if (actual != null && actual.equals(typed.takeLast(length), ignoreCase = true)) {
|
||
connection.deleteSurroundingText(length, 0)
|
||
break
|
||
}
|
||
}
|
||
if (!connection.commitText("$word ", 1)) return
|
||
englishCandidateProvider.recordCommit(word)
|
||
englishComposingWord.clear()
|
||
updateEnglishCandidates()
|
||
performKeyHaptic()
|
||
}
|
||
|
||
/**
|
||
* Long-press on an English candidate — only does anything for a word
|
||
* the user's own typing taught this keyboard (see
|
||
* EnglishCandidateProvider.isCustomWord()); the bundled base dictionary
|
||
* isn't user-removable, so a long-press on one of those is a no-op
|
||
* (silent, not an error toast — a base-dictionary word appearing in the
|
||
* candidate row is completely ordinary, not something to explain away).
|
||
*/
|
||
private fun forgetEnglishCandidate(word: String) {
|
||
if (!englishCandidateProvider.isCustomWord(word)) return
|
||
englishCandidateProvider.forgetCustomWord(word)
|
||
performKeyHaptic()
|
||
Toast.makeText(this, ui("已移除“$word”", "Removed \"$word\""), Toast.LENGTH_SHORT).show()
|
||
updateEnglishCandidates()
|
||
}
|
||
|
||
/**
|
||
* Renders LitePinyinController's own candidate callback into the same
|
||
* candidateItemView()/englishCandidateRow the English candidates use
|
||
* (plan 5.3's Option B — see docs/pinyin-lite/phase-0-audit.md section
|
||
* 4) — a non-clickable encoding label ("guo") is prepended when there's
|
||
* a non-empty encoding, per phase-0 audit section 5, adding no new row
|
||
* or container so the fixed 300dp panel height is never touched.
|
||
*/
|
||
private fun renderPinyinCandidates(candidates: List<String>) {
|
||
val row = englishCandidateRow ?: return
|
||
row.removeAllViews()
|
||
val encoding = litePinyinController.currentEncoding()
|
||
if (encoding.isNotEmpty()) {
|
||
val label = TextView(this).apply {
|
||
text = encoding
|
||
textSize = 16f
|
||
setSingleLine(true)
|
||
gravity = android.view.Gravity.CENTER
|
||
setTextColor(tone(Color.rgb(180, 180, 180), Color.rgb(120, 120, 125)))
|
||
setPadding(dp(9), 0, dp(9), 0)
|
||
isClickable = false
|
||
}
|
||
row.addView(label, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||
}
|
||
val overlayEntries = mutableListOf<Pair<String, () -> Unit>>()
|
||
candidates.forEachIndexed { index, char ->
|
||
row.addView(
|
||
candidateItemView(
|
||
char,
|
||
isFirst = index == 0,
|
||
action = { selectPinyinCandidate(char) },
|
||
),
|
||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT),
|
||
)
|
||
overlayEntries.add(char to { selectPinyinCandidate(char) })
|
||
}
|
||
// Shared with the English/stroke candidate rows (only one of the
|
||
// three is ever visible at a time) — see showCandidateOverlay().
|
||
candidateOverlayEntries = overlayEntries
|
||
if (candidates.isEmpty()) englishExpandCandidatesButton?.visibility = View.GONE
|
||
}
|
||
|
||
/** Candidate tap is the ONLY way a pinyin candidate reaches the field — see toggleLatinInputMode()'s doc comment on why Space deliberately never does this. */
|
||
private fun selectPinyinCandidate(char: String) {
|
||
if (ImeLearningPolicy.isPassword(currentInputEditorInfo?.inputType ?: 0)) return
|
||
if (currentInputConnection?.commitText(char, 1) != true) return
|
||
// observeCommitForLearning needs the encoding still intact; commitSelection
|
||
// then records frequency under the real sourceKey and consumes that key
|
||
// (full buffer or first syllable) before refreshing the candidate row.
|
||
litePinyinController.observeCommitForLearning(char)
|
||
litePinyinController.commitSelection(char) { renderPinyinCandidates(it) }
|
||
litePinyinController.recordCommittedText(char)
|
||
performKeyHaptic()
|
||
refreshPinyinAssociations()
|
||
}
|
||
|
||
/**
|
||
* Post-commit "what word comes next" suggestions — reuses
|
||
* StrokeInputController's own phraseRepository instance (same ~220k-
|
||
* entry index, same user-frequency store Stroke mode already tunes) so
|
||
* a word committed via Pinyin can suggest a continuation the same way
|
||
* Stroke's own commits do, and vice versa. Gated by the same
|
||
* strokeAssociationEnabled preference Stroke's own refreshAssociations()
|
||
* checks — this is explicitly the same feature, not a parallel one.
|
||
*/
|
||
private fun refreshPinyinAssociations() {
|
||
if (!personalizedLearningAllowed() || !OpenLessAndroidPreferences.strokeAssociationEnabled(this)) {
|
||
renderPinyinAssociations(emptyList())
|
||
return
|
||
}
|
||
litePinyinController.queryAssociations(strokeController.phraseRepository) { results ->
|
||
// isEmpty(): guards against the case where the user already
|
||
// started typing a fresh encoding by the time this async result
|
||
// lands — associationEpoch alone only protects against a STALE
|
||
// association query, not against a completely different
|
||
// encoding query having taken over the candidate row since.
|
||
if (latinInputMode == LatinInputMode.PINYIN && litePinyinController.isEmpty()) renderPinyinAssociations(results)
|
||
}
|
||
}
|
||
|
||
/** Same row/candidateItemView() machinery as renderPinyinCandidates(), but no encoding label (there's no in-progress encoding here) and suffix-based commit (see selectPinyinAssociation()) instead of committing the whole label — mirrors StrokeInputController.refreshAssociations() exactly. */
|
||
private fun renderPinyinAssociations(candidates: List<StrokePhraseRepository.Candidate>) {
|
||
val row = englishCandidateRow ?: return
|
||
row.removeAllViews()
|
||
val overlayEntries = mutableListOf<Pair<String, () -> Unit>>()
|
||
candidates.forEachIndexed { index, candidate ->
|
||
val displayText = outputScript(candidate.text)
|
||
row.addView(
|
||
candidateItemView(
|
||
displayText,
|
||
isFirst = index == 0,
|
||
action = { selectPinyinAssociation(candidate) },
|
||
),
|
||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT),
|
||
)
|
||
overlayEntries.add(displayText to { selectPinyinAssociation(candidate) })
|
||
}
|
||
candidateOverlayEntries = overlayEntries
|
||
if (candidates.isEmpty()) englishExpandCandidatesButton?.visibility = View.GONE
|
||
}
|
||
|
||
/**
|
||
* Only the part of the association candidate not already on screen gets
|
||
* committed — matches StrokeInputController.commitAssociation() exactly
|
||
* (same StrokePhraseRepository.Candidate type). candidate.matchedPrefix
|
||
* is always populated by StrokePhraseRepository.find() before a
|
||
* candidate is ever returned from searchAsync(), so it's trusted
|
||
* directly here rather than re-deriving the query context.
|
||
*/
|
||
private fun selectPinyinAssociation(candidate: StrokePhraseRepository.Candidate) {
|
||
if (isSensitiveField(currentInputEditorInfo) || !candidate.text.startsWith(candidate.matchedPrefix)) return
|
||
val suffix = candidate.text.removePrefix(candidate.matchedPrefix)
|
||
val connection = currentInputConnection ?: return
|
||
if (suffix.isNotEmpty() && !connection.commitText(outputScript(suffix), 1)) return
|
||
if (personalizedLearningAllowed() && OpenLessAndroidPreferences.strokeUsageEnabled(this)) {
|
||
strokeController.phraseRepository.recordUsage(candidate.matchedPrefix, candidate.text)
|
||
}
|
||
litePinyinController.recordCommittedText(suffix)
|
||
performKeyHaptic()
|
||
refreshPinyinAssociations()
|
||
}
|
||
|
||
/**
|
||
* Backspace, everywhere it's wired up in this service. deleteSurroundingText()
|
||
* alone only ever removes characters relative to the cursor position — it does
|
||
* not reliably consume an active selection (behavior varies by target app), so
|
||
* when there's a real OS-level selection this commits an empty string instead,
|
||
* which every InputConnection implementation replaces the selection with.
|
||
*/
|
||
internal fun deleteBackward() {
|
||
val connection = currentInputConnection ?: return
|
||
val selected = connection.getSelectedText(0)
|
||
// Consumed by the very next invalidateDictationResultIfTextChanged()
|
||
// call (from onUpdateSelection(), which this delete triggers) so it
|
||
// skips clearing lastDictationText — deleting via our own backspace
|
||
// (plain or select-all-then-backspace, both land here) should stay
|
||
// undoable, unlike text disappearing for some other reason (e.g. the
|
||
// host app clearing the field itself after sending).
|
||
selfInitiatedTextChange = true
|
||
if (!selected.isNullOrEmpty()) {
|
||
connection.commitText("", 1)
|
||
} else {
|
||
connection.deleteSurroundingText(1, 0)
|
||
}
|
||
}
|
||
|
||
internal fun sendEnterKey() {
|
||
currentInputConnection?.sendKeyEvent(android.view.KeyEvent(
|
||
android.view.KeyEvent.ACTION_DOWN,
|
||
android.view.KeyEvent.KEYCODE_ENTER,
|
||
))
|
||
currentInputConnection?.sendKeyEvent(android.view.KeyEvent(
|
||
android.view.KeyEvent.ACTION_UP,
|
||
android.view.KeyEvent.KEYCODE_ENTER,
|
||
))
|
||
}
|
||
|
||
override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
|
||
cancelImeSession()
|
||
super.onStartInput(attribute, restarting)
|
||
restoreInputMode()
|
||
restoreScriptPreference()
|
||
restoreLatinInputMode()
|
||
refreshLanguage()
|
||
strokeController.resetForNewInputSession()
|
||
englishLayer = EnglishLayer.LETTERS
|
||
englishComposingWord.clear()
|
||
englishCandidateQueryEpoch++
|
||
litePinyinController.clear()
|
||
litePinyinController.resetAssociationContext()
|
||
litePinyinController.setLearningAllowed(personalizedLearningAllowed())
|
||
if (ImeLearningPolicy.isPassword(attribute?.inputType ?: 0)) {
|
||
inputMode = InputMode.ENGLISH
|
||
latinInputMode = LatinInputMode.ENGLISH
|
||
}
|
||
renderEnglishCandidates(emptyList())
|
||
startRuntimeService()
|
||
sessionEpoch++
|
||
recording = false
|
||
processing = false
|
||
// A dictation result belongs to the editor it was typed into; carrying
|
||
// it over to whatever field the user taps next risks an undo/edit
|
||
// silently mangling unrelated text.
|
||
lastDictationText = null
|
||
dictationTextUndone = false
|
||
editingDictationResult = false
|
||
awaitingEditReplacement = false
|
||
if (isSensitiveField(attribute)) {
|
||
updateStatus("敏感字段,已禁用听写")
|
||
} else {
|
||
setState("idle", "点击开始说话")
|
||
}
|
||
}
|
||
|
||
override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
|
||
super.onStartInputView(info, restarting)
|
||
// InputMethodService caches onCreateInputView()'s result across hide/
|
||
// show. Keyboard settings write height/raise prefs while that cached
|
||
// view is still the old footprint — rebuild when they diverge so the
|
||
// next focus actually shows what the settings preview promised.
|
||
val key = keyboardFootprintKey()
|
||
if (appliedKeyboardFootprintKey != key) {
|
||
appliedKeyboardFootprintKey = key
|
||
refreshInputView()
|
||
}
|
||
}
|
||
|
||
private fun keyboardFootprintKey(): String =
|
||
"${keyboardPanelHeightDp()}:${raiseHeightDp(this)}:$inputMode:$latinInputMode:${personalizedLearningAllowed()}"
|
||
|
||
override fun onFinishInput() {
|
||
cancelImeSession()
|
||
englishCandidateQueryEpoch++
|
||
englishComposingWord.clear()
|
||
litePinyinController.clear()
|
||
litePinyinController.resetAssociationContext()
|
||
strokeController.resetForNewInputSession()
|
||
if (recording) {
|
||
cancelImeSession()
|
||
}
|
||
recording = false
|
||
processing = false
|
||
lastDictationText = null
|
||
dictationTextUndone = false
|
||
editingDictationResult = false
|
||
awaitingEditReplacement = false
|
||
invalidateSession("输入目标已失效")
|
||
super.onFinishInput()
|
||
}
|
||
|
||
/**
|
||
* Detects the committed dictation text getting cleared by something
|
||
* OTHER than our own backspace key or undo button — e.g. the host app
|
||
* clearing the field itself after sending — so the undo/redo/edit
|
||
* controls don't keep pointing at text that's no longer actually there.
|
||
* Deleting via our own backspace (plain or select-all-then-backspace,
|
||
* see deleteBackward()) is deliberately NOT treated as a reason to hide
|
||
* these: that deletion is itself undoable (the undo button just
|
||
* re-commits lastDictationText), so hiding it would strand an
|
||
* accidental full erase with no way back.
|
||
*
|
||
* Only clears once NONE of the dictated span remains — a single
|
||
* backspace only shrinks it by one character, which should still leave
|
||
* the controls up, not hide them immediately. Checks every leading
|
||
* prefix of the dictated text (shortest first would also work, but
|
||
* longest-first short-circuits sooner in the common case) against
|
||
* what's actually sitting immediately before the cursor; if any prefix
|
||
* still matches there, some of the utterance is still present.
|
||
*/
|
||
private fun invalidateDictationResultIfTextChanged() {
|
||
if (selfInitiatedTextChange) {
|
||
selfInitiatedTextChange = false
|
||
return
|
||
}
|
||
val text = lastDictationText ?: return
|
||
if (dictationTextUndone) return
|
||
val connection = currentInputConnection ?: return
|
||
for (len in text.length downTo 1) {
|
||
if (connection.getTextBeforeCursor(len, 0)?.toString() == text.substring(0, len)) return
|
||
}
|
||
lastDictationText = null
|
||
updateDictationResultControls()
|
||
}
|
||
|
||
override fun onUpdateSelection(
|
||
oldSelStart: Int,
|
||
oldSelEnd: Int,
|
||
newSelStart: Int,
|
||
newSelEnd: Int,
|
||
candidatesStart: Int,
|
||
candidatesEnd: Int,
|
||
) {
|
||
super.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, candidatesStart, candidatesEnd)
|
||
invalidateDictationResultIfTextChanged()
|
||
}
|
||
|
||
internal fun toggleDictation(initialCloud: Boolean = false) {
|
||
if (processing) return
|
||
if (isSensitiveField(currentInputEditorInfo)) {
|
||
updateStatus("敏感字段,禁止听写")
|
||
return
|
||
}
|
||
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||
updateStatus("请先授予麦克风权限")
|
||
return
|
||
}
|
||
if (recording && quickNoteArmed) {
|
||
quickNoteDictation()
|
||
return
|
||
}
|
||
if (recording) {
|
||
recording = false
|
||
processing = true
|
||
if (editingDictationResult) {
|
||
// Leave the edit panel the instant recording stops — the
|
||
// "thinking"/polish animation plays on the main voice panel
|
||
// from here on, not the compact edit view. The eventual
|
||
// result still finishes the pending replacement via
|
||
// awaitingEditReplacement (see commitImeText()).
|
||
editingDictationResult = false
|
||
refreshInputView()
|
||
}
|
||
setState("thinking", "正在思考")
|
||
stopImeSession()
|
||
} else {
|
||
if (!isBackendReady()) {
|
||
if (!awaitingBackendReadyRecheck) {
|
||
backendRecheckStartedAtMs = android.os.SystemClock.elapsedRealtime()
|
||
android.util.Log.w("OpenLessImeService", "backend not ready at mic tap; starting recovery watch")
|
||
OpenLessProcessRestartStats(this, "mictap").recordStart()
|
||
}
|
||
setState("error", ui("服务尚未就绪", "Service not ready yet"))
|
||
voiceLinkWarning?.text = ui("服务尚未就绪,点击重启应用", "Service not ready — tap to restart the app")
|
||
voiceLinkWarning?.visibility = View.VISIBLE
|
||
ensureBackendReady()
|
||
scheduleBackendReadyRecheck()
|
||
return
|
||
}
|
||
recording = true
|
||
processing = false
|
||
rawModeArmed = false
|
||
quickNoteArmed = false
|
||
cloudNoteArmed = false
|
||
// The actual start of a new recording attempt — reset the
|
||
// silence watch and fire the start haptic here, not in
|
||
// onCapsuleStateChanged's "recording" branch: that branch only
|
||
// acted when `recording` was still false at the time, but this
|
||
// line already flips it true before the native "recording"
|
||
// callback ever arrives, so both were structurally unreachable
|
||
// on every normal tap-to-start (silently never firing).
|
||
recordingStartedAtMs = android.os.SystemClock.elapsedRealtime()
|
||
maxObservedLevelThisSession = 0f
|
||
voiceLinkWarning?.visibility = View.GONE
|
||
performKeyHaptic()
|
||
// A genuinely new utterance (not the correction/edit sub-flow,
|
||
// which already hides these via editingDictationResult) makes
|
||
// the previous result's undo/redo/edit controls stale the
|
||
// moment recording starts, not just once the new result
|
||
// replaces them — leaving them up mid-recording risked an undo
|
||
// tap acting on the wrong utterance.
|
||
if (!editingDictationResult) {
|
||
lastDictationText = null
|
||
}
|
||
setState("speaking", "再次点击结束 · 下划取消")
|
||
imeRequestId = 0L
|
||
imeSessionId = null
|
||
cloudNoteArmed = initialCloud
|
||
imeRequestId = nextImeRequest.incrementAndGet()
|
||
pendingImeStop = false
|
||
if (!sendImeCommand("start")) { recording = false; processing = false }
|
||
}
|
||
}
|
||
|
||
private fun cancelDictation() {
|
||
recording = false
|
||
processing = false
|
||
rawModeArmed = false
|
||
quickNoteArmed = false
|
||
cloudNoteArmed = false
|
||
invalidateSession("已取消")
|
||
cancelImeSession()
|
||
}
|
||
|
||
private fun quickNoteDictation() {
|
||
recording = false
|
||
processing = true
|
||
setState("thinking", ui("正在保存笔记", "Saving note"))
|
||
stopImeSession()
|
||
}
|
||
|
||
// Also requires a registered Activity Context (see
|
||
// OpenLessNative.nativeHasRegisteredActivityContext()'s doc comment),
|
||
// not just a running backend: the Rust backend can stay healthy long
|
||
// after its last Activity is destroyed, but every dictation/waveform
|
||
// status notification silently fails without one — recording would
|
||
// otherwise start with zero visible feedback (no waveform, no red
|
||
// warning, nothing), looking exactly like the mic tap did nothing.
|
||
private fun isBackendReady(): Boolean = try {
|
||
OpenLessNative.requireBackendContract()
|
||
OpenLessNative.nativeHasRegisteredActivityContext()
|
||
} catch (error: Throwable) {
|
||
false
|
||
}
|
||
|
||
/**
|
||
* Runs every BACKEND_HEARTBEAT_INTERVAL_MS for the service's whole
|
||
* lifetime (started in onCreate(), stopped in onDestroy()) — not just
|
||
* while actively recording — so backendLinkIndicator can catch the link
|
||
* having quietly dropped *before* the user taps the mic and gets no
|
||
* waveform, instead of only reacting after a failed tap (see "mictap").
|
||
* On a bad reading it also proactively calls ensureBackendReady() the
|
||
* same way a failed mic tap already does, so the self-recovery this
|
||
* symptom relies on gets a head start instead of waiting for the next
|
||
* tap.
|
||
*/
|
||
private fun runBackendHeartbeatCheck() {
|
||
val ready = isBackendReady()
|
||
backendLinkHealthy = ready
|
||
lastHeartbeatElapsedRealtime = android.os.SystemClock.elapsedRealtime()
|
||
lastHeartbeatReady = ready
|
||
// Always repaint the currently mounted indicator. Previously this was
|
||
// skipped while recording/processing, and a recovery could remain
|
||
// visually stale until the user switched panels and rebuilt the View.
|
||
updateBackendLinkIndicator()
|
||
if (!ready) {
|
||
OpenLessProcessRestartStats(this, "heartbeat").recordStart()
|
||
ensureBackendReady()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ready/recording/processing take priority (mirrors the overlay's own
|
||
* state machine) — the heartbeat's own reading (backendLinkHealthy)
|
||
* only shows through while otherwise idle, as a distinct color rather
|
||
* than silently agreeing with "ready". Not gated on an actual
|
||
* connectivity check (that would need a new ACCESS_NETWORK_STATE
|
||
* permission the manifest doesn't declare yet): isBackendReady()
|
||
* false is already the same signal "mictap" reacts to, and is directly
|
||
* what the user described as the link being "disconnected".
|
||
*/
|
||
private fun updateBackendLinkIndicator() {
|
||
val color = when {
|
||
// Matches VoiceButton's own waveform color for each armed
|
||
// gesture exactly (rawWaveformColor's when block) — the
|
||
// breathing dot and the waveform should never disagree about
|
||
// which mode a recording is currently armed for.
|
||
recording && rawModeArmed -> LINK_COLOR_RECORDING_RAW
|
||
recording && quickNoteArmed -> LINK_COLOR_QUICK_NOTE
|
||
recording && cloudNoteArmed -> LINK_COLOR_CLOUD_NOTE
|
||
recording -> LINK_COLOR_RECORDING
|
||
processing -> LINK_COLOR_PROCESSING
|
||
!backendLinkHealthy -> LINK_COLOR_ISSUE
|
||
else -> LINK_COLOR_READY
|
||
}
|
||
(backendLinkIndicator?.background as? GradientDrawable)?.setColor(color)
|
||
backendLinkPulseAnimator?.duration =
|
||
if (color == LINK_COLOR_READY) BACKEND_LINK_READY_PULSE_DURATION_MS else BACKEND_LINK_PULSE_DURATION_MS
|
||
}
|
||
|
||
// Guards scheduleBackendReadyRecheck() so a second mic tap while a
|
||
// recheck loop is already running doesn't stack a duplicate one.
|
||
private var awaitingBackendReadyRecheck = false
|
||
|
||
// elapsedRealtime() at the moment "not ready" was first detected, so the
|
||
// eventual recovery (or timeout) log can report how long it actually took.
|
||
private var backendRecheckStartedAtMs = 0L
|
||
|
||
/**
|
||
* Polls isBackendReady() roughly once a second after showing the
|
||
* "service not ready" warning, since nothing else pushes a "the
|
||
* backend just finished warming up" event into this service — without
|
||
* this, the warning stayed stuck until some unrelated action (another
|
||
* mic tap, switching panels) happened to rebuild/reset it, even though
|
||
* the backend may have become ready seconds earlier in the background.
|
||
* Capped at ~60s of retries rather than polling forever if the backend
|
||
* genuinely never recovers. Raised from an earlier 20s cap after a real
|
||
* device recovery was observed taking ~31s (a double cold-start: the
|
||
* warmup Activity got destroyed once mid-warmup, then succeeded on a
|
||
* second attempt), which exceeded that cap and left the warning stuck.
|
||
*/
|
||
private fun scheduleBackendReadyRecheck(attempt: Int = 0) {
|
||
if (attempt == 0) {
|
||
if (awaitingBackendReadyRecheck) return
|
||
awaitingBackendReadyRecheck = true
|
||
}
|
||
if (isBackendReady()) {
|
||
awaitingBackendReadyRecheck = false
|
||
val elapsedMs = android.os.SystemClock.elapsedRealtime() - backendRecheckStartedAtMs
|
||
android.util.Log.i("OpenLessImeService", "backend recovered after ${elapsedMs}ms (attempt=$attempt)")
|
||
voiceLinkWarning?.visibility = View.GONE
|
||
setState("idle", ui("点击开始说话", "Tap to speak"))
|
||
return
|
||
}
|
||
if (attempt >= 60) {
|
||
awaitingBackendReadyRecheck = false
|
||
val elapsedMs = android.os.SystemClock.elapsedRealtime() - backendRecheckStartedAtMs
|
||
android.util.Log.w("OpenLessImeService", "backend still not ready after ${elapsedMs}ms; giving up recheck loop")
|
||
return
|
||
}
|
||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||
scheduleBackendReadyRecheck(attempt + 1)
|
||
}, 1000L)
|
||
}
|
||
|
||
private fun runNativeAction(action: String, call: () -> Unit) {
|
||
try {
|
||
call()
|
||
} catch (error: Throwable) {
|
||
recording = false
|
||
processing = false
|
||
updateStatus("${action}失败,请打开 OpenLess 后重试")
|
||
android.util.Log.w("OpenLessImeService", "$action native bridge unavailable", error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* `message ?: fallback` only substitutes for a literal null — a native
|
||
* capsule-state event carrying a blank-but-non-null message (empty
|
||
* string, or whitespace) would slip through that check unchanged and
|
||
* land the status line on invisible/blank text, with nothing left to
|
||
* ever correct it (see onCapsuleStateChanged()'s "idle" case, which
|
||
* this guards against overwriting setState()'s own delayed "done" ->
|
||
* "idle" revert with a blank message that then never gets fixed, since
|
||
* state has already moved off "done" by the time that revert's own
|
||
* guard checks it).
|
||
*/
|
||
private fun String?.orDefault(fallback: String): String = this?.takeIf { it.isNotBlank() } ?: fallback
|
||
|
||
override fun onCapsuleStateChanged(state: String, message: String?, level: Float) {
|
||
voiceButton?.audioLevel = level.coerceIn(0f, 1f)
|
||
when (state) {
|
||
"recording" -> {
|
||
if (!recording) {
|
||
// Fresh recording attempt — give it a clean silence watch.
|
||
recordingStartedAtMs = android.os.SystemClock.elapsedRealtime()
|
||
maxObservedLevelThisSession = 0f
|
||
voiceLinkWarning?.visibility = View.GONE
|
||
performKeyHaptic()
|
||
}
|
||
recording = true
|
||
processing = false
|
||
maxObservedLevelThisSession = maxOf(maxObservedLevelThisSession, level)
|
||
val elapsedMs = android.os.SystemClock.elapsedRealtime() - recordingStartedAtMs
|
||
if (maxObservedLevelThisSession >= SILENCE_LEVEL_THRESHOLD) {
|
||
voiceLinkWarning?.visibility = View.GONE
|
||
} else if (elapsedMs > SILENCE_CHECK_DELAY_MS) {
|
||
// This only runs once an actual recording session is
|
||
// underway, so it can't overlap with the "backend not
|
||
// ready" case in toggleDictation() (that one returns
|
||
// before a session ever starts) — but set this widget's
|
||
// own text explicitly anyway rather than trusting
|
||
// whatever the other case last left it as.
|
||
voiceLinkWarning?.text = ui("检测到麦克风无声音,点击重启应用", "No mic audio detected — tap to restart the app")
|
||
voiceLinkWarning?.visibility = View.VISIBLE
|
||
}
|
||
setState("speaking", "再次点击结束 · 下划取消")
|
||
}
|
||
"transcribing" -> {
|
||
// Guarded by the same "was still recording" check as
|
||
// "polishing" below, since either one can be the first to
|
||
// fire after recording actually stops — only whichever
|
||
// gets there first should vibrate.
|
||
if (recording) performKeyHaptic()
|
||
recording = false
|
||
processing = true
|
||
setState("thinking", "正在思考")
|
||
}
|
||
"polishing" -> {
|
||
if (recording) performKeyHaptic()
|
||
recording = false
|
||
processing = true
|
||
setState("thinking", "正在思考")
|
||
}
|
||
"done" -> {
|
||
recording = false
|
||
processing = false
|
||
setState("done", message.orDefault("已完成"))
|
||
performKeyHaptic()
|
||
}
|
||
"cancelled" -> {
|
||
recording = false
|
||
processing = false
|
||
setState("idle", message.orDefault("已取消"))
|
||
}
|
||
"error" -> {
|
||
recording = false
|
||
processing = false
|
||
setState("error", message.orDefault("识别失败"))
|
||
}
|
||
"idle" -> if (!recording) {
|
||
setState("idle", message.orDefault("点击开始说话"))
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun invalidateSession(message: String) {
|
||
sessionEpoch++
|
||
updateStatus(message)
|
||
}
|
||
|
||
internal fun isSensitiveField(attribute: EditorInfo?): Boolean {
|
||
val inputType = attribute?.inputType ?: return false
|
||
val variation = inputType and InputType.TYPE_MASK_VARIATION
|
||
return (inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER ||
|
||
variation == InputType.TYPE_TEXT_VARIATION_PASSWORD ||
|
||
variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD ||
|
||
variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
||
}
|
||
|
||
private fun updateStatus(message: String) {
|
||
currentMessage = message
|
||
status?.text = displayStatus(message)
|
||
status?.setTextColor(recordingAccentColor())
|
||
voiceRawHint?.text = rawModeHintText()
|
||
voiceRawHint?.setTextColor(rawModeHintColor())
|
||
voiceRawHint?.visibility = if (rawModeHintVisible()) View.VISIBLE else View.GONE
|
||
voiceButton?.isRecording = recording
|
||
voiceButton?.isProcessing = processing
|
||
updateDictationResultControls()
|
||
updateBackendLinkIndicator()
|
||
}
|
||
|
||
/**
|
||
* Swipe-up-for-Raw / swipe-left-for-Quick-notes / swipe-right-for-
|
||
* Cloud-notes discoverability hint while idle; once a recording is
|
||
* actually armed into one of those three modes (live through both
|
||
* recording and the following "thinking"/整理 step for Raw and Cloud
|
||
* notes — quick note never reaches "thinking", see
|
||
* quickNoteDictation()), the row repurposes itself to confirm whichever
|
||
* one is armed instead. Recording-or-thinking with none armed (an
|
||
* ordinary dictation) never reaches this text at all — see
|
||
* rawModeHintVisible(), which hides the row entirely for that case.
|
||
*/
|
||
private fun rawModeHintText(): String {
|
||
return when {
|
||
(state == "speaking" || state == "thinking") && rawModeArmed -> ui("原样转写", "Raw Mode")
|
||
(state == "speaking" || state == "thinking") && cloudNoteArmed -> ui("云笔记", "Cloud notes")
|
||
state == "speaking" && quickNoteArmed -> ui("速记模式", "Quick notes")
|
||
else -> ui("上划RAW · 左划速记 · 右划云笔记", "Up: Raw · Left: Quick notes · Right: Cloud notes")
|
||
}
|
||
}
|
||
|
||
/** Same orange/green/red as the status line's own Raw/Quick-notes/Cloud-notes coloring and every other indicator for each mode (VoiceButton's armed pill/waveform) — muted gray otherwise. */
|
||
private fun rawModeHintColor(): Int {
|
||
return when {
|
||
(state == "speaking" || state == "thinking") && rawModeArmed -> LINK_COLOR_RECORDING_RAW
|
||
(state == "speaking" || state == "thinking") && cloudNoteArmed -> LINK_COLOR_CLOUD_NOTE
|
||
state == "speaking" && quickNoteArmed -> LINK_COLOR_QUICK_NOTE
|
||
else -> Color.argb((0.8f * 255).toInt(), 0xB0, 0xB0, 0xB0)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Hidden while actively recording or thinking in an ordinary dictation
|
||
* (none of Raw/Quick-notes/Cloud-notes armed) — at that point it's neither teaching
|
||
* a still-relevant gesture (idle) nor confirming an active one, just a
|
||
* stray label under the mic. Visible the rest of the time: idle
|
||
* (teaches all three gestures) and recording/thinking once one of them
|
||
* is actually armed (confirms it).
|
||
*/
|
||
private fun rawModeHintVisible(): Boolean {
|
||
return !((state == "speaking" || state == "thinking") && !rawModeArmed && !quickNoteArmed && !cloudNoteArmed)
|
||
}
|
||
|
||
/** The request owns its destination; delayed network callbacks cannot affect a later recording. */
|
||
private fun submitCloudNoteText(text: String, destination: Pair<String, String>?, requestId: Long) {
|
||
if (text.isBlank()) {
|
||
recording = false
|
||
processing = false
|
||
setState("error", "没有识别到文字")
|
||
return
|
||
}
|
||
val (url, token) = destination ?: ("" to "")
|
||
recording = false
|
||
processing = false
|
||
if (url.isEmpty() || token.isEmpty()) {
|
||
setState("error", "请先在设置中填写云笔记的地址/Token")
|
||
return
|
||
}
|
||
setState("thinking", "正在提交云笔记")
|
||
Thread {
|
||
val mainHandler = android.os.Handler(Looper.getMainLooper())
|
||
var connection: java.net.HttpURLConnection? = null
|
||
try {
|
||
val body = org.json.JSONObject().apply {
|
||
put("token", token)
|
||
put("content", text)
|
||
put("client", "input_method")
|
||
}.toString()
|
||
connection = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
|
||
requestMethod = "POST"
|
||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||
doOutput = true
|
||
connectTimeout = 8000
|
||
readTimeout = 8000
|
||
}
|
||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||
val code = connection.responseCode
|
||
mainHandler.post {
|
||
if (requestId != imeRequestId) return@post
|
||
if (code in 200..299) {
|
||
setState("done", "已提交云笔记", QUICK_NOTE_CONFIRMATION_DELAY_MS)
|
||
} else {
|
||
setState("error", "云笔记提交失败($code)")
|
||
}
|
||
}
|
||
} catch (error: Throwable) {
|
||
android.util.Log.w("OpenLessImeService", "cloud note webhook submit failed: ${error.javaClass.simpleName}")
|
||
mainHandler.post {
|
||
if (requestId == imeRequestId) setState("error", "云笔记提交失败,请检查网络")
|
||
}
|
||
} finally { connection?.disconnect() }
|
||
}.start()
|
||
}
|
||
|
||
private fun commitImeText(text: String) {
|
||
if (awaitingEditReplacement) {
|
||
finishEditWithSpokenReplacement(text)
|
||
return
|
||
}
|
||
if (text.isBlank()) {
|
||
recording = false
|
||
processing = false
|
||
setState("error", "没有识别到文字")
|
||
return
|
||
}
|
||
val connection = currentInputConnection
|
||
if (connection == null || !connection.commitText(text, 1)) {
|
||
recording = false
|
||
processing = false
|
||
setState("error", "输入连接已失效")
|
||
return
|
||
}
|
||
recording = false
|
||
processing = false
|
||
lastDictationText = text
|
||
lastDictationEpoch = sessionEpoch
|
||
dictationTextUndone = false
|
||
setState("done", "已上屏")
|
||
}
|
||
|
||
private fun flatCircleButton(icon: String, sizeDp: Int, description: String, action: () -> Unit): TextView {
|
||
return TextView(this).apply {
|
||
text = icon
|
||
textSize = sizeDp * 0.45f
|
||
gravity = android.view.Gravity.CENTER
|
||
setTextColor(tone(Color.WHITE, Color.rgb(40, 40, 44)))
|
||
background = GradientDrawable().apply {
|
||
shape = GradientDrawable.OVAL
|
||
setColor(tone(Color.rgb(60, 60, 60), Color.rgb(225, 225, 228)))
|
||
}
|
||
contentDescription = description
|
||
setOnClickListener { action() }
|
||
}
|
||
}
|
||
|
||
/** Shows/hides and relabels the undo↔redo toggle and edit button for the last dictation result. */
|
||
private fun updateDictationResultControls() {
|
||
// No longer gated on state == "done": that flips to "recording"/
|
||
// "thinking" for the *next* utterance almost immediately, which made
|
||
// these controls disappear right after appearing. They should stay
|
||
// up as long as there's a previous result sitting in the buffer to
|
||
// undo/redo/edit, regardless of what the mic is doing right now.
|
||
val visible = inputMode == InputMode.VOICE && lastDictationText != null && !editingDictationResult
|
||
// This row is a FrameLayout overlay above the footer (see
|
||
// buildDictationResultOverlay()), not part of the panel's own
|
||
// LinearLayout flow, so GONE here never shifts the mic or footer.
|
||
dictationResultRow?.visibility = if (visible) View.VISIBLE else View.GONE
|
||
undoRedoButton?.text = if (dictationTextUndone) "↻" else "✕"
|
||
undoRedoButton?.contentDescription = if (dictationTextUndone) {
|
||
ui("重做本次听写结果", "Redo this dictation")
|
||
} else {
|
||
ui("撤销本次听写结果", "Undo this dictation")
|
||
}
|
||
}
|
||
|
||
/** Toggles between removing this dictation's committed text and restoring it. */
|
||
private fun toggleUndoRedoDictation() {
|
||
val text = lastDictationText ?: return
|
||
if (sessionEpoch != lastDictationEpoch) return
|
||
val connection = currentInputConnection ?: return
|
||
if (!dictationTextUndone) {
|
||
// Flip the flag before deleting: onUpdateSelection() may run
|
||
// synchronously inside deleteSurroundingText() and would
|
||
// otherwise see dictationTextUndone still false, mistake this
|
||
// for the user manually erasing the text some other way, and
|
||
// wipe lastDictationText — breaking redo.
|
||
dictationTextUndone = true
|
||
if (!connection.deleteSurroundingText(text.length, 0)) {
|
||
dictationTextUndone = false
|
||
return
|
||
}
|
||
updateStatus("已撤销")
|
||
scheduleRevertToIdle()
|
||
} else {
|
||
if (!connection.commitText(text, 1)) return
|
||
dictationTextUndone = false
|
||
updateStatus("已上屏")
|
||
scheduleRevertToIdle()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Switches the voice panel into the "edit dictation result" sub-view.
|
||
* Corrects whatever text the user selected in the real input field
|
||
* (native OS selection, e.g. long-press to select a wrong word); if
|
||
* nothing is selected, falls back to replacing the whole last dictation
|
||
* result so the button still does something useful.
|
||
*/
|
||
private fun openEditDictationResult() {
|
||
val connection = currentInputConnection ?: return
|
||
val selected = connection.getSelectedText(0)?.toString()?.takeIf { it.isNotEmpty() }
|
||
if (selected != null) {
|
||
editingOriginalText = selected
|
||
editingReplacesWholeResult = false
|
||
} else {
|
||
val whole = lastDictationText ?: return
|
||
if (sessionEpoch != lastDictationEpoch || dictationTextUndone) return
|
||
editingOriginalText = whole
|
||
editingReplacesWholeResult = true
|
||
}
|
||
// Starts unchecked every time — adding to the Dictionary is an
|
||
// opt-in the user ticks deliberately, not a guess based on how much
|
||
// of the result was selected. The user can still flip the checkbox
|
||
// themselves before finishing.
|
||
addToDictionaryForEdit = false
|
||
editingDictationResult = true
|
||
awaitingEditReplacement = true
|
||
refreshInputView()
|
||
// Recording starts immediately when the edit panel opens — the user
|
||
// only has to tap the mic once, to finish, matching "Tap again to
|
||
// finish" rather than requiring a tap to start too.
|
||
toggleDictation()
|
||
}
|
||
|
||
/**
|
||
* Long-press "History"/"历史" in the clipboard panel: same mechanism as
|
||
* openEditDictationResult()'s selected-text branch (replaces the
|
||
* selection in the real input field with the spoken correction) — but
|
||
* with no whole-last-result fallback. Without an actual selection
|
||
* there's nothing this gesture can reasonably act on, so it surfaces a
|
||
* hint instead of guessing.
|
||
*/
|
||
private fun openSelectedTextCorrectionViaVoice() {
|
||
val selected = currentInputConnection?.getSelectedText(0)?.toString()?.takeIf { it.isNotEmpty() }
|
||
if (selected == null) {
|
||
Toast.makeText(this, ui("请先选中字词", "Please select word"), Toast.LENGTH_SHORT).show()
|
||
return
|
||
}
|
||
editingOriginalText = selected
|
||
editingReplacesWholeResult = false
|
||
addToDictionaryForEdit = false
|
||
editingDictationResult = true
|
||
awaitingEditReplacement = true
|
||
refreshInputView()
|
||
toggleDictation()
|
||
}
|
||
|
||
/** Backs out of the edit sub-view without applying any correction. */
|
||
private fun closeEditDictationResult() {
|
||
if (recording) {
|
||
cancelImeSession()
|
||
}
|
||
recording = false
|
||
processing = false
|
||
editingDictationResult = false
|
||
awaitingEditReplacement = false
|
||
editingOriginalText = null
|
||
setState("done", "已上屏")
|
||
refreshInputView()
|
||
}
|
||
|
||
/**
|
||
* Applies the freshly spoken replacement for editingOriginalText: swaps
|
||
* it into the input field (relying on InputConnection.commitText's
|
||
* standard "replace the active selection" behavior when there is a real
|
||
* OS selection, or an explicit delete+insert when we fell back to the
|
||
* whole last result).
|
||
*
|
||
* Remembering the corrected word/phrase in the global Dictionary
|
||
* uses nativeAddLearnedVocabulary and the shared learned-source marker.
|
||
* addToDictionaryForEdit starts unchecked and records the user's explicit choice.
|
||
*/
|
||
private fun finishEditWithSpokenReplacement(text: String) {
|
||
recording = false
|
||
processing = false
|
||
val original = editingOriginalText
|
||
val replacesWhole = editingReplacesWholeResult
|
||
val shouldAddToDictionary = addToDictionaryForEdit
|
||
editingDictationResult = false
|
||
awaitingEditReplacement = false
|
||
editingOriginalText = null
|
||
if (text.isBlank() || original == null) {
|
||
setState("done", "已上屏")
|
||
refreshInputView()
|
||
return
|
||
}
|
||
val connection = currentInputConnection
|
||
if (connection != null) {
|
||
if (replacesWhole) connection.deleteSurroundingText(original.length, 0)
|
||
connection.commitText(text, 1)
|
||
}
|
||
if (shouldAddToDictionary && text != original) {
|
||
runNativeAction("加入词典") {
|
||
check(OpenLessNative.nativeAddLearnedVocabulary(text)) { "词典保存失败,请在历史中重试" }
|
||
}
|
||
}
|
||
// A sub-span correction leaves the surrounding text's exact new
|
||
// length untracked, so the coarse "undo the whole utterance" no
|
||
// longer has a clean span to act on; a whole-result replacement is
|
||
// still one contiguous span, so undo/redo keeps working against it.
|
||
lastDictationText = if (replacesWhole) text else null
|
||
dictationTextUndone = false
|
||
setState("done", "已上屏")
|
||
refreshInputView()
|
||
}
|
||
|
||
private fun setState(nextState: String, message: String, revertDelayMs: Long = DONE_TO_IDLE_DELAY_MS) {
|
||
state = nextState
|
||
updateStatus(message)
|
||
// A completed commit/edit (see the various setState("done", "已
|
||
// 上屏") call sites) used to just sit there until the next recording
|
||
// started; the status line and the Raw hint below it should instead
|
||
// settle back to the same ready state as a fresh session shortly
|
||
// after, so it's visibly clear another dictation can start right
|
||
// away — see scheduleRevertToIdle(). revertDelayMs only ever
|
||
// differs from the default for quickNoteDictation()'s own shorter
|
||
// "笔记已记录" confirmation (QUICK_NOTE_CONFIRMATION_DELAY_MS).
|
||
if (nextState == "done") scheduleRevertToIdle(revertDelayMs)
|
||
}
|
||
|
||
/**
|
||
* Shows "点击开始说话"/"Tap to speak" again DONE_TO_IDLE_DELAY_MS after
|
||
* whatever transient confirmation is currently up (已上屏/已完成 via
|
||
* setState()'s "done" branch, or 已撤销/已上屏 from
|
||
* toggleUndoRedoDictation()'s direct updateStatus() calls, which don't
|
||
* go through setState() at all since undo/redo doesn't change `state`).
|
||
* A monotonic token, not a state-string comparison, guards the delayed
|
||
* callback: every call here bumps it, so only the most recently
|
||
* scheduled revert actually fires — tapping undo right after a commit
|
||
* (before the commit's own revert would have fired) correctly restarts
|
||
* the 2-second countdown from the undo instead of the two racing and
|
||
* the earlier one winning. sessionEpoch is still checked too, for a
|
||
* genuinely new input session in the meantime.
|
||
*/
|
||
private fun scheduleRevertToIdle(delayMs: Long = DONE_TO_IDLE_DELAY_MS) {
|
||
val epoch = sessionEpoch
|
||
val token = ++statusRevertToken
|
||
android.os.Handler(Looper.getMainLooper()).postDelayed({
|
||
if (sessionEpoch == epoch && statusRevertToken == token) {
|
||
setState("idle", ui("点击开始说话", "Tap to speak"))
|
||
}
|
||
}, delayMs)
|
||
}
|
||
|
||
private fun displayStatus(message: String): String {
|
||
if (!englishUi) return message
|
||
return when (message) {
|
||
"点击开始说话" -> "Tap to speak"
|
||
"再次点击结束 · 下划取消" -> "Tap again to finish · Swipe down to cancel"
|
||
"正在思考" -> "Thinking"
|
||
"已完成", "已上屏" -> "Done"
|
||
"笔记已记录" -> "Note saved"
|
||
"正在提交云笔记" -> "Submitting"
|
||
"已提交云笔记" -> "Cloud notes submitted"
|
||
"请先在设置中填写云笔记的地址/Token" -> "Fill in the Cloud notes URL/token in settings first"
|
||
"已取消" -> "Cancelled"
|
||
"敏感字段,已禁用听写", "敏感字段,禁止听写", "敏感字段,禁止上屏" -> "Dictation disabled in this field"
|
||
"请先授予麦克风权限" -> "Microphone permission required"
|
||
"输入目标已失效", "输入连接已失效", "没有有效输入连接" -> "Tap a text field to continue"
|
||
"没有识别到文字" -> "No speech recognized"
|
||
"识别失败" -> "Recognition failed. Please try again."
|
||
else -> if (message.any { it in '\u4e00'..'\u9fff' }) {
|
||
// Provider details remain in logs; the IME always uses its UI language.
|
||
when {
|
||
message.contains("失败") || state == "error" -> "Dictation failed. Please try again."
|
||
state == "thinking" -> "Thinking"
|
||
state == "speaking" -> "Tap again to finish"
|
||
state == "done" -> "Done"
|
||
else -> "Tap to speak"
|
||
}
|
||
} else message
|
||
}
|
||
}
|
||
|
||
internal fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
|
||
|
||
/**
|
||
* Purely visual 0.98x press-scale via StateListAnimator, which reacts to
|
||
* the view's own pressed drawable-state — it doesn't add or replace any
|
||
* click/touch listener, so a key's existing action/repeat wiring is
|
||
* untouched.
|
||
*/
|
||
private fun attachPressScale(view: View) {
|
||
val down = android.animation.ObjectAnimator.ofPropertyValuesHolder(
|
||
view,
|
||
android.animation.PropertyValuesHolder.ofFloat(View.SCALE_X, 0.98f),
|
||
android.animation.PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.98f),
|
||
).setDuration(100)
|
||
val up = android.animation.ObjectAnimator.ofPropertyValuesHolder(
|
||
view,
|
||
android.animation.PropertyValuesHolder.ofFloat(View.SCALE_X, 1f),
|
||
android.animation.PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f),
|
||
).setDuration(100)
|
||
view.stateListAnimator = android.animation.StateListAnimator().apply {
|
||
addState(intArrayOf(android.R.attr.state_pressed), down)
|
||
addState(intArrayOf(), up)
|
||
}
|
||
}
|
||
|
||
internal fun roundedButton(color: Int, radius: Int): android.graphics.drawable.Drawable {
|
||
val lowerEdge = GradientDrawable().apply {
|
||
shape = GradientDrawable.RECTANGLE
|
||
cornerRadius = radius.toFloat()
|
||
setColor(mixColor(color, Color.BLACK, 0.72f))
|
||
}
|
||
val face = GradientDrawable().apply {
|
||
shape = GradientDrawable.RECTANGLE
|
||
cornerRadius = radius.toFloat()
|
||
orientation = GradientDrawable.Orientation.TOP_BOTTOM
|
||
colors = intArrayOf(
|
||
mixColor(color, Color.WHITE, 0.09f),
|
||
color,
|
||
mixColor(color, Color.BLACK, 0.18f),
|
||
)
|
||
setStroke(dp(1), mixColor(color, Color.BLACK, 0.55f))
|
||
}
|
||
return android.graphics.drawable.LayerDrawable(arrayOf(lowerEdge, face)).apply {
|
||
// The exposed lower layer forms the reference keyboard's dark keycap step.
|
||
setLayerInset(0, 0, dp(2), 0, 0)
|
||
setLayerInset(1, 0, 0, 0, dp(3))
|
||
}
|
||
}
|
||
|
||
// Flat (no keycap relief) background for the encode+candidate strip: a
|
||
// rounded rect a touch lighter than the panel, plus a hairline divider
|
||
// baked in at the fixed 24dp encode/candidate boundary. Since `top`'s
|
||
// height is always exactly dp(60), this offset never drifts.
|
||
internal fun buildEncodeAreaBackground(): android.graphics.drawable.Drawable {
|
||
val panel = GradientDrawable().apply {
|
||
shape = GradientDrawable.RECTANGLE
|
||
cornerRadius = dp(6).toFloat()
|
||
setColor(tone(Color.rgb(58, 58, 58), Color.rgb(228, 228, 232)))
|
||
}
|
||
val divider = GradientDrawable().apply {
|
||
shape = GradientDrawable.RECTANGLE
|
||
setColor(tone(Color.rgb(82, 82, 82), Color.rgb(205, 205, 210)))
|
||
}
|
||
val dividerBottomInset = (dp(60) - dp(24) - dp(1)).coerceAtLeast(0)
|
||
return android.graphics.drawable.LayerDrawable(arrayOf(panel, divider)).apply {
|
||
setLayerInset(1, dp(8), dp(24), dp(8), dividerBottomInset)
|
||
}
|
||
}
|
||
|
||
private fun mixColor(first: Int, second: Int, amount: Float): Int {
|
||
val ratio = amount.coerceIn(0f, 1f)
|
||
return Color.rgb(
|
||
(Color.red(first) + (Color.red(second) - Color.red(first)) * ratio).toInt(),
|
||
(Color.green(first) + (Color.green(second) - Color.green(first)) * ratio).toInt(),
|
||
(Color.blue(first) + (Color.blue(second) - Color.blue(first)) * ratio).toInt(),
|
||
)
|
||
}
|
||
|
||
private fun startRuntimeService() {
|
||
try {
|
||
val intent = android.content.Intent(this, OpenLessRuntimeService::class.java)
|
||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||
startForegroundService(intent)
|
||
} else {
|
||
startService(intent)
|
||
}
|
||
ensureBackendReady()
|
||
} catch (error: Throwable) {
|
||
android.util.Log.w("OpenLessImeService", "failed to start IME runtime service", error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Exposed so the nested SwipeModeContainer can dismiss the keyboard on a
|
||
* downward swipe — requestHideSelf() itself is protected (inherited from
|
||
* InputMethodService), which a same-file nested class that isn't a
|
||
* subclass can't call directly through an instance reference.
|
||
*/
|
||
private fun hideKeyboardPanel() {
|
||
requestHideSelf(0)
|
||
}
|
||
|
||
/**
|
||
* Relaunches the whole app and kills this process, for the voice panel's
|
||
* "mic isn't producing audio" warning — a wedged capture/backend session
|
||
* is more reliably cleared by a real process restart than by retrying
|
||
* in place.
|
||
*/
|
||
private fun restartApp() {
|
||
runCatching {
|
||
packageManager.getLaunchIntentForPackage(packageName)?.let { launchIntent ->
|
||
launchIntent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||
startActivity(launchIntent)
|
||
}
|
||
}
|
||
android.os.Process.killProcess(android.os.Process.myPid())
|
||
}
|
||
|
||
private fun openKeyboardSettings() {
|
||
requestHideSelf(0)
|
||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||
startActivity(
|
||
android.content.Intent(this, OpenLessKeyboardSettingsActivity::class.java)
|
||
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
|
||
)
|
||
}, 180L)
|
||
}
|
||
|
||
private fun openSettings() {
|
||
requestHideSelf(0)
|
||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||
// Always go through OpenLessBackendWarmupActivity so there is ever only
|
||
// one tracked Tauri host: it reuses the existing instance if one is
|
||
// running, or starts fresh otherwise. Starting the bare MainActivity
|
||
// here would spin up an untracked second host and re-run Tauri/Rust
|
||
// setup from scratch, which is what produced the black window /
|
||
// native crash seen when settings was opened before any warmup host
|
||
// was tracked as running.
|
||
OpenLessBackendWarmupActivity.openSettings(this)
|
||
}, 180L)
|
||
}
|
||
|
||
private fun ensureBackendReady() = OpenLessBackendWarmupActivity.ensureBackendReady(this)
|
||
|
||
private fun stopRuntimeService() {
|
||
runCatching { stopService(android.content.Intent(this, OpenLessRuntimeService::class.java)) }
|
||
}
|
||
|
||
/**
|
||
* A vertical stack of keys that also recognizes a whole-column swipe:
|
||
* once a drag exceeds touch slop, this claims the gesture from whichever
|
||
* child key the drag started on (via onInterceptTouchEvent) so a single
|
||
* swipe anywhere in the column advances exactly one group, rather than
|
||
* being absorbed as a press/drag on one key.
|
||
*/
|
||
internal class SwipeRail(
|
||
context: android.content.Context,
|
||
private val onSwipe: (Int) -> Unit,
|
||
) : LinearLayout(context) {
|
||
private var startY = 0f
|
||
private var intercepting = false
|
||
private val touchSlop = android.view.ViewConfiguration.get(context).scaledTouchSlop
|
||
|
||
init {
|
||
excludeFromSystemGestures(this)
|
||
}
|
||
|
||
override fun onInterceptTouchEvent(ev: android.view.MotionEvent): Boolean {
|
||
when (ev.actionMasked) {
|
||
android.view.MotionEvent.ACTION_DOWN -> {
|
||
startY = ev.y
|
||
intercepting = false
|
||
}
|
||
android.view.MotionEvent.ACTION_MOVE -> {
|
||
if (!intercepting && kotlin.math.abs(ev.y - startY) > touchSlop) {
|
||
intercepting = true
|
||
// Stop the outer SwipeModeContainer (or any other
|
||
// ancestor) from also trying to claim this gesture
|
||
// once we've committed to handling it as a vertical
|
||
// drag — otherwise a still-moving finger can flip
|
||
// back and forth between the two interceptors.
|
||
parent?.requestDisallowInterceptTouchEvent(true)
|
||
}
|
||
}
|
||
}
|
||
return intercepting
|
||
}
|
||
|
||
override fun onTouchEvent(event: android.view.MotionEvent): Boolean {
|
||
if (event.actionMasked == android.view.MotionEvent.ACTION_UP ||
|
||
event.actionMasked == android.view.MotionEvent.ACTION_CANCEL
|
||
) {
|
||
val dy = startY - event.y
|
||
// Swiping up (finger moves toward the top, dy > 0) advances to
|
||
// the next group; swiping down goes back to the previous one.
|
||
// Posted rather than called inline: onSwipe rebuilds the whole
|
||
// input view, and doing that synchronously while this touch
|
||
// gesture is still unwinding through the view we're about to
|
||
// replace is what caused the panel to visibly flicker
|
||
// closed/open under the finger.
|
||
if (kotlin.math.abs(dy) > touchSlop) post { onSwipe(if (dy > 0) 1 else -1) }
|
||
intercepting = false
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Outer IME host for stretch+raise: key panel on top + empty lift strip
|
||
* below. Forces EXACT total height the same way [SwipeModeContainer]
|
||
* forces the key-panel height (setInputView discards LayoutParams).
|
||
* Re-reads prefs on every measure so a settings change applies on the
|
||
* next layout even before a full view rebuild.
|
||
*/
|
||
private class RaisedKeyboardHost(
|
||
context: android.content.Context,
|
||
) : LinearLayout(context) {
|
||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||
val panel = panelHeightPx(context)
|
||
val raise = raiseHeightPx(context)
|
||
if (childCount >= 1) {
|
||
getChildAt(0).layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, panel)
|
||
}
|
||
if (childCount >= 2) {
|
||
val spacer = getChildAt(1)
|
||
spacer.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, raise)
|
||
spacer.visibility = if (raise > 0) View.VISIBLE else View.GONE
|
||
}
|
||
val exactHeightSpec = android.view.View.MeasureSpec.makeMeasureSpec(
|
||
panel + raise,
|
||
android.view.View.MeasureSpec.EXACTLY,
|
||
)
|
||
super.onMeasure(widthMeasureSpec, exactHeightSpec)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Horizontal swipe-to-switch-mode, applied to every panel's root
|
||
* container: a left/right drag anywhere that isn't already claimed by a
|
||
* vertical gesture (like SwipeRail) steps the input mode toward
|
||
* Voice/English, matching the toggle switch's order. A downward drag
|
||
* anywhere on the panel dismisses the keyboard entirely, same as any
|
||
* other IME's own hide gesture.
|
||
*/
|
||
internal class SwipeModeContainer(
|
||
context: android.content.Context,
|
||
// Fraction of the container's width, measured from the left edge,
|
||
// where a downward drag never triggers dismiss — the stroke panel's
|
||
// punctuation rail lives there and owns vertical drags itself
|
||
// (cycling punctuation groups). Without this, a clean vertical drag
|
||
// starting on the rail was won by this container's own intercept
|
||
// check on the very same touch-move event, before the rail (a
|
||
// descendant, checked only after this ancestor) ever got a chance
|
||
// to call requestDisallowInterceptTouchEvent — so its own "stop the
|
||
// outer container" call never ran in time. Excluding the zone by
|
||
// touch-down position sidesteps that dispatch-order race entirely.
|
||
private val verticalDismissExclusionRatio: Float = 0f,
|
||
// Off for the clipboard history sub-panel: its rows use left/right
|
||
// swipes themselves (favorite / add correction rule), and trying to
|
||
// exclude just their region would hit the exact same dispatch-order
|
||
// race noted above for the punctuation rail — simplest to just not
|
||
// compete for horizontal drags at all inside that sub-panel.
|
||
private val horizontalSwipeEnabled: Boolean = true,
|
||
// Checked once, at the moment a drag first looks vertical enough to
|
||
// possibly claim (see onInterceptTouchEvent's ACTION_MOVE branch) —
|
||
// returning false there lets the gesture fall through to a nested
|
||
// scrollable child instead (e.g. the clipboard history list), so a
|
||
// downward drag only dismisses the keyboard once that child is
|
||
// already scrolled to its own top, the same "pull past the top to
|
||
// dismiss" convention most scrollable sheets use. Defaults to
|
||
// "always allowed" for every panel that has no such nested scroll.
|
||
private val verticalDismissAllowed: () -> Boolean = { true },
|
||
private val onSwipe: (Int) -> Unit,
|
||
) : LinearLayout(context) {
|
||
private var startX = 0f
|
||
private var startY = 0f
|
||
private var interceptingHorizontal = false
|
||
private var interceptingVertical = false
|
||
private var verticalDismissBlockedForGesture = false
|
||
private val touchSlop = android.view.ViewConfiguration.get(context).scaledTouchSlop
|
||
private val dismissThreshold = (120 * resources.displayMetrics.density).toInt()
|
||
// Committing an actual mode switch needs a much bigger, deliberate
|
||
// drag than the touchSlop-based early claim above (which only
|
||
// exists so a vertical scroll elsewhere doesn't get mistaken for a
|
||
// mode swipe partway through) — a fraction of the panel's own width
|
||
// rather than a fixed dp figure, so the required drag scales with
|
||
// the device instead of feeling different on a small vs. large
|
||
// screen. Computed against `width` at commit time (onTouchEvent),
|
||
// not here at construction, since the view isn't laid out yet.
|
||
private val commitThresholdFraction = 1f / 3f
|
||
|
||
init {
|
||
excludeFromSystemGestures(this)
|
||
}
|
||
|
||
// InputMethodService.setInputView() re-wraps whatever view we return
|
||
// in its OWN FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT),
|
||
// discarding the fixed-height LayoutParams every panel builder sets
|
||
// on its root — confirmed via a live device: the attached root's
|
||
// own layoutParams.height read back as WRAP_CONTENT (-2), not the
|
||
// configured height we set. Under a generous (near-fullscreen)
|
||
// WRAP_CONTENT/AT_MOST measure spec from the system, any 0dp/weight=1
|
||
// flexible child (the spacer used by several panels) happily expands
|
||
// to fill that huge bound instead of the real configured height.
|
||
// Forcing an EXACTLY height spec here — regardless of what the parent
|
||
// asks for — makes every panel's height genuinely fixed instead of
|
||
// accidental.
|
||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||
val fixedHeight = panelHeightPx(context)
|
||
val exactHeightSpec = android.view.View.MeasureSpec.makeMeasureSpec(fixedHeight, android.view.View.MeasureSpec.EXACTLY)
|
||
super.onMeasure(widthMeasureSpec, exactHeightSpec)
|
||
}
|
||
|
||
override fun onInterceptTouchEvent(ev: android.view.MotionEvent): Boolean {
|
||
when (ev.actionMasked) {
|
||
android.view.MotionEvent.ACTION_DOWN -> {
|
||
startX = ev.x
|
||
startY = ev.y
|
||
interceptingHorizontal = false
|
||
interceptingVertical = false
|
||
verticalDismissBlockedForGesture = verticalDismissExclusionRatio > 0f &&
|
||
ev.x < width * verticalDismissExclusionRatio
|
||
}
|
||
android.view.MotionEvent.ACTION_MOVE -> {
|
||
val dx = ev.x - startX
|
||
val dy = ev.y - startY
|
||
// Require a clearly horizontal drag so this never steals a
|
||
// vertical gesture meant for a nested SwipeRail.
|
||
if (horizontalSwipeEnabled && !interceptingHorizontal && !interceptingVertical &&
|
||
kotlin.math.abs(dx) > touchSlop && kotlin.math.abs(dx) > kotlin.math.abs(dy) * 1.5f
|
||
) {
|
||
interceptingHorizontal = true
|
||
parent?.requestDisallowInterceptTouchEvent(true)
|
||
}
|
||
// A downward drag anywhere not already claimed by a
|
||
// nested vertical gesture (the candidate scroll) or
|
||
// excluded by touch-down position (the punctuation rail)
|
||
// dismisses the keyboard — unless verticalDismissAllowed()
|
||
// says a nested scrollable child still has room to
|
||
// scroll up first (the clipboard history list).
|
||
if (!interceptingHorizontal && !interceptingVertical && !verticalDismissBlockedForGesture &&
|
||
dy > touchSlop && dy > kotlin.math.abs(dx) * 1.5f && verticalDismissAllowed()
|
||
) {
|
||
interceptingVertical = true
|
||
parent?.requestDisallowInterceptTouchEvent(true)
|
||
}
|
||
}
|
||
}
|
||
return interceptingHorizontal || interceptingVertical
|
||
}
|
||
|
||
override fun onTouchEvent(event: android.view.MotionEvent): Boolean {
|
||
if (event.actionMasked == android.view.MotionEvent.ACTION_UP ||
|
||
event.actionMasked == android.view.MotionEvent.ACTION_CANCEL
|
||
) {
|
||
val dx = event.x - startX
|
||
val dy = event.y - startY
|
||
if (interceptingVertical && dy > dismissThreshold) {
|
||
post { (context as? OpenLessImeService)?.hideKeyboardPanel() }
|
||
} else if (kotlin.math.abs(dx) > width * commitThresholdFraction) {
|
||
// Swiping left (finger moves toward the start, dx < 0)
|
||
// steps toward Voice; swiping right steps toward
|
||
// English. (This is inverted from the raw dx sign — on-
|
||
// device testing showed dx < 0 landing on English, so
|
||
// the mapping below matches what actually happens rather
|
||
// than the "obvious" sign.)
|
||
// Posted for the same reason as SwipeRail: avoid
|
||
// rebuilding the input view synchronously mid-gesture.
|
||
post { onSwipe(if (dx < 0) 1 else -1) }
|
||
}
|
||
interceptingHorizontal = false
|
||
interceptingVertical = false
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
|
||
private class ModeToggle(
|
||
context: android.content.Context,
|
||
private val selectedMode: InputMode,
|
||
private val darkTheme: Boolean,
|
||
private val onModeSelected: (InputMode) -> Unit,
|
||
) : View(context) {
|
||
// Instance properties, not companion constants — this view is
|
||
// rebuilt on every panel refresh, so a live theme switch just means
|
||
// a fresh instance with the other branch's colors.
|
||
private val trackColor = if (darkTheme) Color.rgb(28, 28, 28) else Color.rgb(222, 222, 226)
|
||
// Weakened from a fully-opaque line to a near-invisible one — the
|
||
// floating selected pill below is meant to be the only thing the
|
||
// user reads as "current mode", not these dividers, but the layout
|
||
// math that positions the four icons/labels still uses the same
|
||
// segmentWidth split, so the dividers stay (very faintly) for
|
||
// continuity rather than being deleted outright.
|
||
private val dividerColor = run {
|
||
val base = if (darkTheme) Color.rgb(58, 58, 58) else Color.rgb(200, 200, 204)
|
||
Color.argb(28, Color.red(base), Color.green(base), Color.blue(base))
|
||
}
|
||
// Same surface color as every ordinary key's own background
|
||
// (roundedButton(tone(Color.rgb(52, 52, 54), Color.WHITE), ...) —
|
||
// see buildEnglishCharKey()/keyboardKey()) — reusing it here ties
|
||
// the selected pill into the keyboard's existing "raised key"
|
||
// visual language instead of introducing a new floating-pill color.
|
||
private val selectedPillColor = if (darkTheme) Color.rgb(52, 52, 54) else Color.WHITE
|
||
private val iconColor = if (darkTheme) Color.rgb(240, 240, 240) else Color.rgb(50, 50, 54)
|
||
// Unselected icons/labels dim slightly (not a color or hue change)
|
||
// so the fully-opaque selected one reads as the one thing actively
|
||
// "on" — a light touch, not the kind of visible recolor the spec
|
||
// asks not to introduce.
|
||
private val dimmedIconColor = Color.argb(178, Color.red(iconColor), Color.green(iconColor), Color.blue(iconColor))
|
||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
strokeCap = Paint.Cap.ROUND
|
||
strokeJoin = Paint.Join.ROUND
|
||
}
|
||
// Labels are drawn at a size derived from the view height, not the
|
||
// system text size, so they never shift with font scale or UI language.
|
||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = iconColor
|
||
typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD)
|
||
}
|
||
private val textBounds = android.graphics.Rect()
|
||
private val chevronPath = Path()
|
||
|
||
// Continuous [0, modes.size - 1] position of the selected pill, in
|
||
// segment units — settles on the selected index, but slides through
|
||
// fractional values mid-animation. This view is a fresh instance on
|
||
// every panel rebuild (see the class doc above), so a real mode
|
||
// switch is detected by comparing against lastRenderedMode (a
|
||
// companion field, surviving across those rebuilds) rather than any
|
||
// state this instance could hold itself; an unrelated rebuild that
|
||
// leaves the mode unchanged just settles here with no animation.
|
||
private var indicatorPosition: Float
|
||
private var indicatorAnimator: android.animation.ValueAnimator? = null
|
||
|
||
init {
|
||
val modes = InputMode.entries
|
||
val targetIndex = modes.indexOf(selectedMode).coerceAtLeast(0).toFloat()
|
||
val previousMode = lastRenderedMode
|
||
if (previousMode != null && previousMode != selectedMode) {
|
||
val startIndex = modes.indexOf(previousMode).coerceAtLeast(0).toFloat()
|
||
indicatorPosition = startIndex
|
||
indicatorAnimator = android.animation.ValueAnimator.ofFloat(startIndex, targetIndex).apply {
|
||
duration = 180L
|
||
interpolator = android.view.animation.DecelerateInterpolator()
|
||
addUpdateListener {
|
||
indicatorPosition = it.animatedValue as Float
|
||
invalidate()
|
||
}
|
||
start()
|
||
}
|
||
} else {
|
||
indicatorPosition = targetIndex
|
||
}
|
||
lastRenderedMode = selectedMode
|
||
}
|
||
|
||
override fun onDetachedFromWindow() {
|
||
indicatorAnimator?.cancel()
|
||
super.onDetachedFromWindow()
|
||
}
|
||
|
||
// Every proportion below is measured off the reference toggle design
|
||
// and expressed in units of the pill height `h`.
|
||
override fun onDraw(canvas: Canvas) {
|
||
super.onDraw(canvas)
|
||
val modes = InputMode.entries
|
||
val h = height.toFloat()
|
||
val w = width.toFloat()
|
||
val segmentWidth = w / modes.size
|
||
val centerY = h / 2f
|
||
|
||
paint.style = Paint.Style.FILL
|
||
paint.color = trackColor
|
||
canvas.drawRoundRect(0f, 0f, w, h, h / 2f, h / 2f, paint)
|
||
|
||
paint.color = dividerColor
|
||
paint.strokeWidth = h * 0.012f
|
||
for (i in 1 until modes.size) {
|
||
val x = segmentWidth * i
|
||
canvas.drawLine(x, h * 0.2f, x, h * 0.8f, paint)
|
||
}
|
||
|
||
// Floating rounded pill instead of the old sharp, edge-to-edge
|
||
// highlight: inset on every side so it reads as its own raised
|
||
// surface hovering over the track rather than a panel-colored
|
||
// cutout flush with the track's shape. Insets alone keep it
|
||
// well inside the track's rounded corners even at the first/
|
||
// last segment, so no extra clip path is needed the way the
|
||
// sharp-cornered version required one.
|
||
// Proportional again, not fixed dp — carries forward the exact
|
||
// ratio the last explicit dp spec settled on (5dp inset / 38dp
|
||
// track height, 53dp pill / 60dp segment width at the 240dp
|
||
// outer track width in use then), so the pill keeps this same
|
||
// look if the outer track's own width/height are ever tuned
|
||
// again, instead of needing a matching manual dp recompute.
|
||
val pillInsetV = h * (5f / 38f)
|
||
val pillWidth = segmentWidth * (53f / 60f)
|
||
val pillCenterX = segmentWidth * (indicatorPosition + 0.5f)
|
||
val pillLeft = pillCenterX - pillWidth / 2f
|
||
val pillRight = pillCenterX + pillWidth / 2f
|
||
val pillTop = pillInsetV
|
||
val pillBottom = h - pillInsetV
|
||
paint.color = selectedPillColor
|
||
canvas.drawRoundRect(pillLeft, pillTop, pillRight, pillBottom, (pillBottom - pillTop) / 2f, (pillBottom - pillTop) / 2f, paint)
|
||
|
||
val selectedIndex = modes.indexOf(selectedMode).coerceAtLeast(0)
|
||
fun colorFor(index: Int) = if (index == selectedIndex) iconColor else dimmedIconColor
|
||
|
||
paint.color = colorFor(0)
|
||
drawWaveform(canvas, segmentWidth * 0.5f, centerY, h)
|
||
textPaint.color = colorFor(1)
|
||
drawLabel(canvas, "笔画", segmentWidth * 1.5f, centerY, h * 0.38f, extraBold = true)
|
||
paint.color = colorFor(2)
|
||
drawCursorBrackets(canvas, segmentWidth * 2.5f, centerY, h)
|
||
textPaint.color = colorFor(3)
|
||
drawLabel(canvas, "EN", segmentWidth * 3.5f, centerY, h * 0.28f)
|
||
}
|
||
|
||
/** Five bars — short, mid, tall, mid, short. */
|
||
private fun drawWaveform(canvas: Canvas, centerX: Float, centerY: Float, h: Float) {
|
||
paint.strokeWidth = h * 0.033f
|
||
val spacing = h * 0.11f
|
||
floatArrayOf(0.078f, 0.153f, 0.225f, 0.153f, 0.078f).forEachIndexed { index, half ->
|
||
val x = centerX + (index - 2) * spacing
|
||
canvas.drawLine(x, centerY - h * half, x, centerY + h * half, paint)
|
||
}
|
||
}
|
||
|
||
/** "<I>": a bold capital-I beam (with top/bottom serifs) flanked by chevrons nearly as tall as it. */
|
||
private fun drawCursorBrackets(canvas: Canvas, centerX: Float, centerY: Float, h: Float) {
|
||
// Overall extent (openX/tipX/halfH below) is unchanged from the
|
||
// plain-bar version, so the icon's total width/height stays put
|
||
// even though the center beam is now a thicker "I" with serifs.
|
||
val barHalfH = h * 0.16f
|
||
val serifHalfW = h * 0.05f
|
||
paint.strokeWidth = h * 0.045f
|
||
canvas.drawLine(centerX, centerY - barHalfH, centerX, centerY + barHalfH, paint)
|
||
canvas.drawLine(centerX - serifHalfW, centerY - barHalfH, centerX + serifHalfW, centerY - barHalfH, paint)
|
||
canvas.drawLine(centerX - serifHalfW, centerY + barHalfH, centerX + serifHalfW, centerY + barHalfH, paint)
|
||
|
||
paint.strokeWidth = h * 0.03f
|
||
val openX = h * 0.14f
|
||
val tipX = h * 0.289f
|
||
val halfH = h * 0.143f
|
||
paint.style = Paint.Style.STROKE
|
||
chevronPath.reset()
|
||
chevronPath.moveTo(centerX - openX, centerY - halfH)
|
||
chevronPath.lineTo(centerX - tipX, centerY)
|
||
chevronPath.lineTo(centerX - openX, centerY + halfH)
|
||
chevronPath.moveTo(centerX + openX, centerY - halfH)
|
||
chevronPath.lineTo(centerX + tipX, centerY)
|
||
chevronPath.lineTo(centerX + openX, centerY + halfH)
|
||
canvas.drawPath(chevronPath, paint)
|
||
paint.style = Paint.Style.FILL
|
||
}
|
||
|
||
/** Bold label scaled so its actual ink height (not the font's line height) equals [inkHeight]. */
|
||
private fun drawLabel(canvas: Canvas, label: String, centerX: Float, centerY: Float, inkHeight: Float, extraBold: Boolean = false) {
|
||
textPaint.isFakeBoldText = extraBold
|
||
textPaint.textSize = 100f
|
||
textPaint.getTextBounds(label, 0, label.length, textBounds)
|
||
textPaint.textSize = 100f * inkHeight / textBounds.height().coerceAtLeast(1)
|
||
textPaint.getTextBounds(label, 0, label.length, textBounds)
|
||
canvas.drawText(label, centerX - textBounds.exactCenterX(), centerY - textBounds.exactCenterY(), textPaint)
|
||
}
|
||
|
||
override fun onTouchEvent(event: android.view.MotionEvent): Boolean {
|
||
if (event.action == android.view.MotionEvent.ACTION_UP) {
|
||
val modes = InputMode.entries
|
||
val index = (event.x / (width / modes.size.toFloat())).toInt().coerceIn(0, modes.lastIndex)
|
||
onModeSelected(modes[index])
|
||
}
|
||
return true
|
||
}
|
||
|
||
private companion object {
|
||
// Survives across this view's per-rebuild instances (see the
|
||
// class doc above) so a fresh instance can tell "the mode
|
||
// actually changed since last drawn" from "this rebuild is for
|
||
// some unrelated reason" and only animate the former.
|
||
@Volatile
|
||
private var lastRenderedMode: InputMode? = null
|
||
}
|
||
}
|
||
|
||
/** Central stroke keys use a canvas glyph so their proportions do not depend on a font. */
|
||
private class StrokeGlyphView(
|
||
context: android.content.Context,
|
||
private val glyphCode: String,
|
||
darkTheme: Boolean,
|
||
) : TextView(context) {
|
||
private val iconColor = if (darkTheme) Color.rgb(232, 232, 232) else Color.rgb(40, 40, 44)
|
||
private val mutedColor = if (darkTheme) Color.rgb(155, 155, 155) else Color.rgb(130, 130, 135)
|
||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = iconColor
|
||
textAlign = Paint.Align.CENTER
|
||
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.NORMAL)
|
||
}
|
||
|
||
override fun onDraw(canvas: Canvas) {
|
||
val unit = minOf(width, height).coerceAtLeast(1) / 100f
|
||
val x = width / 2f
|
||
paint.style = Paint.Style.FILL
|
||
paint.textSize = 19f * unit
|
||
val text = when (glyphCode) {
|
||
"*" -> "通配"
|
||
" " -> "分词"
|
||
":" -> ":"
|
||
";" -> ";"
|
||
"symbols" -> "符号"
|
||
"script" -> "繁"
|
||
else -> glyphCode
|
||
}
|
||
val top = when (glyphCode) {
|
||
"*" -> "6"
|
||
" " -> "7"
|
||
":" -> "8"
|
||
";" -> "9"
|
||
else -> ""
|
||
}
|
||
if (top.isNotEmpty()) {
|
||
paint.color = mutedColor
|
||
paint.textSize = 20f * unit
|
||
// Matches the "0" key's TextView-rendered top-gravity number, which
|
||
// sits lower than this baseline-based canvas position implied.
|
||
canvas.drawText(top, x, 32f * unit, paint)
|
||
paint.color = iconColor
|
||
}
|
||
paint.textSize = if (text in listOf("符号", "通配", "分词", "繁")) 33f * unit else 27f * unit
|
||
canvas.drawText(text, x, if (top.isEmpty()) 61f * unit else 76f * unit, paint)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Red actions are also custom-drawn to keep the reference glyph geometry
|
||
* stable. Takes an explicit icon color rather than a theme flag: this
|
||
* view is reused both on the always-dark-red action keys (icon always
|
||
* white, in both themes) and, undecorated, on the clipboard panel's
|
||
* normal/highlighted keys (icon needs to flip with the theme there).
|
||
*/
|
||
internal class StrokeActionView(
|
||
context: android.content.Context,
|
||
private val actionCode: String,
|
||
private val iconRotation: Float = 0f,
|
||
private val iconColor: Int = Color.WHITE,
|
||
) : TextView(context) {
|
||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = iconColor
|
||
style = Paint.Style.STROKE
|
||
strokeWidth = 5.2f
|
||
strokeCap = Paint.Cap.SQUARE
|
||
strokeJoin = Paint.Join.MITER
|
||
}
|
||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = iconColor
|
||
textAlign = Paint.Align.CENTER
|
||
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.NORMAL)
|
||
}
|
||
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = iconColor
|
||
style = Paint.Style.FILL
|
||
}
|
||
|
||
override fun onDraw(canvas: Canvas) {
|
||
val u = minOf(width, height).coerceAtLeast(1) / 100f
|
||
val cx = width / 2f
|
||
val cy = height / 2f
|
||
paint.strokeWidth = 5.2f * u
|
||
when (actionCode) {
|
||
"triangle-down" -> {
|
||
// Fixed absolute size, independent of this button's own
|
||
// (roughly 28x36dp, non-square) cell — about the same
|
||
// visual footprint as a single candidate glyph
|
||
// (candidateItemView's 20sp text), not scaled to the
|
||
// fixed-100-unit icon canvas the other cases share.
|
||
val triangleSize = 16f * resources.displayMetrics.density
|
||
val topY = cy - triangleSize / 2f
|
||
val path = Path().apply {
|
||
moveTo(cx - triangleSize / 2f, topY)
|
||
lineTo(cx + triangleSize / 2f, topY)
|
||
lineTo(cx, topY + triangleSize)
|
||
close()
|
||
}
|
||
canvas.drawPath(path, fillPaint)
|
||
}
|
||
"←" -> {
|
||
canvas.drawLine(cx - 23f * u, cy, cx + 23f * u, cy, paint)
|
||
canvas.drawLine(cx - 23f * u, cy, cx - 8f * u, cy - 12f * u, paint)
|
||
canvas.drawLine(cx - 23f * u, cy, cx - 8f * u, cy + 12f * u, paint)
|
||
}
|
||
"↵" -> {
|
||
val path = Path().apply {
|
||
moveTo(cx + 23f * u, cy - 14f * u)
|
||
lineTo(cx + 23f * u, cy + 5f * u)
|
||
quadTo(cx + 23f * u, cy + 15f * u, cx + 13f * u, cy + 15f * u)
|
||
lineTo(cx - 22f * u, cy + 15f * u)
|
||
}
|
||
canvas.drawPath(path, paint)
|
||
canvas.drawLine(cx - 22f * u, cy + 15f * u, cx - 10f * u, cy + 5f * u, paint)
|
||
canvas.drawLine(cx - 22f * u, cy + 15f * u, cx - 10f * u, cy + 25f * u, paint)
|
||
}
|
||
"dir-up", "backspace-icon" -> {
|
||
// Fixed 32dp icon canvas (not scaled to the button's own,
|
||
// possibly larger, size) so all four rotated direction
|
||
// keys and the backspace key render at one identical
|
||
// visual size no matter how the grid divides the row.
|
||
// Rotating the canvas (not the whole View) keeps the
|
||
// button's own rectangular background undistorted even
|
||
// when the cell itself isn't perfectly square.
|
||
val iconUnit = (32 * resources.displayMetrics.density) / 100f
|
||
paint.strokeWidth = 9f * iconUnit
|
||
paint.strokeCap = Paint.Cap.ROUND
|
||
paint.strokeJoin = Paint.Join.ROUND
|
||
canvas.save()
|
||
if (iconRotation != 0f) canvas.rotate(iconRotation, cx, cy)
|
||
if (actionCode == "dir-up") {
|
||
val chevron = Path().apply {
|
||
moveTo(cx - 22f * iconUnit, cy + 12f * iconUnit)
|
||
lineTo(cx, cy - 12f * iconUnit)
|
||
lineTo(cx + 22f * iconUnit, cy + 12f * iconUnit)
|
||
}
|
||
canvas.drawPath(chevron, paint)
|
||
} else {
|
||
// Classic backspace silhouette: a left-pointing tag
|
||
// outline with an "X" inside, same line weight as the
|
||
// direction arrows.
|
||
val outline = Path().apply {
|
||
moveTo(cx - 26f * iconUnit, cy)
|
||
lineTo(cx - 8f * iconUnit, cy - 22f * iconUnit)
|
||
lineTo(cx + 26f * iconUnit, cy - 22f * iconUnit)
|
||
lineTo(cx + 26f * iconUnit, cy + 22f * iconUnit)
|
||
lineTo(cx - 8f * iconUnit, cy + 22f * iconUnit)
|
||
close()
|
||
}
|
||
canvas.drawPath(outline, paint)
|
||
canvas.drawLine(cx - 2f * iconUnit, cy - 10f * iconUnit, cx + 16f * iconUnit, cy + 10f * iconUnit, paint)
|
||
canvas.drawLine(cx + 16f * iconUnit, cy - 10f * iconUnit, cx - 2f * iconUnit, cy + 10f * iconUnit, paint)
|
||
}
|
||
canvas.restore()
|
||
}
|
||
else -> {
|
||
textPaint.textSize = if (actionCode == "清除") 32f * u else 30f * u
|
||
canvas.drawText(actionCode, cx, cy - (textPaint.ascent() + textPaint.descent()) / 2f, textPaint)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private class StrokeKeyView(
|
||
context: android.content.Context,
|
||
private val strokeCode: String,
|
||
darkTheme: Boolean,
|
||
) : TextView(context) {
|
||
private val numberPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = if (darkTheme) Color.rgb(155, 155, 155) else Color.rgb(130, 130, 135)
|
||
textAlign = Paint.Align.CENTER
|
||
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.NORMAL)
|
||
}
|
||
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = if (darkTheme) Color.rgb(232, 232, 232) else Color.rgb(40, 40, 44)
|
||
style = Paint.Style.STROKE
|
||
strokeCap = Paint.Cap.ROUND
|
||
strokeJoin = Paint.Join.ROUND
|
||
}
|
||
|
||
override fun onDraw(canvas: Canvas) {
|
||
val unit = minOf(width, height).coerceAtLeast(1) / 100f
|
||
val centerX = width / 2f
|
||
val topNumber = when (strokeCode) {
|
||
"h" -> "1"
|
||
"s" -> "2"
|
||
"p" -> "3"
|
||
"n" -> "4"
|
||
else -> "5"
|
||
}
|
||
numberPaint.textSize = 20f * unit
|
||
// Matches the "0" key's TextView-rendered top-gravity number, which
|
||
// sits lower than this baseline-based canvas position implied.
|
||
canvas.drawText(topNumber, centerX, 32f * unit, numberPaint)
|
||
|
||
strokePaint.strokeWidth = 3.2f * unit
|
||
val stroke = Path()
|
||
when (strokeCode) {
|
||
"h" -> {
|
||
stroke.moveTo(centerX - 20f * unit, 62f * unit)
|
||
stroke.lineTo(centerX + 20f * unit, 62f * unit)
|
||
}
|
||
"s" -> {
|
||
// Top trimmed by 1/6 of the original 42–79 length (bottom
|
||
// unchanged) at the user's request: 42 + (79-42)/6 ≈ 48.17.
|
||
stroke.moveTo(centerX, 48.17f * unit)
|
||
stroke.lineTo(centerX, 79f * unit)
|
||
}
|
||
"p" -> {
|
||
// Traced from the reference glyph: straight down for most
|
||
// of the stroke, hooking left only at the very end — a "J"
|
||
// shape, not a curve along its whole length. Top trimmed
|
||
// by 1/6 of the original 38–77 length (bottom/control
|
||
// points unchanged): 38 + (77-38)/6 = 44.5.
|
||
stroke.moveTo(centerX - 2f * unit, 44.5f * unit)
|
||
stroke.cubicTo(
|
||
centerX - 2f * unit, 55f * unit,
|
||
centerX - 5f * unit, 68f * unit,
|
||
centerX - 16f * unit, 77f * unit,
|
||
)
|
||
}
|
||
"n" -> {
|
||
// Traced from the reference glyph: a short, straight tick,
|
||
// not a long curve — 丶 is a dot/dian, not a swooping line.
|
||
stroke.moveTo(centerX - 7f * unit, 50f * unit)
|
||
stroke.lineTo(centerX + 6f * unit, 67f * unit)
|
||
}
|
||
else -> {
|
||
// Traced from the reference glyph: a diagonal down-left to
|
||
// a corner, then a horizontal finish to the right (乙/横折).
|
||
// Top trimmed by 1/6 of the original 42–71 diagonal length
|
||
// (bottom unchanged): 42 + (71-42)/6 ≈ 46.83.
|
||
stroke.moveTo(centerX + 1f * unit, 46.83f * unit)
|
||
stroke.lineTo(centerX - 13f * unit, 71f * unit)
|
||
stroke.lineTo(centerX + 15f * unit, 71f * unit)
|
||
}
|
||
}
|
||
canvas.drawPath(stroke, strokePaint)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Two-line label (via a plain "\n") with a thin horizontal divider
|
||
* drawn between the lines — the divider just needs the vertical
|
||
* midpoint of the view, which lands between the two centered lines of
|
||
* text closely enough without measuring actual text layout bounds.
|
||
*/
|
||
/**
|
||
* The one key-press preview bubble shared by every keyboard (English
|
||
* and stroke) — a short, wide "floating keycap": a rounded rect with a
|
||
* short stubby arrow, soft light-gray body and near-black text, no
|
||
* accent color. Always sized/positioned by its owner (KeyPreviewOverlay)
|
||
* rather than by itself; this class only draws. Plain Canvas drawing,
|
||
* matching every other custom key glyph in this file (ShiftKeyView,
|
||
* ActionSymbolView, etc.) — no new dependency.
|
||
*/
|
||
private class KeyPreviewBubbleView(
|
||
context: android.content.Context,
|
||
darkTheme: Boolean,
|
||
) : View(context) {
|
||
var label: String = ""
|
||
set(value) {
|
||
field = value
|
||
invalidate()
|
||
}
|
||
// Non-null (the stroke keyboard's swipe-up digit preview) matches
|
||
// the actual pressed key's own font size (TextView.getTextSize(),
|
||
// already resolved to raw pixels, so no sp->px conversion is
|
||
// needed here) instead of the default size derived from the
|
||
// bubble's own height, which the English tap preview still relies on.
|
||
var fixedTextSizePx: Float? = null
|
||
set(value) {
|
||
field = value
|
||
invalidate()
|
||
}
|
||
private val bodyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
// Lighter than the stroke encode/candidate row's own card
|
||
// background in both themes (buildEncodeAreaBackground():
|
||
// 58,58,58 dark / 228,228,232 light) rather than darker — a
|
||
// darker-than-the-card bubble read as low-contrast/blended-in;
|
||
// sitting a step lighter than that card makes the bubble read
|
||
// as its own raised surface instead.
|
||
color = if (darkTheme) Color.rgb(96, 96, 100) else Color.rgb(246, 246, 249)
|
||
}
|
||
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
style = Paint.Style.STROKE
|
||
strokeWidth = 2f
|
||
// Dark theme: a lighter outline than the body for definition
|
||
// against a similarly-dark panel. Light theme: a slightly
|
||
// darker outline than the now near-white body, since a lighter
|
||
// outline there would have no contrast against anything.
|
||
color = if (darkTheme) Color.rgb(128, 128, 133) else Color.rgb(210, 210, 215)
|
||
}
|
||
// Same red as the right-hand action rail's ←/↵/清除/123 keys and
|
||
// the stroke candidate row's own selected/first candidate
|
||
// (candidateItemView()), brightened for dark theme by the same
|
||
// amount as candidateItemView()'s own dark-theme branch — the two
|
||
// stay in sync since they're tuned together.
|
||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = if (darkTheme) Color.rgb(190, 45, 60) else Color.rgb(153, 26, 40)
|
||
textAlign = Paint.Align.CENTER
|
||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||
// FILL_AND_STROKE over DEFAULT_BOLD's own fill adds a touch more
|
||
// weight than the typeface alone — there's no heavier built-in
|
||
// weight to switch to without bundling a custom font.
|
||
style = Paint.Style.FILL_AND_STROKE
|
||
strokeWidth = 1.4f
|
||
}
|
||
private val outline = Path()
|
||
|
||
private fun dp(value: Int): Float = value * resources.displayMetrics.density
|
||
|
||
override fun onDraw(canvas: Canvas) {
|
||
super.onDraw(canvas)
|
||
val w = width.toFloat()
|
||
val h = height.toFloat()
|
||
if (w <= 0f || h <= 0f) return
|
||
val arrowHeight = dp(ARROW_HEIGHT_DP)
|
||
val arrowHalfWidth = dp(ARROW_WIDTH_DP) / 2f
|
||
val bodyBottom = h - arrowHeight
|
||
// Also capped by the flat run left on either side of the arrow
|
||
// notch (w/2 - arrowHalfWidth) — at the narrowest bubble widths
|
||
// this is tighter than bodyBottom/2, and skipping it would let
|
||
// the bottom-corner arcs overrun the notch and self-intersect.
|
||
val radius = dp(CORNER_RADIUS_DP)
|
||
.coerceAtMost(bodyBottom / 2f)
|
||
.coerceAtMost((w / 2f - arrowHalfWidth).coerceAtLeast(0f))
|
||
val cx = w / 2f
|
||
// One continuous outline (all four corners rounded to the same
|
||
// radius, short triangular notch at the bottom center) so a
|
||
// stroked border never shows a seam where the arrow meets the
|
||
// body.
|
||
outline.reset()
|
||
outline.moveTo(radius, 0f)
|
||
outline.lineTo(w - radius, 0f)
|
||
outline.arcTo(android.graphics.RectF(w - 2f * radius, 0f, w, 2f * radius), -90f, 90f)
|
||
outline.lineTo(w, bodyBottom - radius)
|
||
outline.arcTo(android.graphics.RectF(w - 2f * radius, bodyBottom - 2f * radius, w, bodyBottom), 0f, 90f)
|
||
outline.lineTo(cx + arrowHalfWidth, bodyBottom)
|
||
outline.lineTo(cx, h)
|
||
outline.lineTo(cx - arrowHalfWidth, bodyBottom)
|
||
outline.lineTo(radius, bodyBottom)
|
||
outline.arcTo(android.graphics.RectF(0f, bodyBottom - 2f * radius, 2f * radius, bodyBottom), 90f, 90f)
|
||
outline.lineTo(0f, radius)
|
||
outline.arcTo(android.graphics.RectF(0f, 0f, 2f * radius, 2f * radius), 180f, 90f)
|
||
outline.close()
|
||
canvas.drawPath(outline, bodyPaint)
|
||
canvas.drawPath(outline, borderPaint)
|
||
textPaint.textSize = fixedTextSizePx ?: (bodyBottom * 0.5f)
|
||
val metrics = textPaint.fontMetrics
|
||
val textY = bodyBottom / 2f - (metrics.ascent + metrics.descent) / 2f
|
||
canvas.drawText(label, cx, textY, textPaint)
|
||
}
|
||
|
||
companion object {
|
||
const val ARROW_HEIGHT_DP = 12
|
||
const val ARROW_WIDTH_DP = 22
|
||
const val CORNER_RADIUS_DP = 4
|
||
const val BODY_HEIGHT_DP = 48
|
||
const val TOTAL_HEIGHT_DP = BODY_HEIGHT_DP + ARROW_HEIGHT_DP
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Single top-level overlay hosting the one KeyPreviewBubbleView shared
|
||
* by every key in a panel (see wrapWithKeyPreviewOverlay()). Added as
|
||
* the last (topmost-drawn) child of the FrameLayout that wraps a
|
||
* panel's own root, so the bubble renders above the whole keyboard —
|
||
* unaffected by the panel's own clipChildren/candidate-row/mode-toggle
|
||
* bounds — without taking part in that panel's layout: it never
|
||
* changes size, position, or measurement of anything else, and it
|
||
* never intercepts touch (dispatchTouchEvent always returns false, so
|
||
* every gesture falls straight through to whatever key is underneath).
|
||
*/
|
||
private class KeyPreviewOverlay(
|
||
context: android.content.Context,
|
||
darkTheme: Boolean,
|
||
) : FrameLayout(context) {
|
||
private val bubble = KeyPreviewBubbleView(context, darkTheme).apply {
|
||
alpha = 0f
|
||
visibility = View.GONE
|
||
}
|
||
|
||
init {
|
||
isClickable = false
|
||
isFocusable = false
|
||
clipChildren = false
|
||
clipToPadding = false
|
||
addView(bubble, LayoutParams(0, 0))
|
||
}
|
||
|
||
// A pure visual layer: never claims a touch sequence, regardless of
|
||
// where the bubble is currently drawn, so a stroke swipe or an
|
||
// English drag-retarget already in progress is never disturbed by
|
||
// the preview appearing on top of it.
|
||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean = false
|
||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean = false
|
||
|
||
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
|
||
|
||
private fun relativeLocation(target: View): IntArray {
|
||
val targetLoc = IntArray(2)
|
||
target.getLocationOnScreen(targetLoc)
|
||
val selfLoc = IntArray(2)
|
||
getLocationOnScreen(selfLoc)
|
||
return intArrayOf(targetLoc[0] - selfLoc[0], targetLoc[1] - selfLoc[1])
|
||
}
|
||
|
||
private fun placeBubble(centerX: Int, top: Int, bubbleWidth: Int, bubbleHeight: Int) {
|
||
val maxLeft = (width - bubbleWidth - dp(4)).coerceAtLeast(dp(4))
|
||
val left = (centerX - bubbleWidth / 2).coerceIn(dp(4), maxLeft)
|
||
val clampedTop = top.coerceAtLeast(dp(2))
|
||
val params = bubble.layoutParams as LayoutParams
|
||
params.width = bubbleWidth
|
||
params.height = bubbleHeight
|
||
params.leftMargin = left
|
||
params.topMargin = clampedTop
|
||
bubble.layoutParams = params
|
||
}
|
||
|
||
/**
|
||
* TAP preview (English/number/symbol keys): centered directly above
|
||
* the anchor key, sized relative to it, with the default
|
||
* proportional-to-body text size (fixedTextSizePx left null).
|
||
*/
|
||
fun showTapPreview(text: String, anchor: View) {
|
||
val loc = relativeLocation(anchor)
|
||
val bubbleWidth = (anchor.width * BUBBLE_WIDTH_RATIO).toInt().coerceAtLeast(dp(40))
|
||
val bubbleHeight = dp(KeyPreviewBubbleView.TOTAL_HEIGHT_DP)
|
||
bubble.fixedTextSizePx = null
|
||
bubble.label = text
|
||
val centerX = loc[0] + anchor.width / 2
|
||
val top = loc[1] - bubbleHeight - dp(TAP_GAP_DP)
|
||
placeBubble(centerX, top, bubbleWidth, bubbleHeight)
|
||
show()
|
||
}
|
||
|
||
/**
|
||
* SWIPE preview (stroke digit swipe-up): pinned to the actual
|
||
* anchor key's own on-screen position, same as showTapPreview —
|
||
* not to the finger's current height. It used to float a fixed
|
||
* distance above the raw touch point instead, which looked right
|
||
* for a straight vertical swipe on one key but drifted away from
|
||
* the real key as soon as a drag retargeted sideways or across
|
||
* rows (the bubble's X snapped to the new key while its Y stayed
|
||
* tied to wherever the finger happened to be).
|
||
*
|
||
* Text size is a fixed constant here, not the tracked key's own
|
||
* face text size — the "0" digit's key is the microphone icon key,
|
||
* whose own face text is much smaller than every other digit key's
|
||
* (10sp vs 17sp, since it's mostly an icon), so sizing off the key
|
||
* made "0"'s bubble read noticeably smaller than the other nine.
|
||
*/
|
||
fun showSwipePreview(text: String, anchor: View) {
|
||
val loc = relativeLocation(anchor)
|
||
val bubbleWidth = (anchor.width * BUBBLE_WIDTH_RATIO).toInt().coerceAtLeast(dp(40))
|
||
val bubbleHeight = dp(KeyPreviewBubbleView.TOTAL_HEIGHT_DP)
|
||
bubble.fixedTextSizePx = dp(SWIPE_TEXT_SIZE_DP).toFloat()
|
||
bubble.label = text
|
||
val centerX = loc[0] + anchor.width / 2
|
||
// 20dp further above the key than the tap preview's own gap —
|
||
// a separate constant so this doesn't also shift
|
||
// showTapPreview's gap for the English/number keys.
|
||
val top = loc[1] - bubbleHeight - dp(SWIPE_GAP_DP)
|
||
placeBubble(centerX, top, bubbleWidth, bubbleHeight)
|
||
show()
|
||
}
|
||
|
||
// No fade animation: a fast tap's down-to-up gap is often shorter
|
||
// than any fade would take, so an animated show/hide left the
|
||
// bubble stuck mid-fade — either never reaching full opacity
|
||
// (looked "too transparent to read") or reversing before it ever
|
||
// became visible at all. Popping straight to alpha=1 (matching the
|
||
// pre-refactor PopupWindow, which never animated either)
|
||
// guarantees the bubble is fully opaque the instant it shows,
|
||
// regardless of tap speed.
|
||
private fun show() {
|
||
bubble.animate().cancel()
|
||
bubble.alpha = 1f
|
||
bubble.scaleX = 1f
|
||
bubble.scaleY = 1f
|
||
bubble.visibility = View.VISIBLE
|
||
}
|
||
|
||
fun hide() {
|
||
bubble.animate().cancel()
|
||
bubble.visibility = View.GONE
|
||
}
|
||
|
||
private companion object {
|
||
const val BUBBLE_WIDTH_RATIO = 1.2f
|
||
const val TAP_GAP_DP = 3
|
||
const val SWIPE_GAP_DP = TAP_GAP_DP + 20
|
||
// Bigger than the default proportional size the tap preview
|
||
// falls back to (bodyBottom * 0.5, ~24dp-equivalent here) and,
|
||
// being a flat constant rather than derived from any one key's
|
||
// own face size, the same for every digit including "0".
|
||
const val SWIPE_TEXT_SIZE_DP = 22
|
||
}
|
||
}
|
||
|
||
private class MidDividerTextView(
|
||
context: android.content.Context,
|
||
private val dividerColor: Int,
|
||
) : TextView(context) {
|
||
private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { strokeWidth = 1.5f }
|
||
|
||
override fun onDraw(canvas: Canvas) {
|
||
super.onDraw(canvas)
|
||
dividerPaint.color = dividerColor
|
||
val marginX = width * 0.22f
|
||
canvas.drawLine(marginX, height / 2f, width - marginX, height / 2f, dividerPaint)
|
||
}
|
||
}
|
||
|
||
private class ActionSymbolView(
|
||
context: android.content.Context,
|
||
private val symbol: String,
|
||
darkTheme: Boolean,
|
||
) : TextView(context) {
|
||
private val symbolPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = if (darkTheme) Color.WHITE else Color.rgb(30, 30, 34)
|
||
style = Paint.Style.STROKE
|
||
strokeCap = Paint.Cap.SQUARE
|
||
strokeJoin = Paint.Join.ROUND
|
||
}
|
||
|
||
override fun onDraw(canvas: Canvas) {
|
||
val unit = minOf(width, height).coerceAtLeast(1) / 100f
|
||
val centerX = width / 2f
|
||
val centerY = height / 2f
|
||
symbolPaint.strokeWidth = 5.5f * unit
|
||
if (symbol == "←") {
|
||
val left = centerX - 27f * unit
|
||
val right = centerX + 27f * unit
|
||
canvas.drawLine(left + 13f * unit, centerY, right, centerY, symbolPaint)
|
||
val arrow = Path().apply {
|
||
moveTo(left + 13f * unit, centerY)
|
||
lineTo(left + 27f * unit, centerY - 12f * unit)
|
||
moveTo(left + 13f * unit, centerY)
|
||
lineTo(left + 27f * unit, centerY + 12f * unit)
|
||
}
|
||
canvas.drawPath(arrow, symbolPaint)
|
||
} else {
|
||
val path = Path().apply {
|
||
moveTo(centerX + 29f * unit, centerY - 14f * unit)
|
||
lineTo(centerX + 29f * unit, centerY + 8f * unit)
|
||
lineTo(centerX + 21f * unit, centerY + 18f * unit)
|
||
lineTo(centerX + 7f * unit, centerY + 18f * unit)
|
||
lineTo(centerX - 25f * unit, centerY + 18f * unit)
|
||
}
|
||
canvas.drawPath(path, symbolPaint)
|
||
val arrow = Path().apply {
|
||
moveTo(centerX - 25f * unit, centerY + 18f * unit)
|
||
lineTo(centerX - 12f * unit, centerY + 8f * unit)
|
||
moveTo(centerX - 25f * unit, centerY + 18f * unit)
|
||
lineTo(centerX - 12f * unit, centerY + 28f * unit)
|
||
}
|
||
canvas.drawPath(arrow, symbolPaint)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The English keyboard's shift key draws one of three distinct icons so
|
||
* its state is visible at a glance: a hollow arrow (lowercase), a solid
|
||
* arrow (capitalize the next letter only), or a solid arrow with an
|
||
* underline bar (caps lock — every letter is capitalized until toggled
|
||
* off again).
|
||
*/
|
||
private class ShiftKeyView(
|
||
context: android.content.Context,
|
||
private val state: ShiftState,
|
||
private val darkTheme: Boolean,
|
||
) : TextView(context) {
|
||
private val mutedColor = if (darkTheme) Color.rgb(190, 190, 190) else Color.rgb(120, 120, 125)
|
||
private val activeColor = if (darkTheme) Color.rgb(245, 245, 245) else Color.rgb(30, 30, 34)
|
||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
strokeJoin = Paint.Join.ROUND
|
||
strokeCap = Paint.Cap.ROUND
|
||
}
|
||
|
||
override fun onDraw(canvas: Canvas) {
|
||
val unit = minOf(width, height).coerceAtLeast(1) / 100f
|
||
val cx = width / 2f
|
||
val cy = height / 2f - 6f * unit
|
||
val arrow = Path().apply {
|
||
moveTo(cx, cy - 20f * unit)
|
||
lineTo(cx + 17f * unit, cy - 2f * unit)
|
||
lineTo(cx + 8f * unit, cy - 2f * unit)
|
||
lineTo(cx + 8f * unit, cy + 15f * unit)
|
||
lineTo(cx - 8f * unit, cy + 15f * unit)
|
||
lineTo(cx - 8f * unit, cy - 2f * unit)
|
||
lineTo(cx - 17f * unit, cy - 2f * unit)
|
||
close()
|
||
}
|
||
when (state) {
|
||
ShiftState.OFF -> {
|
||
paint.style = Paint.Style.STROKE
|
||
paint.strokeWidth = 3f * unit
|
||
paint.color = mutedColor
|
||
}
|
||
ShiftState.SHIFT_ONCE, ShiftState.CAPS_LOCK -> {
|
||
paint.style = Paint.Style.FILL
|
||
paint.color = activeColor
|
||
}
|
||
}
|
||
canvas.drawPath(arrow, paint)
|
||
if (state == ShiftState.CAPS_LOCK) {
|
||
paint.style = Paint.Style.FILL
|
||
paint.color = activeColor
|
||
canvas.drawRoundRect(
|
||
cx - 17f * unit, cy + 22f * unit, cx + 17f * unit, cy + 28f * unit,
|
||
3f * unit, 3f * unit, paint,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
private class MicrophoneKeyView(context: android.content.Context, darkTheme: Boolean) : TextView(context) {
|
||
private val microphonePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
color = if (darkTheme) Color.rgb(190, 190, 190) else Color.rgb(110, 110, 115)
|
||
strokeCap = Paint.Cap.ROUND
|
||
strokeJoin = Paint.Join.ROUND
|
||
style = Paint.Style.FILL
|
||
}
|
||
override fun onDraw(canvas: Canvas) {
|
||
super.onDraw(canvas)
|
||
// Keep the same proportions as VoiceButton, but leave room for the small 0 label.
|
||
val unit = minOf(width, height).coerceAtLeast(1) / 160f
|
||
val centerX = width / 2f
|
||
val centerY = height * 0.56f
|
||
val bodyWidth = 11.5f * unit
|
||
val bodyTop = centerY - 24f * unit
|
||
val bodyBottom = centerY + 20f * unit
|
||
canvas.drawRoundRect(
|
||
centerX - bodyWidth,
|
||
bodyTop,
|
||
centerX + bodyWidth,
|
||
bodyBottom,
|
||
bodyWidth,
|
||
bodyWidth,
|
||
microphonePaint,
|
||
)
|
||
microphonePaint.style = Paint.Style.STROKE
|
||
microphonePaint.strokeWidth = 4.5f * unit
|
||
val arc = Path().apply {
|
||
moveTo(centerX - 19f * unit, centerY + 8f * unit)
|
||
cubicTo(
|
||
centerX - 18f * unit, centerY + 25f * unit,
|
||
centerX - 9f * unit, centerY + 30f * unit,
|
||
centerX, centerY + 30f * unit,
|
||
)
|
||
cubicTo(
|
||
centerX + 9f * unit, centerY + 30f * unit,
|
||
centerX + 18f * unit, centerY + 25f * unit,
|
||
centerX + 19f * unit, centerY + 8f * unit,
|
||
)
|
||
}
|
||
canvas.drawPath(arc, microphonePaint)
|
||
canvas.drawLine(
|
||
centerX,
|
||
centerY + 30f * unit,
|
||
centerX,
|
||
centerY + 39f * unit,
|
||
microphonePaint,
|
||
)
|
||
microphonePaint.style = Paint.Style.FILL
|
||
}
|
||
}
|
||
|
||
private class VoiceButton(context: android.content.Context, private val darkTheme: Boolean) : View(context) {
|
||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||
// Panel's own dark-theme background is rgb(48,48,48); this reads
|
||
// 12/255 lighter (tried 6 — too subtle to tell apart from the panel
|
||
// — then 24, which read as too strong; 12 is the settled value).
|
||
private val idlePillColor = if (darkTheme) Color.rgb(60, 60, 60) else Color.rgb(225, 225, 228)
|
||
private val idleIconColor = if (darkTheme) Color.WHITE else Color.rgb(60, 60, 64)
|
||
// Match the raw-mode recording indicator exactly, so the swipe-up
|
||
// preview and the resulting raw recording state use one consistent
|
||
// orange-yellow accent.
|
||
private val swipeArmedPillColor = LINK_COLOR_RECORDING_RAW
|
||
// Same idea, for the swipe-left-to-Note gesture's own idle-pill
|
||
// preview — see armedForQuickNote/pillArmedAmountGreen below.
|
||
private val quickNoteArmedPillColor = LINK_COLOR_QUICK_NOTE
|
||
// Same idea again, for the swipe-right-to-Cloud-notes gesture's own
|
||
// idle-pill preview — see armedForCloudNote/pillArmedAmountRed below.
|
||
private val cloudNoteArmedPillColor = LINK_COLOR_CLOUD_NOTE
|
||
// Light red, blended into the waveform bars while a swipe-down-to-
|
||
// cancel gesture is past its commit threshold — same value in both
|
||
// themes, mirroring swipeArmedPillColor's own choice.
|
||
private val cancelArmedWaveformColor = Color.rgb(255, 150, 150)
|
||
private val waveformColor = if (darkTheme) Color.rgb(222, 222, 222) else Color.rgb(70, 70, 74)
|
||
private val processingDotColors = if (darkTheme) {
|
||
intArrayOf(
|
||
Color.rgb(245, 245, 245), Color.rgb(205, 205, 205),
|
||
Color.rgb(170, 170, 170), Color.rgb(235, 235, 235),
|
||
Color.rgb(190, 190, 190), Color.rgb(220, 220, 220),
|
||
)
|
||
} else {
|
||
intArrayOf(
|
||
Color.rgb(40, 40, 40), Color.rgb(90, 90, 90),
|
||
Color.rgb(130, 130, 130), Color.rgb(55, 55, 55),
|
||
Color.rgb(100, 100, 100), Color.rgb(70, 70, 70),
|
||
)
|
||
}
|
||
var isRecording: Boolean = false
|
||
set(value) {
|
||
field = value
|
||
invalidate()
|
||
}
|
||
var isProcessing: Boolean = false
|
||
set(value) {
|
||
field = value
|
||
invalidate()
|
||
}
|
||
|
||
var rawModeActive: Boolean = false
|
||
set(value) {
|
||
field = value
|
||
invalidate()
|
||
}
|
||
|
||
var quickNoteActive: Boolean = false
|
||
set(value) {
|
||
field = value
|
||
invalidate()
|
||
}
|
||
|
||
var cloudNoteActive: Boolean = false
|
||
set(value) {
|
||
field = value
|
||
invalidate()
|
||
}
|
||
|
||
var audioLevel: Float = 0f
|
||
set(value) {
|
||
field = value
|
||
invalidate()
|
||
}
|
||
|
||
// Live "would releasing now enter raw mode?" indicator — the
|
||
// setter only starts an ease toward the target (see
|
||
// pillArmedAmount below); it doesn't jump straight there, so
|
||
// rapid on/off toggling right at the dp(30) threshold doesn't
|
||
// flicker instantly between the two colors.
|
||
var armedForRawSwipe: Boolean = false
|
||
set(value) {
|
||
if (field == value) return
|
||
field = value
|
||
pillArmedAnimator?.cancel()
|
||
pillArmedAnimator = android.animation.ValueAnimator.ofFloat(pillArmedAmount, if (value) 1f else 0f).apply {
|
||
duration = 120L
|
||
addUpdateListener { pillArmedAmount = it.animatedValue as Float }
|
||
start()
|
||
}
|
||
}
|
||
private var pillArmedAmount: Float = 0f
|
||
set(value) {
|
||
field = value
|
||
invalidate()
|
||
}
|
||
private var pillArmedAnimator: android.animation.ValueAnimator? = null
|
||
|
||
// Same pattern as armedForRawSwipe/pillArmedAmount above, for the
|
||
// swipe-left-to-Note idle-pill preview.
|
||
var armedForQuickNote: Boolean = false
|
||
set(value) {
|
||
if (field == value) return
|
||
field = value
|
||
pillArmedAnimatorGreen?.cancel()
|
||
pillArmedAnimatorGreen = android.animation.ValueAnimator.ofFloat(pillArmedAmountGreen, if (value) 1f else 0f).apply {
|
||
duration = 120L
|
||
addUpdateListener { pillArmedAmountGreen = it.animatedValue as Float }
|
||
start()
|
||
}
|
||
}
|
||
private var pillArmedAmountGreen: Float = 0f
|
||
set(value) {
|
||
field = value
|
||
invalidate()
|
||
}
|
||
private var pillArmedAnimatorGreen: android.animation.ValueAnimator? = null
|
||
|
||
// Same pattern again, for the swipe-right-to-Cloud-notes idle-pill
|
||
// preview.
|
||
var armedForCloudNote: Boolean = false
|
||
set(value) {
|
||
if (field == value) return
|
||
field = value
|
||
pillArmedAnimatorRed?.cancel()
|
||
pillArmedAnimatorRed = android.animation.ValueAnimator.ofFloat(pillArmedAmountRed, if (value) 1f else 0f).apply {
|
||
duration = 120L
|
||
addUpdateListener { pillArmedAmountRed = it.animatedValue as Float }
|
||
start()
|
||
}
|
||
}
|
||
private var pillArmedAmountRed: Float = 0f
|
||
set(value) {
|
||
field = value
|
||
invalidate()
|
||
}
|
||
private var pillArmedAnimatorRed: android.animation.ValueAnimator? = null
|
||
|
||
// Same pattern as armedForRawSwipe/pillArmedAmount above, applied
|
||
// to the recording waveform instead of the idle capsule — swipe
|
||
// DOWN past the threshold while recording tints the waveform bars
|
||
// light red instead of the capsule turning green.
|
||
var armedForCancel: Boolean = false
|
||
set(value) {
|
||
if (field == value) return
|
||
field = value
|
||
waveformCancelAnimator?.cancel()
|
||
waveformCancelAnimator = android.animation.ValueAnimator.ofFloat(waveformCancelAmount, if (value) 1f else 0f).apply {
|
||
duration = 120L
|
||
addUpdateListener { waveformCancelAmount = it.animatedValue as Float }
|
||
start()
|
||
}
|
||
}
|
||
private var waveformCancelAmount: Float = 0f
|
||
set(value) {
|
||
field = value
|
||
invalidate()
|
||
}
|
||
private var waveformCancelAnimator: android.animation.ValueAnimator? = null
|
||
|
||
private fun lerpColor(from: Int, to: Int, amount: Float): Int {
|
||
val t = amount.coerceIn(0f, 1f)
|
||
return Color.rgb(
|
||
(Color.red(from) + (Color.red(to) - Color.red(from)) * t).toInt(),
|
||
(Color.green(from) + (Color.green(to) - Color.green(from)) * t).toInt(),
|
||
(Color.blue(from) + (Color.blue(to) - Color.blue(from)) * t).toInt(),
|
||
)
|
||
}
|
||
|
||
private var phase = 0f
|
||
private val animator = object : Runnable {
|
||
override fun run() {
|
||
phase += 0.18f
|
||
invalidate()
|
||
postDelayed(this, 50L)
|
||
}
|
||
}
|
||
|
||
override fun onAttachedToWindow() {
|
||
super.onAttachedToWindow()
|
||
post(animator)
|
||
}
|
||
|
||
override fun onDetachedFromWindow() {
|
||
removeCallbacks(animator)
|
||
pillArmedAnimator?.cancel()
|
||
pillArmedAnimatorGreen?.cancel()
|
||
pillArmedAnimatorRed?.cancel()
|
||
waveformCancelAnimator?.cancel()
|
||
super.onDetachedFromWindow()
|
||
}
|
||
|
||
private fun dp(value: Int): Int =
|
||
(value * resources.displayMetrics.density).toInt()
|
||
|
||
override fun onDraw(canvas: Canvas) {
|
||
super.onDraw(canvas)
|
||
val centerX = width / 2f
|
||
val centerY = height / 2f
|
||
val scale = if (isRecording) {
|
||
1f + 0.04f + audioLevel * 0.20f
|
||
} else {
|
||
1f
|
||
}
|
||
val pillWidth = width * 0.86f
|
||
val pillHeight = minOf(pillWidth * 192f / 470f, height * 0.90f)
|
||
val left = centerX - pillWidth / 2f
|
||
val top = centerY - pillHeight * scale / 2f
|
||
val right = centerX + pillWidth / 2f
|
||
val bottom = centerY + pillHeight * scale / 2f
|
||
val radius = pillHeight * 0.5f
|
||
// Recording/thinking states show only the animation with the capsule background
|
||
// fully removed; the idle state keeps the mic button.
|
||
if (!isRecording && !isProcessing) {
|
||
val pillColor = lerpColor(
|
||
lerpColor(
|
||
lerpColor(idlePillColor, swipeArmedPillColor, pillArmedAmount),
|
||
quickNoteArmedPillColor,
|
||
pillArmedAmountGreen,
|
||
),
|
||
cloudNoteArmedPillColor,
|
||
pillArmedAmountRed,
|
||
)
|
||
paint.color = pillColor
|
||
canvas.drawRoundRect(left, top, right, bottom, radius, radius, paint)
|
||
}
|
||
|
||
paint.color = idleIconColor
|
||
paint.strokeWidth = width * 0.025f
|
||
paint.strokeCap = Paint.Cap.ROUND
|
||
if (!isRecording && !isProcessing) {
|
||
// Reference crop: capsule (393,380)-(863,572), 470 x 192.
|
||
// All geometry uses that ONE coordinate system and uniform scaling.
|
||
canvas.save()
|
||
val unit = pillHeight / 192f
|
||
canvas.translate(centerX - 235f * unit, centerY - 96f * unit)
|
||
canvas.scale(unit, unit)
|
||
paint.style = Paint.Style.FILL
|
||
canvas.drawRoundRect(213f, 40f, 259f, 113f, 23f, 23f, paint)
|
||
paint.style = Paint.Style.STROKE
|
||
paint.strokeWidth = 11f
|
||
val arc = Path().apply {
|
||
moveTo(196f, 96f)
|
||
cubicTo(198f, 118f, 215f, 130f, 236f, 130f)
|
||
cubicTo(257f, 130f, 274f, 118f, 275f, 96f)
|
||
}
|
||
canvas.drawPath(arc, paint)
|
||
canvas.drawLine(236f, 130f, 236f, 146f, paint)
|
||
paint.style = Paint.Style.FILL
|
||
canvas.restore()
|
||
return
|
||
}
|
||
if (isRecording) {
|
||
// The normal waveform is monochrome; RAW recording uses the
|
||
// same orange-yellow accent as the RAW indicator and the
|
||
// swipe-up preview. Quiet input stays compact while speech
|
||
// expands the bars clearly with the live microphone level.
|
||
// Longer bar group (17 vs the original 9) with a genuine
|
||
// left-flowing traveling wave — as `phase` advances, the sine
|
||
// term's peak visibly drifts from higher to lower bar
|
||
// indices, i.e. right to left — layered on top of the same
|
||
// bell-shaped envelope (tallest in the middle) as before.
|
||
// Gap is derived from bar count so the group always spans the
|
||
// same ~86% of the view width regardless of how many bars.
|
||
val live = (audioLevel * 1.15f).coerceIn(0f, 1f)
|
||
val barCount = 17
|
||
val envelopeCenter = (barCount - 1) / 2f
|
||
val gap = width * 0.86f / (barCount - 1)
|
||
val startX = centerX - gap * (barCount - 1) / 2f
|
||
val rawWaveformColor = when {
|
||
rawModeActive -> LINK_COLOR_RECORDING_RAW
|
||
quickNoteActive -> LINK_COLOR_QUICK_NOTE
|
||
cloudNoteActive -> LINK_COLOR_CLOUD_NOTE
|
||
else -> waveformColor
|
||
}
|
||
val currentWaveformColor = lerpColor(rawWaveformColor, cancelArmedWaveformColor, waveformCancelAmount)
|
||
for (index in 0 until barCount) {
|
||
val x = startX + index * gap
|
||
val distanceFromCenter = kotlin.math.abs(index - envelopeCenter) / envelopeCenter
|
||
val envelope = 1f - distanceFromCenter * distanceFromCenter * 0.75f
|
||
val flow = 0.55f + 0.45f * kotlin.math.sin(
|
||
(phase * 2.2f + index * 0.9f).toDouble(),
|
||
).toFloat()
|
||
val halfHeight = minOf(height * 0.95f, dp(66).toFloat()) *
|
||
(0.035f + live * 0.965f) * envelope * flow
|
||
paint.color = currentWaveformColor
|
||
paint.strokeWidth = dp(3).toFloat()
|
||
canvas.drawLine(x, centerY - halfHeight, x, centerY + halfHeight, paint)
|
||
}
|
||
} else if (isProcessing) {
|
||
// Analysis state uses the same restrained monochrome palette;
|
||
// the ring of dots keeps rotating exactly as before, and on
|
||
// top of that the whole ring's radius now breathes — growing
|
||
// then shrinking together as one — rather than each dot
|
||
// sizing itself independently off its own angle.
|
||
val colors = processingDotColors
|
||
val baseOrbit = minOf(width * 0.28f, height * 0.52f)
|
||
val baseDotRadius = minOf(width * 0.055f, height * 0.15f)
|
||
val breathe = 0.6f + 0.4f * ((1f + kotlin.math.sin((phase * 0.5f).toDouble()).toFloat()) / 2f)
|
||
val orbit = baseOrbit * breathe
|
||
val dotRadius = baseDotRadius * breathe
|
||
colors.forEachIndexed { index, color ->
|
||
val angle = phase * 0.65f + index * (Math.PI.toFloat() / 3f)
|
||
val x = centerX + kotlin.math.cos(angle.toDouble()).toFloat() * orbit
|
||
val y = centerY + kotlin.math.sin(angle.toDouble()).toFloat() * orbit
|
||
paint.color = color
|
||
canvas.drawCircle(x, y, dotRadius, paint)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
companion object {
|
||
private val nextImeRequest = java.util.concurrent.atomic.AtomicLong()
|
||
const val PREF_KEYBOARD_HEIGHT_DP = "keyboard_height_dp"
|
||
const val DEFAULT_KEYBOARD_HEIGHT_DP = 300
|
||
const val MIN_KEYBOARD_HEIGHT_DP = 220
|
||
const val MAX_KEYBOARD_HEIGHT_DP = 420
|
||
const val PREF_KEYBOARD_RAISE_DP = "keyboard_raise_dp"
|
||
const val DEFAULT_KEYBOARD_RAISE_DP = 0
|
||
const val MIN_KEYBOARD_RAISE_DP = 0
|
||
const val MAX_KEYBOARD_RAISE_DP = 120
|
||
|
||
fun panelHeightDp(context: android.content.Context): Int {
|
||
return context.getSharedPreferences("openless_ime_ui", android.content.Context.MODE_PRIVATE)
|
||
.getInt(PREF_KEYBOARD_HEIGHT_DP, DEFAULT_KEYBOARD_HEIGHT_DP)
|
||
.coerceIn(MIN_KEYBOARD_HEIGHT_DP, MAX_KEYBOARD_HEIGHT_DP)
|
||
}
|
||
|
||
fun panelHeightPx(context: android.content.Context): Int {
|
||
return (panelHeightDp(context) * context.resources.displayMetrics.density).toInt()
|
||
}
|
||
|
||
fun raiseHeightDp(context: android.content.Context): Int {
|
||
return context.getSharedPreferences("openless_ime_ui", android.content.Context.MODE_PRIVATE)
|
||
.getInt(PREF_KEYBOARD_RAISE_DP, DEFAULT_KEYBOARD_RAISE_DP)
|
||
.coerceIn(MIN_KEYBOARD_RAISE_DP, MAX_KEYBOARD_RAISE_DP)
|
||
}
|
||
|
||
fun raiseHeightPx(context: android.content.Context): Int {
|
||
return (raiseHeightDp(context) * context.resources.displayMetrics.density).toInt()
|
||
}
|
||
|
||
private const val SILENCE_LEVEL_THRESHOLD = 0.02f
|
||
private const val SILENCE_CHECK_DELAY_MS = 3000L
|
||
private const val BACKEND_HEARTBEAT_INTERVAL_MS = 6000L
|
||
// How long a transient confirmation (已上屏/已完成/已撤销) stays up
|
||
// before settling back to "点击开始说话" — see scheduleRevertToIdle().
|
||
private const val DONE_TO_IDLE_DELAY_MS = 2000L
|
||
// quickNoteDictation()'s own "笔记已记录" confirmation — shorter
|
||
// than DONE_TO_IDLE_DELAY_MS per product request, since there's no
|
||
// undo/redo/edit row to give the user time to read (quick note
|
||
// never inserts anything).
|
||
private const val QUICK_NOTE_CONFIRMATION_DELAY_MS = 1000L
|
||
// Not a hard "show exactly N" cap — the candidate bar is a
|
||
// HorizontalScrollView (see buildEnglishCandidateBar()), so this
|
||
// just bounds how many the provider bothers ranking/returning per
|
||
// keystroke; comfortably more than can fit on screen at once so
|
||
// scrolling actually reveals more real options.
|
||
private const val ENGLISH_CANDIDATE_QUERY_LIMIT = 10
|
||
// buildClipboardView()'s "recent clips" strip. Originally 92dp for
|
||
// 4 equal-weight rows + 5 divider lines (dp(1) each), raised ~1.2x
|
||
// to 109dp per user request: (92-5)/4=21.75dp/row * 1.2 = 26.1,
|
||
// 26.1*4+5=109.4 -> 109. Then reduced from 4 rows to 3 (also per
|
||
// user request) WITHOUT changing this total — 3 rows only need 4
|
||
// dividers, not 5, so the freed divider's dp plus the freed row's
|
||
// own share both land back on the remaining 3: (109-4)/3=35dp/row,
|
||
// up from 26dp. The button grid below needs no matching change —
|
||
// it's already LinearLayout.LayoutParams(.., 0, 1f), i.e. "whatever
|
||
// space is left after this strip's fixed height," so it grows or
|
||
// shrinks on its own the moment this constant or the row count
|
||
// changes.
|
||
private const val RECENT_CLIPS_COLUMN_HEIGHT_DP = 109
|
||
// Swipe-up symbol hint, any LETTERS-layer row (buildEnglishCharKey())
|
||
// — its own independent TextView pinned to the key's top edge, so
|
||
// these are absolute sizes, not a ratio of the 22sp letter below
|
||
// it. Small and close to the top by design; tune these two numbers
|
||
// directly. Shared by row1's digits and row2/row3's punctuation so
|
||
// all three rows read as one consistent hint style.
|
||
private const val SWIPE_SYMBOL_HINT_TEXT_SIZE_SP = 11f
|
||
private const val SWIPE_SYMBOL_HINT_TOP_PADDING_DP = 2
|
||
// Pure render-time offset (View.translationY), not part of the
|
||
// letter TextView's own measured layout — see buildEnglishCharKey().
|
||
private const val LETTER_VERTICAL_NUDGE_DP = 2
|
||
// Same hue family as OpenLessOverlayService's OverlayVisualState
|
||
// (recording/processing), plus a light-green "ready" and an amber
|
||
// "link issue" that overlay doesn't have.
|
||
private val LINK_COLOR_READY = Color.rgb(134, 239, 172)
|
||
private val LINK_COLOR_RECORDING = Color.rgb(244, 63, 94)
|
||
// Recording with rawModeArmed set (mic swipe-up: skip LLM polish on
|
||
// stop) — distinct orange so glancing at the dot tells the two
|
||
// recording states apart without reading any text.
|
||
private val LINK_COLOR_RECORDING_RAW = Color.rgb(249, 115, 22)
|
||
// Recording with quickNoteArmed set (mic swipe-left: end the
|
||
// dictation and archive it as a standalone note instead of
|
||
// inserting) — green, the same treatment LINK_COLOR_RECORDING_RAW
|
||
// gets for its own swipe-up gesture.
|
||
private val LINK_COLOR_QUICK_NOTE = Color.rgb(34, 197, 94)
|
||
// Recording with cloudNoteArmed set (mic swipe-right: end the dictation
|
||
// and POST the verbatim transcript to the configured webhook
|
||
// instead of inserting anything) — red, the third accent alongside
|
||
// LINK_COLOR_RECORDING_RAW's orange and LINK_COLOR_QUICK_NOTE's
|
||
// green.
|
||
private val LINK_COLOR_CLOUD_NOTE = Color.rgb(239, 68, 68)
|
||
private val LINK_COLOR_PROCESSING = Color.rgb(56, 189, 248)
|
||
private val LINK_COLOR_ISSUE = Color.rgb(250, 204, 21)
|
||
// Ready is the state the indicator sits in almost all the time, so
|
||
// its pulse is slower/gentler ("breathing") than the other, more
|
||
// urgent states — which keep the original brisker cadence.
|
||
private const val BACKEND_LINK_PULSE_DURATION_MS = 900L
|
||
private const val BACKEND_LINK_READY_PULSE_DURATION_MS = 1800L
|
||
|
||
@Volatile
|
||
private var lastHeartbeatElapsedRealtime = 0L
|
||
@Volatile
|
||
private var lastHeartbeatReady: Boolean? = null
|
||
|
||
/** Runtime/heartbeat state shown in native keyboard settings for diagnosis. */
|
||
fun backendDebugSnapshot(): String {
|
||
val service = activeInstance?.get()
|
||
?: return "ime=none heartbeat=none"
|
||
val heartbeat = lastHeartbeatReady?.let { if (it) "ready" else "not-ready" } ?: "none"
|
||
val ageMs = if (lastHeartbeatElapsedRealtime == 0L) {
|
||
-1L
|
||
} else {
|
||
(android.os.SystemClock.elapsedRealtime() - lastHeartbeatElapsedRealtime).coerceAtLeast(0L)
|
||
}
|
||
return "ime=present recording=${service.recording} processing=${service.processing} " +
|
||
"backend=${heartbeat} ageMs=$ageMs indicator=${service.backendLinkIndicator != null}"
|
||
}
|
||
|
||
/**
|
||
* Opts a view out of Android's system gesture navigation (back/home
|
||
* edge swipes) so our own in-keyboard swipe gestures aren't preempted
|
||
* by the OS, which otherwise reads as the IME window flickering
|
||
* closed and reopening under the user's finger.
|
||
*/
|
||
fun excludeFromSystemGestures(view: View) {
|
||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) return
|
||
view.addOnLayoutChangeListener { v, left, top, right, bottom, _, _, _, _ ->
|
||
if (right > left && bottom > top) {
|
||
v.systemGestureExclusionRects = listOf(android.graphics.Rect(0, 0, right - left, bottom - top))
|
||
}
|
||
}
|
||
}
|
||
|
||
@Volatile
|
||
private var activeInstance: java.lang.ref.WeakReference<OpenLessImeService>? = null
|
||
|
||
/**
|
||
* Whether the input panel is actually on screen right now — checked by
|
||
* OpenLessBackendWarmupActivity.launchWarmup() right before it steals
|
||
* the foreground, so it knows afterward whether restoring the panel is
|
||
* even meaningful (see requestInputPanelAfterWarmup()'s doc comment).
|
||
*/
|
||
fun isInputPanelCurrentlyShown(): Boolean {
|
||
val service = activeInstance?.get() ?: return false
|
||
return service.isInputViewShown
|
||
}
|
||
|
||
/**
|
||
* Re-open the IME after the one-time backend Activity gives focus
|
||
* back — but only when there is still a real, keyboard-wanting field
|
||
* focused at fire time. Without this check, a warmup silently
|
||
* triggered by an unrelated onStartInput() (e.g. Camera/Dialer
|
||
* momentarily focusing their own non-text-entry views) or by the
|
||
* periodic backend heartbeat could force the panel open over
|
||
* whatever app the user has since switched to — up to ~500ms after
|
||
* the original trigger (120ms launch delay + 180ms self-background +
|
||
* this call's own delay), plenty of time to have left the field, the
|
||
* app, or even backgrounded to the home screen entirely. Callers are
|
||
* additionally expected to only invoke this at all when the panel
|
||
* was visible right before the warmup Activity took focus (see
|
||
* isInputPanelCurrentlyShown()) — this is the second, independent
|
||
* check against the state at the moment of firing.
|
||
*/
|
||
fun requestInputPanelAfterWarmup(delayMs: Long = 260L) {
|
||
val service = activeInstance?.get() ?: return
|
||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||
if (activeInstance?.get() === service) {
|
||
val attribute = service.currentInputEditorInfo
|
||
if (attribute != null && attribute.inputType != InputType.TYPE_NULL) {
|
||
service.requestShowSelf(InputMethodManager.SHOW_IMPLICIT)
|
||
}
|
||
}
|
||
}, delayMs)
|
||
}
|
||
}
|
||
}
|