From 5a60138132b0b1038322329f2e4ba7c2e0a4192c Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 27 Oct 2026 06:45:07 +0000 Subject: [PATCH 1/5] feat(ime): Lucide icons + compact toolbars (RFC 0002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IME's View-based toolbars used text labels ('⌫', 'Sel', 'Cp', 'Ps') and default Material text pills for Float/Hide β€” 'no icons, looks poor, the dock buttons are massive' against the design guide and the main app (which already renders Lucide via Compose). - Lucide icons baked as tintable vector drawables (lucide-static v1.62, ISC), generated for: arrow-left/right, delete, copy, clipboard-paste, text-select, panel-bottom, picture-in-picture-2, keyboard-off - EditingToolbar: 48dp icon targets (56dp delete), 8dp rounded corners (design token rounded.md), contentDescriptions preserved - Top bar: compact 48dp icon buttons for Float (picture-in-picture-2), Hide (keyboard-off); FloatDock toggle swaps icon+description+action via setFloatDockButton; emoji toggle keeps its 'πŸ˜€'/'Abc' state label in a compact pill - LucideIcons: mutate()+tint bridge for the classic-View layer Signed-off-by: will wade --- .../at/dasher/android/DasherImeService.kt | 86 +++++++++++-- .../java/at/dasher/android/EditingToolbar.kt | 67 ++++++----- .../java/at/dasher/android/LucideIcons.kt | 26 ++++ .../res/drawable/ic_lucide_arrow_left.xml | 22 ++++ .../res/drawable/ic_lucide_arrow_right.xml | 22 ++++ .../drawable/ic_lucide_clipboard_paste.xml | 36 ++++++ app/src/main/res/drawable/ic_lucide_copy.xml | 15 +++ .../main/res/drawable/ic_lucide_delete.xml | 29 +++++ .../res/drawable/ic_lucide_keyboard_off.xml | 78 ++++++++++++ .../res/drawable/ic_lucide_panel_bottom.xml | 15 +++ .../ic_lucide_picture_in_picture_2.xml | 15 +++ .../res/drawable/ic_lucide_text_select.xml | 113 ++++++++++++++++++ 12 files changed, 479 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/at/dasher/android/LucideIcons.kt create mode 100644 app/src/main/res/drawable/ic_lucide_arrow_left.xml create mode 100644 app/src/main/res/drawable/ic_lucide_arrow_right.xml create mode 100644 app/src/main/res/drawable/ic_lucide_clipboard_paste.xml create mode 100644 app/src/main/res/drawable/ic_lucide_copy.xml create mode 100644 app/src/main/res/drawable/ic_lucide_delete.xml create mode 100644 app/src/main/res/drawable/ic_lucide_keyboard_off.xml create mode 100644 app/src/main/res/drawable/ic_lucide_panel_bottom.xml create mode 100644 app/src/main/res/drawable/ic_lucide_picture_in_picture_2.xml create mode 100644 app/src/main/res/drawable/ic_lucide_text_select.xml diff --git a/app/src/main/java/at/dasher/android/DasherImeService.kt b/app/src/main/java/at/dasher/android/DasherImeService.kt index 0990c7f..db18974 100644 --- a/app/src/main/java/at/dasher/android/DasherImeService.kt +++ b/app/src/main/java/at/dasher/android/DasherImeService.kt @@ -50,7 +50,7 @@ class DasherImeService : InputMethodService() { // Floating mode private var floating = false private var floatingView: LinearLayout? = null - private var floatDockBtn: Button? = null + private var floatDockBtn: android.widget.ImageButton? = null private var emojiToggleBtn: Button? = null // Last-used mode (dock/float), restored on the next keyboard show so @@ -191,25 +191,38 @@ class DasherImeService : InputMethodService() { // Top bar (shared look): Float toggle + emoji mode + Hide. Mode // controls cluster here so the editing strip below stays a pure // edit-action row (Heide feedback: too many buttons up top). + // RFC 0002: Lucide icons on compact 48dp targets β€” the default + // Material text pills rendered huge next to the icon toolbar + // ("the dock buttons are massive"). val top = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL or Gravity.END setPadding(dp(8, density), dp(2, density), dp(8, density), dp(2, density)) } - val floatBtn = Button(this).apply { text = "Float" } + val floatBtn = iconButton(R.drawable.ic_lucide_picture_in_picture_2, "Float the keyboard") { enterFloatingMode() } // Emoji toggle (#61 option 2): GONE until createEngine() confirms // the data bundle ships the Emoji alphabet (DasherCore b00a4c8a+). + // Text (not icon): the label IS the state β€” "πŸ˜€" invites into emoji + // mode, "Abc" promises the way back. val emojiBtn = Button(this).apply { text = "πŸ˜€" contentDescription = "Switch to emoji keyboard" visibility = View.GONE + isAllCaps = false + minWidth = 0; minHeight = 0 + minimumWidth = 0; minimumHeight = 0 + setPadding(dp(10, density), dp(8, density), dp(10, density), dp(8, density)) + textSize = 15f + setTextColor(topBarTint()) + background = GradientDrawable().apply { + cornerRadius = dp(8, density).toFloat() + setColor(0) + setStroke(1, topBarBorder()) + } setOnClickListener { toggleEmojiMode() } } emojiToggleBtn = emojiBtn - val hideBtn = Button(this).apply { - text = "Hide" - setOnClickListener { requestHideSelf(0) } - } + val hideBtn = iconButton(R.drawable.ic_lucide_keyboard_off, "Hide the keyboard") { requestHideSelf(0) } top.addView(floatBtn) top.addView(emojiBtn) top.addView(hideBtn) @@ -332,7 +345,7 @@ class DasherImeService : InputMethodService() { setBackgroundColor(if (nightMode) 0xFF2A353D.toInt() else 0xFFE0E6E8.toInt()) setPadding(dp(8, density), dp(4, density), dp(8, density), dp(4, density)) } - val dockBtn = Button(this).apply { text = "Dock" } + val dockBtn = iconButton(R.drawable.ic_lucide_panel_bottom, "Dock the keyboard") { exitFloatingMode() } dragBar.addView(dockBtn) // Resize handle (#49): drag right/bottom edge to grow, left/top to shrink. val resizeHandle = Button(this).apply { @@ -401,8 +414,7 @@ class DasherImeService : InputMethodService() { setDockedHeight(dp(40, density)) saveStartMode(true) AnalyticsService.capture("ime_float_enter", mapOf("reason" to reason)) - floatDockBtn?.text = "Dock" - floatDockBtn?.setOnClickListener { exitFloatingMode() } + setFloatDockButton(docked = false) // Drag handling. Gravity.TOP or Gravity.LEFT β€” LEFT is explicit; // START can resolve to RIGHT on Samsung (overlay windows), inverting @@ -429,7 +441,6 @@ class DasherImeService : InputMethodService() { } // Also wire the dock button. dockBtn.setOnClickListener { exitFloatingMode() } - // 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 @@ -500,8 +511,7 @@ class DasherImeService : InputMethodService() { AnalyticsService.capture("ime_float_exit", mapOf( "persist" to persist, "reason" to reason, )) - floatDockBtn?.text = "Float" - floatDockBtn?.setOnClickListener { enterFloatingMode() } + setFloatDockButton(docked = true) canvasView?.let { if (it.width > 0 && it.height > 0) engine?.onSurfaceSizeChanged(it.width, it.height) } } @@ -852,6 +862,58 @@ class DasherImeService : InputMethodService() { // ── Utils ────────────────────────────────────────────────────────────── + /** + * RFC 0002 icon buttons for the IME's classic-View bars: compact 48dp + * target (design-guide touch-target token), Lucide glyph, no default + * Material pill chrome (which rendered oversized next to these rows). + */ + private fun iconButton( + iconRes: Int, + description: String, + onClick: () -> Unit, + ): android.widget.ImageButton { + val density = resources.displayMetrics.density + return android.widget.ImageButton(this).apply { + layoutParams = LinearLayout.LayoutParams(dp(48, density), dp(48, density)).apply { + marginStart = dp(2, density) + } + contentDescription = description + scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE + setImageDrawable(LucideIcons.tinted(context, iconRes, topBarTint())) + background = GradientDrawable().apply { + cornerRadius = dp(8, density).toFloat() + setColor(0) + setStroke(1, topBarBorder()) + } + setOnClickListener { onClick() } + } + } + + /** The Float⇄Dock toggle: icon + description + action swap with state. */ + private fun setFloatDockButton(docked: Boolean) { + val btn = floatDockBtn ?: return + btn.setImageDrawable( + LucideIcons.tinted( + this, + if (docked) R.drawable.ic_lucide_picture_in_picture_2 + else R.drawable.ic_lucide_panel_bottom, + topBarTint() + ) + ) + btn.contentDescription = if (docked) "Float the keyboard" else "Dock the keyboard" + btn.setOnClickListener { if (docked) enterFloatingMode() else exitFloatingMode() } + } + + private fun topBarTint(): Int = + if (isNightMode()) 0xFFE6EDF3.toInt() else 0xFF0F4B75.toInt() + + private fun topBarBorder(): Int = + if (isNightMode()) 0xFF3A4550.toInt() else 0xFFB8C4C8.toInt() + + private fun isNightMode(): Boolean = + (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == + android.content.res.Configuration.UI_MODE_NIGHT_YES + private fun dp(value: Int, density: Float) = (value * density).toInt() private companion object { diff --git a/app/src/main/java/at/dasher/android/EditingToolbar.kt b/app/src/main/java/at/dasher/android/EditingToolbar.kt index 4879689..b092420 100644 --- a/app/src/main/java/at/dasher/android/EditingToolbar.kt +++ b/app/src/main/java/at/dasher/android/EditingToolbar.kt @@ -3,10 +3,6 @@ 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 /** @@ -14,16 +10,21 @@ import android.widget.LinearLayout * 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. + * 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). * * Edit actions ONLY: mode toggles (emoji, float/dock) live in the IME's * top bar so this strip stays a predictable editing cluster. + * + * Icons: Lucide (RFC 0002), rasterised for these classic Views via + * [LucideIcons] β€” text labels ("⌫", "Sel", "Cp", "Ps") read as clutter and + * depended on glyph coverage; the icon set matches the main app and the + * cross-platform design guide. Touch targets stay β‰₯48dp (#48). */ class EditingToolbar( context: Context, - private val inputConnection: () -> InputConnection?, + private val inputConnection: () -> android.view.inputmethod.InputConnection?, private val onBufferChanged: () -> Unit, ) : LinearLayout(context) { @@ -32,6 +33,12 @@ class EditingToolbar( android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES + // Design-guide tokens (dasher-design-guide DESIGN.md): border-light/dark, + // rounded md = 8, text/onSurface navy. OnSurface ints live here because + // the View layer has no MaterialTheme to read from. + private val onSurface = if (nightMode) 0xFFE6EDF3.toInt() else 0xFF0F4B75.toInt() + private val border = if (nightMode) 0xFF3A4550.toInt() else 0xFFB8C4C8.toInt() + init { orientation = HORIZONTAL gravity = Gravity.CENTER_VERTICAL @@ -41,42 +48,36 @@ class EditingToolbar( // 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) } + addTool(R.drawable.ic_lucide_arrow_left, "Move cursor left", mutates = true) { moveCursor(-1) } + addTool(R.drawable.ic_lucide_arrow_right, "Move cursor right", mutates = true) { moveCursor(1) } addSpacer(dp(8)) - addTool("⌫", "Delete", large = true, mutates = true) { backspace() } + addTool(R.drawable.ic_lucide_delete, "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) } + addTool(R.drawable.ic_lucide_text_select, "Select all", mutates = false) { performAction(android.R.id.selectAll) } + addTool(R.drawable.ic_lucide_copy, "Copy", mutates = false) { performAction(android.R.id.copy) } + addTool(R.drawable.ic_lucide_clipboard_paste, "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)) + private fun toolButton(@Suppress("SameParameterValue") iconRes: Int, tooltip: String, large: Boolean): android.widget.ImageButton { + val size = dp(if (large) 56 else 48) + return android.widget.ImageButton(context).apply { + layoutParams = LayoutParams(size, size).apply { marginEnd = dp(2) } contentDescription = tooltip + scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE + setImageDrawable(LucideIcons.tinted(context, iconRes, onSurface)) background = GradientDrawable().apply { - cornerRadius = dp(6).toFloat() + cornerRadius = dp(8).toFloat() // design token rounded.md setColor(0) - setStroke(1, if (nightMode) 0xFF3A4550.toInt() else 0xFFB8C4C8.toInt()) - } - layoutParams = LayoutParams(width, LayoutParams.WRAP_CONTENT).apply { - marginEnd = dp(2) + setStroke(1, border) } } } - private fun addTool(label: String, tooltip: String, large: Boolean = false, - mutates: Boolean = true, action: () -> Unit) { - addView(toolButton(label, tooltip, large).apply { + private fun addTool(iconRes: Int, tooltip: String, + large: Boolean = false, mutates: Boolean = true, action: () -> Unit) { + addView(toolButton(iconRes, tooltip, large).apply { setOnClickListener { action() if (mutates) onBufferChanged() @@ -85,7 +86,7 @@ class EditingToolbar( } private fun addSpacer(widthPx: Int) { - addView(View(context).apply { + addView(android.view.View(context).apply { layoutParams = LayoutParams(widthPx, 1) }) } @@ -111,9 +112,9 @@ class EditingToolbar( /** 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)) + val keyCode = if (direction < 0) android.view.KeyEvent.KEYCODE_DPAD_LEFT else android.view.KeyEvent.KEYCODE_DPAD_RIGHT + ic.sendKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, keyCode)) + ic.sendKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, keyCode)) } /** Clipboard bridge: Select All / Copy / Paste via the target's context menu (#50). */ diff --git a/app/src/main/java/at/dasher/android/LucideIcons.kt b/app/src/main/java/at/dasher/android/LucideIcons.kt new file mode 100644 index 0000000..0f1e86c --- /dev/null +++ b/app/src/main/java/at/dasher/android/LucideIcons.kt @@ -0,0 +1,26 @@ +package at.dasher.android + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat + +/** + * RFC 0002 (Lucide icons) for the IME's classic-View toolbars. The main app + * renders Lucide via the Compose library; the IME is built from LinearLayouts + * and Buttons (no composition, no lifecycle owner), so the same icons are + * baked as tintable vector drawables (res/drawable/ic_lucide_*.xml, generated + * from lucide-static, ISC licence) and tinted here. One icon set, one visual + * language, per the cross-platform design guide. + */ +object LucideIcons { + + /** + * @param tint applied via mutate()+setTint so shared drawable constants + * are never re-tinted across widgets. + */ + fun tinted(context: Context, @DrawableRes res: Int, tint: Int): Drawable = + requireNotNull(ContextCompat.getDrawable(context, res)) { + "Lucide drawable $res missing" + }.mutate().apply { setTint(tint) } +} diff --git a/app/src/main/res/drawable/ic_lucide_arrow_left.xml b/app/src/main/res/drawable/ic_lucide_arrow_left.xml new file mode 100644 index 0000000..56752c2 --- /dev/null +++ b/app/src/main/res/drawable/ic_lucide_arrow_left.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_lucide_arrow_right.xml b/app/src/main/res/drawable/ic_lucide_arrow_right.xml new file mode 100644 index 0000000..3db60ab --- /dev/null +++ b/app/src/main/res/drawable/ic_lucide_arrow_right.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_lucide_clipboard_paste.xml b/app/src/main/res/drawable/ic_lucide_clipboard_paste.xml new file mode 100644 index 0000000..64a1382 --- /dev/null +++ b/app/src/main/res/drawable/ic_lucide_clipboard_paste.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_lucide_copy.xml b/app/src/main/res/drawable/ic_lucide_copy.xml new file mode 100644 index 0000000..ce44d2e --- /dev/null +++ b/app/src/main/res/drawable/ic_lucide_copy.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_lucide_delete.xml b/app/src/main/res/drawable/ic_lucide_delete.xml new file mode 100644 index 0000000..108f868 --- /dev/null +++ b/app/src/main/res/drawable/ic_lucide_delete.xml @@ -0,0 +1,29 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_lucide_keyboard_off.xml b/app/src/main/res/drawable/ic_lucide_keyboard_off.xml new file mode 100644 index 0000000..c3bdb67 --- /dev/null +++ b/app/src/main/res/drawable/ic_lucide_keyboard_off.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_lucide_panel_bottom.xml b/app/src/main/res/drawable/ic_lucide_panel_bottom.xml new file mode 100644 index 0000000..2f62880 --- /dev/null +++ b/app/src/main/res/drawable/ic_lucide_panel_bottom.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_lucide_picture_in_picture_2.xml b/app/src/main/res/drawable/ic_lucide_picture_in_picture_2.xml new file mode 100644 index 0000000..6fbe981 --- /dev/null +++ b/app/src/main/res/drawable/ic_lucide_picture_in_picture_2.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_lucide_text_select.xml b/app/src/main/res/drawable/ic_lucide_text_select.xml new file mode 100644 index 0000000..1f0e4c0 --- /dev/null +++ b/app/src/main/res/drawable/ic_lucide_text_select.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + From 739458fe205b753702999cdd6d4a4a640d756840 Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 27 Oct 2026 07:10:16 +0000 Subject: [PATCH 2/5] feat(ime): always docked (no fullscreen extract) + press states + icon tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback from the first hands-on pass on the Lucide toolbars: - 'I get fullscreen Dasher but I can't see the search field': Android's default extract mode takes the whole screen in landscape and hides the app behind the IME. onEvaluateFullscreenMode() now returns false β€” the keyboard docks at the bottom in every orientation, matching the product stance (Heide: 'start it directly as docked') and how Windows/macOS Dasher behave. - 'Can't tell what the icons mean / no hover state': the border-only button backgrounds had no state list β€” zero touch feedback. Both toolbars now use a bordered pill with a pressed fill (design tokens: rounded.md 8dp, border-light/dark, highlight-light/dark), and every icon carries a long-press tooltip (API 26+) alongside its existing contentDescription β€” the touch equivalent of the Windows/macOS hover labels. Signed-off-by: will wade --- .../at/dasher/android/DasherImeService.kt | 51 +++++++++++++++---- .../java/at/dasher/android/EditingToolbar.kt | 22 +++++--- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/at/dasher/android/DasherImeService.kt b/app/src/main/java/at/dasher/android/DasherImeService.kt index db18974..443bb7c 100644 --- a/app/src/main/java/at/dasher/android/DasherImeService.kt +++ b/app/src/main/java/at/dasher/android/DasherImeService.kt @@ -214,11 +214,8 @@ class DasherImeService : InputMethodService() { setPadding(dp(10, density), dp(8, density), dp(10, density), dp(8, density)) textSize = 15f setTextColor(topBarTint()) - background = GradientDrawable().apply { - cornerRadius = dp(8, density).toFloat() - setColor(0) - setStroke(1, topBarBorder()) - } + background = pillBackground() + tooltipTextCompat("Switch to emoji keyboard") setOnClickListener { toggleEmojiMode() } } emojiToggleBtn = emojiBtn @@ -747,6 +744,13 @@ class DasherImeService : InputMethodService() { if (skippedDeletion) reanchorEngineToTarget() } + // Always docked, never Android's fullscreen extract mode: in landscape + // the default IMS behavior replaces the whole screen with the IME and + // hides the app's text field ("I get fullscreen Dasher but I can't see + // search field"). The docked canvas is the product (Heide: "start it + // directly as docked") β€” landscape docks at the bottom like portrait. + override fun onEvaluateFullscreenMode(): Boolean = false + // API 36+ (Android 16): measure the actual docked-root position in the // window so the insets animation has a concrete target (review C1/C3: // imeHeightPx() is a HEIGHT, not an offset β€” a constant created a dead @@ -880,15 +884,37 @@ class DasherImeService : InputMethodService() { contentDescription = description scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE setImageDrawable(LucideIcons.tinted(context, iconRes, topBarTint())) - background = GradientDrawable().apply { - cornerRadius = dp(8, density).toFloat() - setColor(0) - setStroke(1, topBarBorder()) - } + background = pillBackground() + tooltipTextCompat(description) setOnClickListener { onClick() } } } + /** + * The toolbars' bordered pill with a real pressed state (design tokens: + * rounded.md 8dp, border-light/dark, highlight-light/dark as the press + * fill) β€” the border-only GradientDrawable gave no touch feedback. + */ + private fun pillBackground(cornerRadiusDp: Int = 8): android.graphics.drawable.StateListDrawable { + val density = resources.displayMetrics.density + val r = dp(cornerRadiusDp, density).toFloat() + return android.graphics.drawable.StateListDrawable().apply { + addState( + intArrayOf(android.R.attr.state_pressed), + GradientDrawable().apply { cornerRadius = r; setColor(pressFill()) } + ) + addState( + intArrayOf(), + GradientDrawable().apply { cornerRadius = r; setColor(0); setStroke(1, topBarBorder()) } + ) + } + } + + /** Long-press tooltip (API 26+; the Windows/macOS hover label equivalent on touch). */ + private fun android.view.View.tooltipTextCompat(text: String) { + if (android.os.Build.VERSION.SDK_INT >= 26) tooltipText = text + } + /** The Float⇄Dock toggle: icon + description + action swap with state. */ private fun setFloatDockButton(docked: Boolean) { val btn = floatDockBtn ?: return @@ -901,6 +927,7 @@ class DasherImeService : InputMethodService() { ) ) btn.contentDescription = if (docked) "Float the keyboard" else "Dock the keyboard" + btn.tooltipTextCompat(btn.contentDescription.toString()) btn.setOnClickListener { if (docked) enterFloatingMode() else exitFloatingMode() } } @@ -910,6 +937,10 @@ class DasherImeService : InputMethodService() { private fun topBarBorder(): Int = if (isNightMode()) 0xFF3A4550.toInt() else 0xFFB8C4C8.toInt() + /** Design-token highlight (press feedback): highlight-light/dark. */ + private fun pressFill(): Int = + if (isNightMode()) 0xFF1D2D35.toInt() else 0xFFE9F2F1.toInt() + private fun isNightMode(): Boolean = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES diff --git a/app/src/main/java/at/dasher/android/EditingToolbar.kt b/app/src/main/java/at/dasher/android/EditingToolbar.kt index b092420..13cc9c5 100644 --- a/app/src/main/java/at/dasher/android/EditingToolbar.kt +++ b/app/src/main/java/at/dasher/android/EditingToolbar.kt @@ -34,10 +34,11 @@ class EditingToolbar( android.content.res.Configuration.UI_MODE_NIGHT_YES // Design-guide tokens (dasher-design-guide DESIGN.md): border-light/dark, - // rounded md = 8, text/onSurface navy. OnSurface ints live here because - // the View layer has no MaterialTheme to read from. + // rounded md = 8, highlight as press fill, text/onSurface navy. OnSurface + // ints live here because the View layer has no MaterialTheme to read from. private val onSurface = if (nightMode) 0xFFE6EDF3.toInt() else 0xFF0F4B75.toInt() private val border = if (nightMode) 0xFF3A4550.toInt() else 0xFFB8C4C8.toInt() + private val pressFill = if (nightMode) 0xFF1D2D35.toInt() else 0xFFE9F2F1.toInt() init { orientation = HORIZONTAL @@ -67,11 +68,20 @@ class EditingToolbar( contentDescription = tooltip scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE setImageDrawable(LucideIcons.tinted(context, iconRes, onSurface)) - background = GradientDrawable().apply { - cornerRadius = dp(8).toFloat() // design token rounded.md - setColor(0) - setStroke(1, border) + // Bordered pill with a real pressed state β€” the flat + // GradientDrawable gave no touch feedback ("can't tell what + // the icons mean / no hover state"). + background = android.graphics.drawable.StateListDrawable().apply { + addState( + intArrayOf(android.R.attr.state_pressed), + GradientDrawable().apply { cornerRadius = dp(8).toFloat(); setColor(pressFill) } + ) + addState( + intArrayOf(), + GradientDrawable().apply { cornerRadius = dp(8).toFloat(); setColor(0); setStroke(1, border) } + ) } + if (android.os.Build.VERSION.SDK_INT >= 26) tooltipText = tooltip } } From de06af0677e800738c5106943f5d99ab675658e2 Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 27 Oct 2026 07:44:10 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix(ime):=20docked=20height=20re-applied=20?= =?UTF-8?q?after=20the=20framework=20stamps=20its=20own=20LayoutParams=20?= =?UTF-8?q?=E2=80=94=20the=20real=20full-screen=20IME=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E sweep finding (API 35, pixel-verified): onCreateInputView sets the docked root's FrameLayout.LayoutParams(1007px, Gravity.BOTTOM) BEFORE attachment (parent=null) β€” then the IME framework STAMPS its own MATCH_PARENTΓ—WRAP_CONTENT params over them when attaching the view to the decor. Measured: root laid out 1080x2272 at [0,0] β€” toolbar over the status bar, canvas swallowing the app: 'fullscreen Dasher, can't see the search field'. #75's Gravity.BOTTOM never actually applied; its API-33 'verification' passed because that layout path re-docks. Fix: setDockedHeight runs again in the posted handler β€” post-attachment, post-stamp β€” where the parent is real and the params stick. Verified: toolbar strip at y1400-1540 (bottom), app visible above, in portrait AND landscape, surviving rotation and IME switches. Also: re-push setSystemAppearance on uiMode config changes (RFC 0007 β€” mid-session dark flips now re-derive the palette for System-palette users instead of waiting for engine recreation), plus setDockedHeight/ after-layout Log.d diagnostics from the sweep. Signed-off-by: will wade --- .../at/dasher/android/DasherImeService.kt | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/at/dasher/android/DasherImeService.kt b/app/src/main/java/at/dasher/android/DasherImeService.kt index 443bb7c..29cd4dc 100644 --- a/app/src/main/java/at/dasher/android/DasherImeService.kt +++ b/app/src/main/java/at/dasher/android/DasherImeService.kt @@ -130,6 +130,12 @@ class DasherImeService : InputMethodService() { // presented exactly as "the IME still starts in float mode" (Heide, // three consecutive reports, repro'd on the API 35 emulator). private fun setDockedHeight(heightPx: Int) { + dockedRoot?.let { root -> + // #76 diagnostics: which container the IME decor actually gives + // us determines the LayoutParams type that will be honoured. + Log.d(TAG, "setDockedHeight($heightPx): parent=${root.parent?.javaClass?.simpleName}, " + + "lp=${root.layoutParams?.javaClass?.simpleName}(${root.layoutParams?.width}x${root.layoutParams?.height})") + } dockedRoot?.layoutParams = android.widget.FrameLayout.LayoutParams( MATCH_PARENT, heightPx, Gravity.BOTTOM ) @@ -254,6 +260,20 @@ class DasherImeService : InputMethodService() { floatBtn.setOnClickListener { enterFloatingMode() } Handler(Looper.getMainLooper()).post { + // #76 (the real one): the IME framework STAMPS its own + // MATCH_PARENTΓ—MATCH_PARENT LayoutParams over our view when it + // attaches it to the decor β€” the height/gravity we set in + // onCreateInputView (before attachment, parent=null) is + // discarded. Measured symptom on API 35: the "docked" root + // laid out full-screen at [0,0] β€” toolbar over the app's + // status bar, canvas swallowing everything (Heide's "big + // float-like window you can't type on" and Will's "fullscreen + // Dasher, can't see the search field" are both THIS). Re-apply + // now β€” post-attachment, post-stamp β€” while parent is real. + setDockedHeight(imeHeightPx()) + Log.d(TAG, "after-layout: root=${dockedRoot?.width}x${dockedRoot?.height} " + + "at (${dockedRoot?.let { r -> IntArray(2).also { r.getLocationInWindow(it) } }?.contentToString()}), " + + "parent=${dockedRoot?.parent?.javaClass?.simpleName}") createEngine() // Self-heal a stale floating state BEFORE evaluating the saved // preference (#73). onCreateInputView can run again on a LIVE @@ -636,6 +656,13 @@ class DasherImeService : InputMethodService() { override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { super.onConfigurationChanged(newConfig) + // RFC 0007: dark/light mode flips mid-session must re-derive the + // engine palette β€” setSystemAppearance was only pushed at engine + // creation, so the toolbar went dark while the canvas stayed light + // (E2E sweep finding). Same re-push MainActivity does. + val night = (newConfig.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == + android.content.res.Configuration.UI_MODE_NIGHT_YES + engine?.setSystemAppearance(night) // #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 @@ -707,8 +734,10 @@ class DasherImeService : InputMethodService() { * are dropped on a fresh field start β€” onStartInput.) */ private fun flushOutputToTarget() { - val ic = currentInputConnection ?: return - val flush = outputCoalescer.takeFlush() ?: return + val ic = currentInputConnection + val flush = outputCoalescer.takeFlush() + Log.d(TAG, "flush: ic=${ic != null} ${flush ?: "(nothing pending)"}") + if (ic == null || flush == null) return var skippedDeletion = false ic.beginBatchEdit() try { From 35a369618cad2adebb6d8836d0cb81fe35107ec4 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 28 Oct 2026 07:16:06 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix(ime):=20review=20loop=201=20=E2=80=94?= =?UTF-8?q?=20restore=20flush=20pending-preservation,=20complete=20icon=20?= =?UTF-8?q?geometry,=2048dp=20emoji=20target,=20stamp=20defense-in-depth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the #78 review (5/10): - C1: the flush instrumentation consumed the pending edit even when the InputConnection was null (takeFlush resets the coalescer) β€” silent text loss, introduced for a log line, contradicting the contract documented three lines above. Consumption now gated on the connection; the log only fires when there is something to flush (also fixes the per-frame Log.d spam, M3). - C2: the SVGβ†’vector generator dropped every β€” 4 of 9 icons (copy, panel-bottom, picture-in-picture-2, clipboard-paste) rendered as broken glyphs; the Dock button was a single horizontal line. Generator converts rects to rounded-rect stroke paths; all 9 regenerated (verified counts: paste 5 parts, copy/pip2/panel 2 each). - I1: emoji toggle back to a 48dp minimum target (compact pill rendering, full-size hit area). - I2: setDockedHeight re-asserted in onStartInputView β€” belt to the posted-handler re-apply for OEM re-attach paths. - I3: emoji tooltip swaps with mode (was stale 'Switch to emoji keyboard' while in emoji mode). - M1: pillBackground doc no longer claims the border hexes are the border token (they are a darkened variant β€” the token doubles as the strip background); ImeToolbarPalette extracted as the single definition (M4); dead duplicate listener removed (M2); misplaced @Suppress dropped (M5). Signed-off-by: will wade --- .../at/dasher/android/DasherImeService.kt | 50 ++++++++++++------- .../java/at/dasher/android/EditingToolbar.kt | 13 +++-- .../at/dasher/android/ImeToolbarPalette.kt | 22 ++++++++ .../drawable/ic_lucide_clipboard_paste.xml | 7 +++ app/src/main/res/drawable/ic_lucide_copy.xml | 7 +++ .../res/drawable/ic_lucide_panel_bottom.xml | 7 +++ .../ic_lucide_picture_in_picture_2.xml | 7 +++ 7 files changed, 87 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/at/dasher/android/ImeToolbarPalette.kt diff --git a/app/src/main/java/at/dasher/android/DasherImeService.kt b/app/src/main/java/at/dasher/android/DasherImeService.kt index 29cd4dc..52254a7 100644 --- a/app/src/main/java/at/dasher/android/DasherImeService.kt +++ b/app/src/main/java/at/dasher/android/DasherImeService.kt @@ -105,6 +105,7 @@ class DasherImeService : InputMethodService() { imePrefs.edit().remove(ImePrefs.EMOJI_RETURN_ALPHABET).apply() btn.text = "πŸ˜€" btn.contentDescription = "Switch to emoji keyboard" + if (android.os.Build.VERSION.SDK_INT >= 26) btn.tooltipText = btn.contentDescription // The alphabet switch rebuilt the model from scratch β€” re-seed // from the target field so predictions continue mid-sentence // (same machinery as the editing actions, RFC 0015 tier 2). @@ -115,6 +116,7 @@ class DasherImeService : InputMethodService() { eng.setAlphabet("Emoji") btn.text = "Abc" btn.contentDescription = "Switch back to text keyboard" + if (android.os.Build.VERSION.SDK_INT >= 26) btn.tooltipText = btn.contentDescription } } @@ -215,9 +217,12 @@ class DasherImeService : InputMethodService() { contentDescription = "Switch to emoji keyboard" visibility = View.GONE isAllCaps = false - minWidth = 0; minHeight = 0 - minimumWidth = 0; minimumHeight = 0 + minWidth = 0 setPadding(dp(10, density), dp(8, density), dp(10, density), dp(8, density)) + // 48dp minimum touch target (#48) even though the pill renders + // compact (#78 review I1): the hit area is the target, not the glyph. + minimumWidth = dp(48, density) + minimumHeight = dp(48, density) textSize = 15f setTextColor(topBarTint()) background = pillBackground() @@ -256,8 +261,9 @@ class DasherImeService : InputMethodService() { setDockedHeight(imeHeightPx()) root.minimumHeight = imeHeightPx() + // iconButton() already wired the click; only the field ref is needed + // here (setFloatDockButton swaps icon/description/action with state). floatDockBtn = floatBtn - floatBtn.setOnClickListener { enterFloatingMode() } Handler(Looper.getMainLooper()).post { // #76 (the real one): the IME framework STAMPS its own @@ -734,10 +740,14 @@ class DasherImeService : InputMethodService() { * are dropped on a fresh field start β€” onStartInput.) */ private fun flushOutputToTarget() { - val ic = currentInputConnection - val flush = outputCoalescer.takeFlush() - Log.d(TAG, "flush: ic=${ic != null} ${flush ?: "(nothing pending)"}") - if (ic == null || flush == null) return + // Gate consumption on the connection (#78 review C1): takeFlush() + // RESETS the coalescer, so calling it when the connection is + // momentarily null (WebView focus churn β€” see ImeOutputCoalescer's + // type-2 note) would discard the user's pending text. Hold it for + // the next frame instead, exactly as the contract below documents. + val ic = currentInputConnection ?: return + val flush = outputCoalescer.takeFlush() ?: return + Log.d(TAG, "flush: ${flush.deleteUnits}u del + ${flush.insert.length}ch") var skippedDeletion = false ic.beginBatchEdit() try { @@ -848,6 +858,12 @@ class DasherImeService : InputMethodService() { override fun onStartInputView(info: EditorInfo?, restarting: Boolean) { super.onStartInputView(info, restarting) Log.d(TAG, "onStartInputView(restarting=$restarting, floating=$floating)") + // #78 review I2 β€” defense-in-depth for the LayoutParams stamp: the + // framework replaces our docked-root LayoutParams on attach; the + // posted-handler re-apply covers the attach, this covers any later + // re-attach/edge the post could miss. Every show re-asserts the + // docked geometry while docked (no-op when already correct). + if (!floating) setDockedHeight(imeHeightPx()) // Re-assert ALL IME-owned static listeners β€” the main app may have // overwritten them (#53: output, clipboard, messages, speak). installImeListeners() @@ -920,9 +936,11 @@ class DasherImeService : InputMethodService() { } /** - * The toolbars' bordered pill with a real pressed state (design tokens: - * rounded.md 8dp, border-light/dark, highlight-light/dark as the press - * fill) β€” the border-only GradientDrawable gave no touch feedback. + * The toolbars' bordered pill with a real pressed state β€” rounded.md 8dp + * and the highlight tokens for the press fill. The STROKE deliberately + * uses a darkened border variant (#B8C4C8/#3A4550), not the border + * token itself (#E0E6E8/#2A353D): the token doubles as the toolbar + * strip background, and a same-colour stroke on it is invisible. */ private fun pillBackground(cornerRadiusDp: Int = 8): android.graphics.drawable.StateListDrawable { val density = resources.displayMetrics.density @@ -960,15 +978,9 @@ class DasherImeService : InputMethodService() { btn.setOnClickListener { if (docked) enterFloatingMode() else exitFloatingMode() } } - private fun topBarTint(): Int = - if (isNightMode()) 0xFFE6EDF3.toInt() else 0xFF0F4B75.toInt() - - private fun topBarBorder(): Int = - if (isNightMode()) 0xFF3A4550.toInt() else 0xFFB8C4C8.toInt() - - /** Design-token highlight (press feedback): highlight-light/dark. */ - private fun pressFill(): Int = - if (isNightMode()) 0xFF1D2D35.toInt() else 0xFFE9F2F1.toInt() + private fun topBarTint(): Int = ImeToolbarPalette.onSurface(isNightMode()) + private fun topBarBorder(): Int = ImeToolbarPalette.border(isNightMode()) + private fun pressFill(): Int = ImeToolbarPalette.pressFill(isNightMode()) private fun isNightMode(): Boolean = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == diff --git a/app/src/main/java/at/dasher/android/EditingToolbar.kt b/app/src/main/java/at/dasher/android/EditingToolbar.kt index 13cc9c5..909f4c5 100644 --- a/app/src/main/java/at/dasher/android/EditingToolbar.kt +++ b/app/src/main/java/at/dasher/android/EditingToolbar.kt @@ -33,12 +33,11 @@ class EditingToolbar( android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES - // Design-guide tokens (dasher-design-guide DESIGN.md): border-light/dark, - // rounded md = 8, highlight as press fill, text/onSurface navy. OnSurface - // ints live here because the View layer has no MaterialTheme to read from. - private val onSurface = if (nightMode) 0xFFE6EDF3.toInt() else 0xFF0F4B75.toInt() - private val border = if (nightMode) 0xFF3A4550.toInt() else 0xFFB8C4C8.toInt() - private val pressFill = if (nightMode) 0xFF1D2D35.toInt() else 0xFFE9F2F1.toInt() + // Design-guide tokens via the shared palette (#78 review M4): see + // ImeToolbarPalette β€” one definition, used by both IME toolbars. + private val onSurface = ImeToolbarPalette.onSurface(nightMode) + private val border = ImeToolbarPalette.border(nightMode) + private val pressFill = ImeToolbarPalette.pressFill(nightMode) init { orientation = HORIZONTAL @@ -61,7 +60,7 @@ class EditingToolbar( private fun dp(v: Int) = (v * density).toInt() - private fun toolButton(@Suppress("SameParameterValue") iconRes: Int, tooltip: String, large: Boolean): android.widget.ImageButton { + private fun toolButton(iconRes: Int, tooltip: String, large: Boolean): android.widget.ImageButton { val size = dp(if (large) 56 else 48) return android.widget.ImageButton(context).apply { layoutParams = LayoutParams(size, size).apply { marginEnd = dp(2) } diff --git a/app/src/main/java/at/dasher/android/ImeToolbarPalette.kt b/app/src/main/java/at/dasher/android/ImeToolbarPalette.kt new file mode 100644 index 0000000..eb0bdb0 --- /dev/null +++ b/app/src/main/java/at/dasher/android/ImeToolbarPalette.kt @@ -0,0 +1,22 @@ +package at.dasher.android + +/** + * Design-guide tokens (dasher-design-guide DESIGN.md) for the IME's classic- + * View toolbars, as one definition shared by DasherImeService and + * EditingToolbar (#78 review M4 β€” the hexes previously lived twice and a + * third copy would have drifted). + * + * The border stroke is deliberately a DARKENED variant of the border token + * (#E0E6E8/#2A353D): the token itself doubles as the toolbar strip + * background, and a same-colour stroke on it is invisible. + */ +object ImeToolbarPalette { + fun onSurface(nightMode: Boolean): Int = + if (nightMode) 0xFFE6EDF3.toInt() else 0xFF0F4B75.toInt() // text token + + fun border(nightMode: Boolean): Int = + if (nightMode) 0xFF3A4550.toInt() else 0xFFB8C4C8.toInt() // darkened border + + fun pressFill(nightMode: Boolean): Int = + if (nightMode) 0xFF1D2D35.toInt() else 0xFFE9F2F1.toInt() // highlight tokens +} diff --git a/app/src/main/res/drawable/ic_lucide_clipboard_paste.xml b/app/src/main/res/drawable/ic_lucide_clipboard_paste.xml index 64a1382..bae666c 100644 --- a/app/src/main/res/drawable/ic_lucide_clipboard_paste.xml +++ b/app/src/main/res/drawable/ic_lucide_clipboard_paste.xml @@ -33,4 +33,11 @@ android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" /> + diff --git a/app/src/main/res/drawable/ic_lucide_copy.xml b/app/src/main/res/drawable/ic_lucide_copy.xml index ce44d2e..943e742 100644 --- a/app/src/main/res/drawable/ic_lucide_copy.xml +++ b/app/src/main/res/drawable/ic_lucide_copy.xml @@ -12,4 +12,11 @@ android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" /> + diff --git a/app/src/main/res/drawable/ic_lucide_panel_bottom.xml b/app/src/main/res/drawable/ic_lucide_panel_bottom.xml index 2f62880..4fabd92 100644 --- a/app/src/main/res/drawable/ic_lucide_panel_bottom.xml +++ b/app/src/main/res/drawable/ic_lucide_panel_bottom.xml @@ -12,4 +12,11 @@ android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" /> + diff --git a/app/src/main/res/drawable/ic_lucide_picture_in_picture_2.xml b/app/src/main/res/drawable/ic_lucide_picture_in_picture_2.xml index 6fbe981..6320077 100644 --- a/app/src/main/res/drawable/ic_lucide_picture_in_picture_2.xml +++ b/app/src/main/res/drawable/ic_lucide_picture_in_picture_2.xml @@ -12,4 +12,11 @@ android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" /> + From a53c13e386efbe2dd15e2d47f94e5757c487c566 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 28 Oct 2026 07:28:18 +0000 Subject: [PATCH 5/5] =?UTF-8?q?chore(ime):=20review=20loop=202=20polish=20?= =?UTF-8?q?=E2=80=94=20emoji-restore=20tooltip,=20icon=20generator=20check?= =?UTF-8?q?ed=20in,=20cleanups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The third emoji-label site (createEngine's process-death restore) now updates tooltipText with the label/description (loop-2 M2 β€” same class as loop-1 I3). - scripts/gen_lucide_icons.py checked in β€” provenance for the 9 IME icon drawables; fails loudly on zero-geometry parses. The generator that dropped s was never in the repo; the next icon would have re-broken it. - pillBackground's never-overridden cornerRadiusDp inlined; the FQNs from the icon import purge restored to imports in EditingToolbar. - Night-mode mid-show flip verified in the E2E sweep (toolbar strip went to the dark token after 'cmd uimode night yes' with the keyboard up β€” the input view IS recreated on uiMode changes, so the construction-time palette vals re-derive). Signed-off-by: will wade --- .../at/dasher/android/DasherImeService.kt | 9 ++- .../java/at/dasher/android/EditingToolbar.kt | 15 ++-- scripts/gen_lucide_icons.py | 71 +++++++++++++++++++ 3 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 scripts/gen_lucide_icons.py diff --git a/app/src/main/java/at/dasher/android/DasherImeService.kt b/app/src/main/java/at/dasher/android/DasherImeService.kt index 52254a7..ba346e3 100644 --- a/app/src/main/java/at/dasher/android/DasherImeService.kt +++ b/app/src/main/java/at/dasher/android/DasherImeService.kt @@ -627,6 +627,11 @@ class DasherImeService : InputMethodService() { } emojiToggleBtn?.text = "Abc" emojiToggleBtn?.contentDescription = "Switch back to text keyboard" + // Review loop-2 M2: the third emoji-label site (process death + // mid-emoji-mode restore) β€” keep the tooltip in step too. + if (android.os.Build.VERSION.SDK_INT >= 26) { + emojiToggleBtn?.tooltipText = emojiToggleBtn?.contentDescription + } } eng.start() } @@ -942,9 +947,9 @@ class DasherImeService : InputMethodService() { * token itself (#E0E6E8/#2A353D): the token doubles as the toolbar * strip background, and a same-colour stroke on it is invisible. */ - private fun pillBackground(cornerRadiusDp: Int = 8): android.graphics.drawable.StateListDrawable { + private fun pillBackground(): android.graphics.drawable.StateListDrawable { val density = resources.displayMetrics.density - val r = dp(cornerRadiusDp, density).toFloat() + val r = dp(8, density).toFloat() // design token rounded.md return android.graphics.drawable.StateListDrawable().apply { addState( intArrayOf(android.R.attr.state_pressed), diff --git a/app/src/main/java/at/dasher/android/EditingToolbar.kt b/app/src/main/java/at/dasher/android/EditingToolbar.kt index 909f4c5..17205f1 100644 --- a/app/src/main/java/at/dasher/android/EditingToolbar.kt +++ b/app/src/main/java/at/dasher/android/EditingToolbar.kt @@ -2,6 +2,9 @@ package at.dasher.android import android.content.Context import android.graphics.drawable.GradientDrawable +import android.view.KeyEvent +import android.view.inputmethod.InputConnection +import android.widget.ImageButton import android.view.Gravity import android.widget.LinearLayout @@ -24,7 +27,7 @@ import android.widget.LinearLayout */ class EditingToolbar( context: Context, - private val inputConnection: () -> android.view.inputmethod.InputConnection?, + private val inputConnection: () -> InputConnection?, private val onBufferChanged: () -> Unit, ) : LinearLayout(context) { @@ -60,9 +63,9 @@ class EditingToolbar( private fun dp(v: Int) = (v * density).toInt() - private fun toolButton(iconRes: Int, tooltip: String, large: Boolean): android.widget.ImageButton { + private fun toolButton(iconRes: Int, tooltip: String, large: Boolean): ImageButton { val size = dp(if (large) 56 else 48) - return android.widget.ImageButton(context).apply { + return ImageButton(context).apply { layoutParams = LayoutParams(size, size).apply { marginEnd = dp(2) } contentDescription = tooltip scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE @@ -121,9 +124,9 @@ class EditingToolbar( /** 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) android.view.KeyEvent.KEYCODE_DPAD_LEFT else android.view.KeyEvent.KEYCODE_DPAD_RIGHT - ic.sendKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, keyCode)) - ic.sendKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, keyCode)) + 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). */ diff --git a/scripts/gen_lucide_icons.py b/scripts/gen_lucide_icons.py new file mode 100644 index 0000000..228ad59 --- /dev/null +++ b/scripts/gen_lucide_icons.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Generate Android vector drawables from Lucide SVGs (RFC 0002). + +Provenance for app/src/main/res/drawable/ic_lucide_*.xml β€” the icons the +IME's classic-View toolbars render. Checked in after rev 1 of the IME icon +work silently dropped every (4 of 9 icons rendered as broken +glyphs); rerun + diff to regenerate when icons change: + + ./scripts/gen_lucide_icons.py icons/ # dir of lucide-static SVGs + # or fetch: curl -sfL https://unpkg.com/lucide-static@1.62.0/icons/.svg + +Lucide is ISC-licensed; keep the licence header the template writes. +""" +import os +import re +import sys + +TEMPLATE = ''' + +{paths} + +''' + +PATH_T = ' ' + + +def rect_to_path(x, y, w, h, rx): + """Rounded rect as a stroke path β€” has no vector-drawable equivalent.""" + if rx > 0: + return (f"M{x+rx} {y} h{w-2*rx} a{rx} {rx} 0 0 1 {rx} {rx} v{h-2*rx} " + f"a{rx} {rx} 0 0 1 {-rx} {rx} h{-(w-2*rx)} a{rx} {rx} 0 0 1 {-rx} {-rx} " + f"v{-(h-2*rx)} a{rx} {rx} 0 0 1 {rx} {-rx} z") + return f"M{x} {y} h{w} v{h} h{-w} z" + + +def parse_svg(svg): + parts = list(re.findall(r'', svg): + attrs = dict(re.findall(r'(\w+)="([^"]+)"', m.group(1))) + x = float(attrs.get('x', 0)) + y = float(attrs.get('y', 0)) + w = float(attrs.get('width', 0)) + h = float(attrs.get('height', 0)) + rx = float(attrs.get('rx', attrs.get('ry', 0))) + parts.append(rect_to_path(x, y, w, h, rx)) + return parts + + +def main(src_dir, out_dir): + os.makedirs(out_dir, exist_ok=True) + for f in sorted(os.listdir(src_dir)): + if not f.endswith('.svg'): + continue + name = f[:-4] + parts = parse_svg(open(os.path.join(src_dir, f)).read()) + if not parts: + sys.exit(f"NO GEOMETRY PARSED from {f} β€” generator or source broken") + xml = TEMPLATE.format(name=name, paths="\n".join(PATH_T.format(d=d) for d in parts)) + out = os.path.join(out_dir, "ic_lucide_" + name.replace('-', '_') + ".xml") + open(out, 'w').write(xml) + print(f"{name}: {len(parts)} pathDatas -> {os.path.basename(out)}") + + +if __name__ == '__main__': + main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else + "app/src/main/res/drawable")