diff --git a/app/src/main/java/at/dasher/android/DasherImeService.kt b/app/src/main/java/at/dasher/android/DasherImeService.kt
index 0990c7f..ba346e3 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
@@ -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
}
}
@@ -130,6 +132,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
)
@@ -191,25 +199,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
+ 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()
+ tooltipTextCompat("Switch to emoji keyboard")
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)
@@ -240,10 +261,25 @@ 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
+ // 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
@@ -332,7 +368,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 +437,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 +464,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 +534,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) }
}
@@ -594,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()
}
@@ -629,6 +667,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
@@ -700,8 +745,14 @@ class DasherImeService : InputMethodService() {
* are dropped on a fresh field start β onStartInput.)
*/
private fun flushOutputToTarget() {
+ // 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 {
@@ -737,6 +788,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
@@ -805,6 +863,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()
@@ -852,6 +916,81 @@ 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 = pillBackground()
+ tooltipTextCompat(description)
+ setOnClickListener { onClick() }
+ }
+ }
+
+ /**
+ * 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(): android.graphics.drawable.StateListDrawable {
+ val density = resources.displayMetrics.density
+ val r = dp(8, density).toFloat() // design token rounded.md
+ 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
+ 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.tooltipTextCompat(btn.contentDescription.toString())
+ btn.setOnClickListener { if (docked) enterFloatingMode() else exitFloatingMode() }
+ }
+
+ 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) ==
+ 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..17205f1 100644
--- a/app/src/main/java/at/dasher/android/EditingToolbar.kt
+++ b/app/src/main/java/at/dasher/android/EditingToolbar.kt
@@ -2,11 +2,10 @@ 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.ImageButton
+import android.view.Gravity
import android.widget.LinearLayout
/**
@@ -14,12 +13,17 @@ 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,
@@ -32,6 +36,12 @@ class EditingToolbar(
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_YES
+ // 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
gravity = Gravity.CENTER_VERTICAL
@@ -41,42 +51,45 @@ 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(iconRes: Int, tooltip: String, large: Boolean): ImageButton {
+ val size = dp(if (large) 56 else 48)
+ return ImageButton(context).apply {
+ layoutParams = LayoutParams(size, size).apply { marginEnd = 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)
+ scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE
+ setImageDrawable(LucideIcons.tinted(context, iconRes, onSurface))
+ // 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
}
}
- 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 +98,7 @@ class EditingToolbar(
}
private fun addSpacer(widthPx: Int) {
- addView(View(context).apply {
+ addView(android.view.View(context).apply {
layoutParams = LayoutParams(widthPx, 1)
})
}
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/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..bae666c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_lucide_clipboard_paste.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
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..943e742
--- /dev/null
+++ b/app/src/main/res/drawable/ic_lucide_copy.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
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..4fabd92
--- /dev/null
+++ b/app/src/main/res/drawable/ic_lucide_panel_bottom.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
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..6320077
--- /dev/null
+++ b/app/src/main/res/drawable/ic_lucide_picture_in_picture_2.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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")