Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 121 additions & 10 deletions app/src/main/java/at/dasher/android/DasherImeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,20 @@ class DasherImeService : InputMethodService() {

// Shared height formula (was duplicated between onCreateInputView and
// exitFloatingMode — drift would give different dock heights).
private fun imeHeightPx() = (resources.displayMetrics.heightPixels * 0.42f).toInt()
private var floatingParams: WindowManager.LayoutParams? = null
// #48: 35% on screens ≥600dp (tablets/foldables — Heide: "you can't even
// see what you're zooming"), 42% on phones.
private fun imeHeightPx(): Int {
val fraction = if (resources.displayMetrics.widthPixels >= dp(600, resources.displayMetrics.density)) 0.35f else 0.42f
return (resources.displayMetrics.heightPixels * fraction).toInt()
}
private val windowManager get() = getSystemService(Context.WINDOW_SERVICE) as WindowManager
private var editingToolbar: EditingToolbar? = null

override fun onCreateInputView(): View {
val density = resources.displayMetrics.density
val nightMode = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES
val bg = if (nightMode) 0xFF1E262B.toInt() else 0xFFF4F7F6.toInt()
val imeHeight = imeHeightPx()

// Dasher canvas — shared between docked and floating modes.
val canvas = DasherCanvasView(this).apply {
Expand Down Expand Up @@ -113,10 +117,22 @@ class DasherImeService : InputMethodService() {
top.addView(floatBtn)
top.addView(hideBtn)

// RFC 0019 editing toolbar (#50): backspace, cursor, clipboard.
// Acts on the TARGET app via InputConnection; after each action,
// re-read the target text and re-anchor the engine so predictions
// follow the edit (RFC 0015 tier 2).
val toolbar = EditingToolbar(
context = this,
inputConnection = { currentInputConnection },
onBufferChanged = { reanchorEngineToTarget() },
)
editingToolbar = toolbar

val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(bg)
}
root.addView(toolbar, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
root.addView(top, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
root.addView(canvasHost, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f))
// The root is the IME window's content view — its parent is the
Expand Down Expand Up @@ -162,7 +178,7 @@ class DasherImeService : InputMethodService() {
val floatW = minOf(screenW - dp(32, density), dp(600, density))
val floatH = dp(280, density)

// Drag handle bar with a Dock button.
// Drag handle bar with a Dock button and a resize handle (#49).
val dragBar = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL or Gravity.CENTER_HORIZONTAL
Expand All @@ -171,6 +187,13 @@ class DasherImeService : InputMethodService() {
}
val dockBtn = Button(this).apply { text = "Dock" }
dragBar.addView(dockBtn)
// Resize handle (#49): drag right/bottom edge to grow, left/top to shrink.
val resizeHandle = Button(this).apply {
text = "⟷" // horizontal resize indicator
contentDescription = "Resize"
layoutParams = LinearLayout.LayoutParams(dp(44, density), WRAP_CONTENT)
}
dragBar.addView(resizeHandle)

// Floating container: drag bar + canvas.
val floating = LinearLayout(this).apply {
Expand All @@ -184,6 +207,13 @@ class DasherImeService : InputMethodService() {
setPadding(dp(2, density), dp(2, density), dp(2, density), dp(2, density))
}
floating.addView(dragBar, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
// RFC 0019 editing toolbar in floating mode too (#50). Must detach
// from the docked root FIRST — addView on an attached child throws
// IllegalStateException (review C1: Float crashed on first tap).
editingToolbar?.let { toolbar ->
(toolbar.parent as? ViewGroup)?.removeView(toolbar)
floating.addView(toolbar, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
}
// Reparent the canvas (host carries the loading overlay) from docked to floating.
(canvasHost?.parent as? ViewGroup)?.removeView(canvasHost)
floating.addView(canvasHost, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f))
Expand All @@ -205,14 +235,17 @@ class DasherImeService : InputMethodService() {
windowManager.addView(floating, params)
} catch (e: Exception) {
Log.e(TAG, "Floating overlay failed: ${e.message}")
// Fall back: put canvas back in docked
// Fall back: put canvas and toolbar back in docked
floating.removeView(canvasHost)
dockedRoot?.addView(canvasHost, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f))
editingToolbar?.let { toolbar ->
floating.removeView(toolbar)
dockedRoot?.addView(toolbar, 0, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
}
return
}

floatingView = floating
floatingParams = params
this.floating = true

// Shrink the docked view so the system doesn't reserve full keyboard space.
Expand Down Expand Up @@ -248,23 +281,56 @@ class DasherImeService : InputMethodService() {
// Also wire the dock button.
dockBtn.setOnClickListener { exitFloatingMode(floatBtn) }

// Resize handling (#49): drag the ⟷ handle to adjust the floating
// window's width (height follows proportionally). Pin the handle's
// initial touch position and the window's initial size; the delta
// applies to both dimensions with a 0.47 height-to-width ratio.
var initW = 0; var resizeTouchX = 0f
val minW = dp(240, density)
val maxW = screenW - dp(16, density)
resizeHandle.setOnTouchListener { _, ev ->
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
initW = params.width
resizeTouchX = ev.rawX
true
}
MotionEvent.ACTION_MOVE -> {
val newW = (initW + (ev.rawX - resizeTouchX).toInt()).coerceIn(minW, maxW)
params.width = newW
params.height = (newW * 0.47f).toInt().coerceIn(dp(180, density), dp(420, density))
try { windowManager.updateViewLayout(floating, params) } catch (_: Exception) { }
// Notify the canvas of its new size.
canvasView?.let { v ->
v.post { engine?.onSurfaceSizeChanged(v.width, v.height) }
}
true
}
else -> false
}
}

// Notify the canvas of its new size.
canvasView?.let { if (it.width > 0 && it.height > 0) engine?.onSurfaceSizeChanged(it.width, it.height) }
}

private fun exitFloatingMode(floatBtn: Button) {
if (!floating) return
val fv = floatingView ?: return
val fp = floatingParams ?: return
try { windowManager.removeView(fv) } catch (_: Exception) {}
// Detach the toolbar from floating; re-insert at the TOP of the
// docked root (index 0, above the Float/Hide bar — where it was
// before floating). Review I1: the toolbar was being orphaned.
editingToolbar?.let { toolbar ->
(toolbar.parent as? ViewGroup)?.removeView(toolbar)
dockedRoot?.addView(toolbar, 0, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
}
// Reparent canvas back to docked.
(canvasHost?.parent as? ViewGroup)?.removeView(canvasHost)
dockedRoot?.addView(canvasHost, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f))
// Restore docked height.
val imeHeight = imeHeightPx()
// Restore docked height (#48: adaptive — smaller on tablets).
setDockedHeight(imeHeightPx())
floatingView = null
floatingParams = null
floating = false
floatBtn.text = "Float"
floatBtn.setOnClickListener { enterFloatingMode(floatBtn) }
Expand Down Expand Up @@ -333,8 +399,52 @@ class DasherImeService : InputMethodService() {
eng.start()
}

// ── Engine re-anchoring (RFC 0015 tier 2) ────────────────────────────

/**
* After an editing action (backspace, cursor move, paste), the target
* field's text changed. Re-read it and re-anchor the engine so
* predictions follow (RFC 0015 tier 2 + RFC 0019 clause 2). Reads a
* trailing window (not the full field — sentence-window, governance#40).
*/
private fun reanchorEngineToTarget() {
val eng = engine ?: return
val ic = currentInputConnection ?: return
// Read a trailing window of text before the cursor (the engine's
// context is what's BEFORE the caret — predictions continue from it).
val before = ic.getTextBeforeCursor(200, 0)?.toString() ?: return
if (before.isEmpty()) {
// Empty window: the engine's buffer is stale (user backspaced
// to the start or the field is empty) — reset rather than
// predict from deleted text (review I2).
eng.newSession()
return
}
// seedBuffer converts the UTF-16 caret to UTF-8 bytes internally —
// do NOT pre-convert (review C2: double-conversion shifted the
// anchor past the caret for any non-ASCII text).
eng.seedBuffer(before, before.length)
}

// ── Lifecycle ──────────────────────────────────────────────────────────

override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
// #48: fold/unfold changes the screen dimensions — recompute the
// docked height so the adaptive fraction (35%/42%) matches the
// current device state. Only when not floating (floating has its
// own window size, managed by the resize handle).
if (!floating) {
setDockedHeight(imeHeightPx())
dockedRoot?.minimumHeight = imeHeightPx()
} else {
// Floating: the recreated docked root gets full height at
// line 143 — re-shrink it (review 2: fold-while-floating
// produced a full-height empty dock behind the overlay).
setDockedHeight(dp(40, resources.displayMetrics.density))
}
}

override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
super.onStartInputView(info, restarting)
// The main app's engine writes to the shared dasher_settings.xml
Expand Down Expand Up @@ -365,6 +475,7 @@ class DasherImeService : InputMethodService() {
canvasHost = null
loadingOverlay = null
dockedRoot = null
editingToolbar = null
super.onDestroy()
}

Expand Down
120 changes: 120 additions & 0 deletions app/src/main/java/at/dasher/android/EditingToolbar.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package at.dasher.android

import android.content.Context
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.InputConnection
import android.widget.Button
import android.widget.LinearLayout

/**
* RFC 0019 editing toolbar for the IME (#50): backspace, cursor movement,
* and clipboard actions — the tools Heide needs so she doesn't "go back
* and forth with SwiftKey" to fix mistakes.
*
* All actions go through [InputConnection] against the TARGET app's field.
* After each action, [onBufferChanged] fires so the IME can re-anchor the
* engine (re-read the target text and re-seed — RFC 0015 tier 2).
*/
class EditingToolbar(
context: Context,
private val inputConnection: () -> InputConnection?,
private val onBufferChanged: () -> Unit,
) : LinearLayout(context) {

private val density = context.resources.displayMetrics.density
private val nightMode = (context.resources.configuration.uiMode and
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_YES

init {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setBackgroundColor(if (nightMode) 0xFF2A353D.toInt() else 0xFFE0E6E8.toInt())
setPadding(dp(4), dp(2), dp(4), dp(2))

// Buffer-mutating actions re-anchor the engine (buffer changed);
// non-mutating (Copy, Select All) don't — replacing the edit buffer
// on every action would reset the model mid-sentence (review 2).
addTool("←", "Move cursor left", mutates = true) { moveCursor(-1) }
addTool("→", "Move cursor right", mutates = true) { moveCursor(1) }
addSpacer(dp(8))
addTool("⌫", "Delete", large = true, mutates = true) { backspace() }
addSpacer(dp(8))
addTool("Sel", "Select all", mutates = false) { performAction(android.R.id.selectAll) }
addTool("Cp", "Copy", mutates = false) { performAction(android.R.id.copy) }
addTool("Ps", "Paste", mutates = true) { performAction(android.R.id.paste) }
}

private fun dp(v: Int) = (v * density).toInt()

private fun toolButton(label: String, tooltip: String, large: Boolean): Button {
val width = if (large) dp(56) else dp(48)
return Button(context).apply {
text = label
textSize = if (large) 20f else 14f
isAllCaps = false
minimumWidth = width // exact width; LayoutParams below uses this
minimumHeight = dp(48) // #48: 48dp minimum touch target for motor-impaired users
setPadding(dp(4), dp(2), dp(4), dp(2))
contentDescription = tooltip
background = GradientDrawable().apply {
cornerRadius = dp(6).toFloat()
setColor(0)
setStroke(1, if (nightMode) 0xFF3A4550.toInt() else 0xFFB8C4C8.toInt())
}
layoutParams = LayoutParams(width, LayoutParams.WRAP_CONTENT).apply {
marginEnd = dp(2)
}
}
}

private fun addTool(label: String, tooltip: String, large: Boolean = false,
mutates: Boolean = true, action: () -> Unit) {
addView(toolButton(label, tooltip, large).apply {
setOnClickListener {
action()
if (mutates) onBufferChanged()
}
})
}

private fun addSpacer(widthPx: Int) {
addView(View(context).apply {
layoutParams = LayoutParams(widthPx, 1)
})
}

// ── Actions ────────────────────────────────────────────────────────────

/**
* Delete one codepoint before the cursor (#50: Heide's #1 request).
* [InputConnection.deleteSurroundingText] expects UTF-16 code-unit
* counts; a surrogate pair (emoji, CJK beyond BMP) is 2 units.
*/
private fun backspace() {
val ic = inputConnection() ?: return
val before = ic.getTextBeforeCursor(2, 0)
val units = when {
before == null || before.isEmpty() -> return
before.length >= 2 && Character.isHighSurrogate(before[before.length - 2]) -> 2
else -> 1
}
ic.deleteSurroundingText(units, 0)
}

/** Move the cursor one position left/right via the target's key handler (#50). */
private fun moveCursor(direction: Int) {
val ic = inputConnection() ?: return
val keyCode = if (direction < 0) KeyEvent.KEYCODE_DPAD_LEFT else KeyEvent.KEYCODE_DPAD_RIGHT
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode))
}

/** Clipboard bridge: Select All / Copy / Paste via the target's context menu (#50). */
private fun performAction(actionId: Int) {
inputConnection()?.performContextMenuAction(actionId)
}
}
Loading