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")