gioser

sergio/humanoid

HTML 13TOML 10 Definitions

Trazo: teclado MessagEase remasterizado (IME v0.1)

Módulo :keyboard (com.rizoma.trazo) — IME de trazos para español:
- Grid 3x3: un toque + ocho swipes por tecla, todo data-driven (KeyLayout).
  Toques = letras frecuentes en posición MessagEase clásica (A N I / H O R / T E S);
  secundarias frecuentes en los swipes del centro (O), raras y Ñ en los bordes.
- Capa de números/símbolos conmutable.
- Gestos: detección tap vs. 8 direcciones por ángulo del delta del dedo.
- Lógica inteligente: auto-mayúscula según el editor (getCursorCapsMode),
  doble-espacio → ". ", shift one-shot / bloqueo, enter contextual (editor action).
- Teclado Compose hospedado dentro del InputMethodService (lifecycle/savedstate
  owners cableados al ComposeView).
- Activity de configuración para activar y elegir Trazo.

settings.gradle: :keyboard habilitado.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

e3dd167a16e071c181b3d4f3bc77664f9cf053ab · by sergio · (4 months ago) · parent 3feee86e3 · browse the tree at this commit

Semantic delta

No definition changed: this change is text, documentation or data.

17 files · +719 −2

diff --git a/keyboard/build.gradle.kts b/keyboard/build.gradle.kts
new file mode 100644
index 0000000..c39522e
--- /dev/null
+++ b/keyboard/build.gradle.kts
@@ -0,0 +1,47 @@
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.kotlin.android)
+    alias(libs.plugins.kotlin.compose)
+}
+
+android {
+    namespace = "com.rizoma.trazo"
+    compileSdk = 36
+
+    defaultConfig {
+        applicationId = "com.rizoma.trazo"
+        minSdk = 26
+        targetSdk = 36
+        versionCode = 1
+        versionName = "0.1.0"
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = true
+            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+        }
+    }
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+    kotlinOptions {
+        jvmTarget = "17"
+    }
+    buildFeatures {
+        compose = true
+    }
+}
+
+dependencies {
+    implementation(libs.androidx.core.ktx)
+    implementation(libs.androidx.lifecycle.runtime.ktx)
+    implementation(libs.androidx.activity.compose)
+    implementation(platform(libs.androidx.compose.bom))
+    implementation(libs.androidx.compose.ui)
+    implementation(libs.androidx.compose.ui.graphics)
+    implementation(libs.androidx.compose.foundation)
+    implementation(libs.androidx.compose.animation)
+    implementation(libs.androidx.compose.material3)
+}
diff --git a/keyboard/proguard-rules.pro b/keyboard/proguard-rules.pro
new file mode 100644
index 0000000..6de1bab
--- /dev/null
+++ b/keyboard/proguard-rules.pro
@@ -0,0 +1 @@
+# Reglas específicas de Trazo (vacío por ahora)
diff --git a/keyboard/src/main/AndroidManifest.xml b/keyboard/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..436fe0b
--- /dev/null
+++ b/keyboard/src/main/AndroidManifest.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <application
+        android:label="@string/app_name"
+        android:icon="@mipmap/ic_launcher"
+        android:theme="@style/Theme.Trazo"
+        android:supportsRtl="true">
+
+        <service
+            android:name=".TrazoImeService"
+            android:label="@string/ime_label"
+            android:permission="android.permission.BIND_INPUT_METHOD"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.view.InputMethod" />
+            </intent-filter>
+            <meta-data
+                android:name="android.view.im"
+                android:resource="@xml/method" />
+        </service>
+
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:theme="@style/Theme.Trazo">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/keyboard/src/main/java/com/rizoma/trazo/MainActivity.kt b/keyboard/src/main/java/com/rizoma/trazo/MainActivity.kt
new file mode 100644
index 0000000..d16c93d
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/MainActivity.kt
@@ -0,0 +1,97 @@
+package com.rizoma.trazo
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.provider.Settings
+import android.view.inputmethod.InputMethodManager
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.view.WindowCompat
+import com.rizoma.trazo.ui.theme.NeonCian
+import com.rizoma.trazo.ui.theme.NeonRosa
+import com.rizoma.trazo.ui.theme.NeonVerde
+import com.rizoma.trazo.ui.theme.Tecla
+import com.rizoma.trazo.ui.theme.Tenue
+import com.rizoma.trazo.ui.theme.TrazoTheme
+import com.rizoma.trazo.ui.theme.Vacio
+
+/** Pantalla mínima para activar Trazo y elegirlo como teclado. */
+class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+        setContent {
+            TrazoTheme {
+                Setup(
+                    onActivar = {
+                        startActivity(Intent(Settings.ACTION_INPUT_METHOD_SETTINGS))
+                    },
+                    onElegir = {
+                        (getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
+                            .showInputMethodPicker()
+                    },
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun Setup(onActivar: () -> Unit, onElegir: () -> Unit) {
+    Column(
+        Modifier
+            .fillMaxSize()
+            .background(Vacio)
+            .padding(horizontal = 32.dp),
+        verticalArrangement = Arrangement.Center,
+    ) {
+        Text("Trazo", color = NeonVerde, fontSize = 44.sp, fontWeight = FontWeight.Light)
+        Spacer(Modifier.height(8.dp))
+        Text(
+            "teclado de trazos — un toque, ocho gestos.",
+            color = Tenue,
+            fontSize = 15.sp,
+        )
+        Spacer(Modifier.height(40.dp))
+        Paso("1", "activa Trazo en ajustes del sistema", NeonCian, onActivar)
+        Spacer(Modifier.height(16.dp))
+        Paso("2", "elígelo como teclado actual", NeonRosa, onElegir)
+    }
+}
+
+@Composable
+private fun Paso(num: String, label: String, accent: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
+    Column(
+        Modifier
+            .fillMaxWidth()
+            .clip(RoundedCornerShape(18.dp))
+            .background(Tecla)
+            .border(1.dp, accent.copy(alpha = 0.4f), RoundedCornerShape(18.dp))
+            .clickable(onClick = onClick)
+            .padding(20.dp),
+    ) {
+        Text(num, color = accent, fontSize = 13.sp, fontWeight = FontWeight.Bold)
+        Spacer(Modifier.height(6.dp))
+        Text(label, color = androidx.compose.ui.graphics.Color.White, fontSize = 17.sp)
+    }
+}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt b/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
new file mode 100644
index 0000000..8a17584
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
@@ -0,0 +1,112 @@
+package com.rizoma.trazo
+
+import android.inputmethodservice.InputMethodService
+import android.text.InputType
+import android.view.KeyEvent
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.rizoma.trazo.ime.ImeHost
+import com.rizoma.trazo.ime.KeyboardController
+import com.rizoma.trazo.ui.KeyboardScreen
+import com.rizoma.trazo.ui.theme.TrazoTheme
+
+/**
+ * Trazo IME. Hospeda un teclado Compose dentro del InputMethodService, para lo cual
+ * el servicio actúa como Lifecycle/ViewModelStore/SavedState owner del ComposeView.
+ */
+class TrazoImeService : InputMethodService(), LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {
+
+    private val lifecycleRegistry = LifecycleRegistry(this)
+    override val lifecycle: Lifecycle get() = lifecycleRegistry
+
+    private val store = ViewModelStore()
+    override val viewModelStore: ViewModelStore get() = store
+
+    private val savedStateController = SavedStateRegistryController.create(this)
+    override val savedStateRegistry: SavedStateRegistry get() = savedStateController.savedStateRegistry
+
+    private var editorInputType: Int = InputType.TYPE_CLASS_TEXT
+
+    private val host = object : ImeHost {
+        override fun commit(text: String) {
+            currentInputConnection?.commitText(text, 1)
+        }
+
+        override fun backspace() {
+            val ic = currentInputConnection ?: return
+            val selected = ic.getSelectedText(0)
+            if (selected.isNullOrEmpty()) ic.deleteSurroundingText(1, 0) else ic.commitText("", 1)
+        }
+
+        override fun newline() {
+            val action = currentInputEditorInfo?.imeOptions ?: 0
+            val opt = action and EditorInfo.IME_MASK_ACTION
+            if (opt != EditorInfo.IME_ACTION_NONE && opt != EditorInfo.IME_ACTION_UNSPECIFIED &&
+                (action and EditorInfo.IME_FLAG_NO_ENTER_ACTION) == 0
+            ) {
+                currentInputConnection?.performEditorAction(opt)
+            } else {
+                currentInputConnection?.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER))
+                currentInputConnection?.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER))
+            }
+        }
+
+        override fun textBeforeCursor(n: Int): CharSequence =
+            currentInputConnection?.getTextBeforeCursor(n, 0) ?: ""
+
+        override fun cursorCapsMode(): Int =
+            currentInputConnection?.getCursorCapsMode(editorInputType) ?: 0
+    }
+
+    private val controller = KeyboardController(host)
+
+    override fun onCreate() {
+        super.onCreate()
+        savedStateController.performRestore(null)
+        lifecycleRegistry.currentState = Lifecycle.State.CREATED
+    }
+
+    override fun onCreateInputView(): View {
+        val view = ComposeView(this).apply {
+            setViewTreeLifecycleOwner(this@TrazoImeService)
+            setViewTreeViewModelStoreOwner(this@TrazoImeService)
+            setViewTreeSavedStateRegistryOwner(this@TrazoImeService)
+            setContent {
+                TrazoTheme {
+                    KeyboardScreen(controller)
+                }
+            }
+        }
+        return view
+    }
+
+    override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
+        super.onStartInputView(info, restarting)
+        editorInputType = info?.inputType ?: InputType.TYPE_CLASS_TEXT
+        lifecycleRegistry.currentState = Lifecycle.State.RESUMED
+        controller.syncAutoShift()
+    }
+
+    override fun onFinishInputView(finishingInput: Boolean) {
+        super.onFinishInputView(finishingInput)
+        lifecycleRegistry.currentState = Lifecycle.State.CREATED
+    }
+
+    override fun onDestroy() {
+        lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
+        store.clear()
+        super.onDestroy()
+    }
+}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt b/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt
new file mode 100644
index 0000000..73c3e5e
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt
@@ -0,0 +1,80 @@
+package com.rizoma.trazo.ime
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.rizoma.trazo.model.Layouts
+
+/** Puente con el editor real. Lo implementa el InputMethodService. */
+interface ImeHost {
+    fun commit(text: String)
+    fun backspace()
+    fun newline()
+    fun textBeforeCursor(n: Int): CharSequence
+    /** TextUtils.CAP_MODE_* combinado: !=0 si el editor quiere mayúscula aquí. */
+    fun cursorCapsMode(): Int
+}
+
+enum class ShiftState { OFF, ON, LOCK }
+enum class Layer { LETTERS, SYMBOLS }
+
+/**
+ * Toda la lógica de escritura. La UI solo dispara eventos; el estado de mayúscula
+ * y de capa son observables por Compose.
+ */
+class KeyboardController(private val host: ImeHost) {
+
+    var shift by mutableStateOf(ShiftState.ON)
+        private set
+    var layer by mutableStateOf(Layer.LETTERS)
+        private set
+
+    val keys get() = if (layer == Layer.LETTERS) Layouts.letters else Layouts.symbols
+    val shifted get() = shift != ShiftState.OFF
+
+    fun onChar(raw: Char) {
+        val out = if (shifted) raw.uppercaseChar() else raw
+        host.commit(out.toString())
+        if (shift == ShiftState.ON) shift = ShiftState.OFF // mayúscula de un solo disparo
+    }
+
+    fun onSpace() {
+        // Doble espacio tras palabra → ". " (puntua y capitaliza).
+        val before = host.textBeforeCursor(2)
+        if (before.length == 2 && before[1] == ' ' && before[0].isLetterOrDigit()) {
+            host.backspace()
+            host.commit(". ")
+        } else {
+            host.commit(" ")
+        }
+        syncAutoShift()
+    }
+
+    fun onBackspace() {
+        host.backspace()
+        syncAutoShift()
+    }
+
+    fun onEnter() {
+        host.newline()
+        syncAutoShift()
+    }
+
+    fun onShift() {
+        shift = when (shift) {
+            ShiftState.OFF -> ShiftState.ON
+            ShiftState.ON -> ShiftState.LOCK
+            ShiftState.LOCK -> ShiftState.OFF
+        }
+    }
+
+    fun toggleLayer() {
+        layer = if (layer == Layer.LETTERS) Layer.SYMBOLS else Layer.LETTERS
+    }
+
+    /** Recalcula la mayúscula automática según el contexto del editor. */
+    fun syncAutoShift() {
+        if (shift == ShiftState.LOCK) return
+        shift = if (host.cursorCapsMode() != 0) ShiftState.ON else ShiftState.OFF
+    }
+}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/model/KeyLayout.kt b/keyboard/src/main/java/com/rizoma/trazo/model/KeyLayout.kt
new file mode 100644
index 0000000..ef3a776
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/model/KeyLayout.kt
@@ -0,0 +1,75 @@
+package com.rizoma.trazo.model
+
+import kotlin.math.atan2
+import kotlin.math.hypot
+
+/** Las 9 direcciones de una tecla: centro (toque) + 8 swipes. */
+enum class Dir { C, N, NE, E, SE, S, SW, W, NW }
+
+/**
+ * Una tecla del grid 3x3. [center] es el toque; [swipes] mapea cada dirección
+ * de deslizamiento a su carácter. Todo es dato → la distribución se ajusta sin tocar UI.
+ */
+data class KeyDef(
+    val center: Char,
+    val swipes: Map<Dir, Char> = emptyMap(),
+) {
+    fun charFor(dir: Dir): Char? = if (dir == Dir.C) center else swipes[dir]
+}
+
+object Layouts {
+
+    /**
+     * Letras — remaster del espíritu MessagEase para español.
+     * Los 9 toques son las letras más frecuentes en su posición MessagEase clásica
+     * (A N I / H O R / T E S). Las 8 direcciones del centro (O) llevan las secundarias
+     * frecuentes; las raras y la Ñ van en los bordes. Tweakable a gusto.
+     */
+    val letters: List<KeyDef> = listOf(
+        // fila superior
+        KeyDef('a', mapOf(Dir.E to 'q', Dir.S to 'z')),
+        KeyDef('n', mapOf(Dir.S to 'f', Dir.E to 'v')),
+        KeyDef('i', mapOf(Dir.S to 'j', Dir.W to 'k')),
+        // fila media
+        KeyDef('h', mapOf(Dir.N to 'w', Dir.S to 'x')),
+        KeyDef(
+            'o',
+            mapOf(
+                Dir.N to 'u', Dir.NE to 'b', Dir.E to 'c', Dir.SE to 'd',
+                Dir.S to 'g', Dir.SW to 'p', Dir.W to 'm', Dir.NW to 'l',
+            ),
+        ),
+        KeyDef('r', mapOf(Dir.S to 'y')),
+        // fila inferior
+        KeyDef('t', mapOf(Dir.N to 'ñ')),
+        KeyDef('e', emptyMap()),
+        KeyDef('s', emptyMap()),
+    )
+
+    /**
+     * Números y símbolos. Dígitos 1-9 en posición de marcador telefónico; 0 y la
+     * puntuación frecuente como swipes.
+     */
+    val symbols: List<KeyDef> = listOf(
+        KeyDef('1', mapOf(Dir.N to '!', Dir.E to '@')),
+        KeyDef('2', mapOf(Dir.N to '"', Dir.E to '#')),
+        KeyDef('3', mapOf(Dir.N to '\'', Dir.W to '$')),
+        KeyDef('4', mapOf(Dir.N to '%', Dir.E to '&')),
+        KeyDef('5', mapOf(Dir.N to '*', Dir.E to '+', Dir.W to '-', Dir.S to '=')),
+        KeyDef('6', mapOf(Dir.N to '/', Dir.W to '\\')),
+        KeyDef('7', mapOf(Dir.N to '(', Dir.E to ')')),
+        KeyDef('8', mapOf(Dir.N to ',', Dir.E to '.', Dir.W to '?')),
+        KeyDef('9', mapOf(Dir.N to ';', Dir.W to ':', Dir.E to '0')),
+    )
+}
+
+/** Geometría: convierte el delta de un gesto en una de las 9 direcciones. */
+fun directionOf(dx: Float, dy: Float, tapRadiusPx: Float): Dir {
+    if (hypot(dx, dy) < tapRadiusPx) return Dir.C
+    var deg = Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())) // 0=E, 90=S (y hacia abajo)
+    if (deg < 0) deg += 360.0
+    // 8 sectores de 45° centrados en cada eje.
+    val order = arrayOf(Dir.E, Dir.SE, Dir.S, Dir.SW, Dir.W, Dir.NW, Dir.N, Dir.NE)
+    val idx = (((deg + 22.5) / 45.0).toInt()) % 8
+    return order[idx]
+}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/ui/KeyCell.kt b/keyboard/src/main/java/com/rizoma/trazo/ui/KeyCell.kt
new file mode 100644
index 0000000..b59bbaf
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/ui/KeyCell.kt
@@ -0,0 +1,94 @@
+package com.rizoma.trazo.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.trazo.model.Dir
+import com.rizoma.trazo.model.KeyDef
+import com.rizoma.trazo.model.directionOf
+import com.rizoma.trazo.ui.theme.NeonVerde
+import com.rizoma.trazo.ui.theme.Tecla
+import com.rizoma.trazo.ui.theme.Tenue
+
+private val hintAlignments = mapOf(
+    Dir.N to Alignment.TopCenter,
+    Dir.NE to Alignment.TopEnd,
+    Dir.E to Alignment.CenterEnd,
+    Dir.SE to Alignment.BottomEnd,
+    Dir.S to Alignment.BottomCenter,
+    Dir.SW to Alignment.BottomStart,
+    Dir.W to Alignment.CenterStart,
+    Dir.NW to Alignment.TopStart,
+)
+
+@Composable
+fun KeyCell(
+    key: KeyDef,
+    shifted: Boolean,
+    onChar: (Char) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val tapRadiusPx = with(LocalDensity.current) { 22.dp.toPx() }
+
+    Box(
+        modifier
+            .padding(3.dp)
+            .clip(RoundedCornerShape(14.dp))
+            .background(Tecla)
+            .border(1.dp, NeonVerde.copy(alpha = 0.10f), RoundedCornerShape(14.dp))
+            .pointerInput(key, shifted) {
+                awaitEachGesture {
+                    val down = awaitFirstDown()
+                    var current = down.position
+                    while (true) {
+                        val event = awaitPointerEvent()
+                        val change = event.changes.firstOrNull { it.id == down.id } ?: event.changes.first()
+                        current = change.position
+                        change.consume()
+                        if (!change.pressed) break
+                    }
+                    val dx = current.x - down.position.x
+                    val dy = current.y - down.position.y
+                    val dir = directionOf(dx, dy, tapRadiusPx)
+                    key.charFor(dir)?.let { raw -> onChar(raw) }
+                }
+            },
+    ) {
+        // Pistas de swipe (tenues) en los bordes.
+        key.swipes.forEach { (dir, ch) ->
+            val align = hintAlignments[dir] ?: return@forEach
+            Text(
+                text = glyph(ch, shifted),
+                color = Tenue,
+                fontSize = 11.sp,
+                modifier = Modifier.align(align).padding(horizontal = 6.dp, vertical = 3.dp),
+            )
+        }
+        // Carácter central.
+        Text(
+            text = glyph(key.center, shifted),
+            color = androidx.compose.ui.graphics.Color.White,
+            fontSize = 22.sp,
+            fontWeight = FontWeight.Medium,
+            modifier = Modifier.align(Alignment.Center),
+        )
+    }
+}
+
+private fun glyph(c: Char, shifted: Boolean): String =
+    if (shifted) c.uppercaseChar().toString() else c.toString()
diff --git a/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt b/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt
new file mode 100644
index 0000000..ab8f9a4
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt
@@ -0,0 +1,97 @@
+package com.rizoma.trazo.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.trazo.ime.KeyboardController
+import com.rizoma.trazo.ime.Layer
+import com.rizoma.trazo.ime.ShiftState
+import com.rizoma.trazo.ui.theme.NeonCian
+import com.rizoma.trazo.ui.theme.NeonRosa
+import com.rizoma.trazo.ui.theme.NeonVerde
+import com.rizoma.trazo.ui.theme.Tecla
+import com.rizoma.trazo.ui.theme.Tenue
+import com.rizoma.trazo.ui.theme.Vacio
+
+@Composable
+fun KeyboardScreen(controller: KeyboardController) {
+    Column(
+        Modifier
+            .fillMaxWidth()
+            .background(Vacio)
+            .padding(4.dp),
+    ) {
+        val keys = controller.keys
+        // 3 filas × 3 teclas.
+        for (row in 0 until 3) {
+            Row(Modifier.fillMaxWidth().height(70.dp)) {
+                for (col in 0 until 3) {
+                    KeyCell(
+                        key = keys[row * 3 + col],
+                        shifted = controller.shifted,
+                        onChar = controller::onChar,
+                        modifier = Modifier.weight(1f).fillMaxHeight(),
+                    )
+                }
+            }
+        }
+
+        // Fila de funciones.
+        Row(Modifier.fillMaxWidth().height(52.dp)) {
+            val shiftAccent = when (controller.shift) {
+                ShiftState.OFF -> Tenue
+                ShiftState.ON -> NeonVerde
+                ShiftState.LOCK -> NeonRosa
+            }
+            FuncKey("⇧", shiftAccent, Modifier.weight(1f), onClick = controller::onShift)
+            FuncKey(
+                if (controller.layer == Layer.LETTERS) "?123" else "ABC",
+                NeonCian, Modifier.weight(1f), onClick = controller::toggleLayer,
+            )
+            FuncKey("espacio", Color.White, Modifier.weight(3f), onClick = controller::onSpace)
+            FuncKey("⌫", Color.White, Modifier.weight(1f), onClick = controller::onBackspace)
+            FuncKey("↵", NeonVerde, Modifier.weight(1f), onClick = controller::onEnter)
+        }
+    }
+}
+
+@Composable
+private fun FuncKey(
+    label: String,
+    accent: Color,
+    modifier: Modifier,
+    onClick: () -> Unit,
+) {
+    Box(
+        modifier
+            .fillMaxHeight()
+            .padding(3.dp)
+            .clip(RoundedCornerShape(14.dp))
+            .background(Tecla)
+            .clickable(onClick = onClick),
+        contentAlignment = Alignment.Center,
+    ) {
+        Text(
+            text = label,
+            color = accent,
+            fontSize = if (label.length > 2) 14.sp else 20.sp,
+            fontWeight = FontWeight.Medium,
+        )
+    }
+}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/ui/theme/Theme.kt b/keyboard/src/main/java/com/rizoma/trazo/ui/theme/Theme.kt
new file mode 100644
index 0000000..40bbaef
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/ui/theme/Theme.kt
@@ -0,0 +1,30 @@
+package com.rizoma.trazo.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+val Vacio = Color(0xFF050507)
+val Tecla = Color(0xFF111118)
+val TeclaActiva = Color(0xFF1B1B26)
+val NeonVerde = Color(0xFF7CFF4F)
+val NeonRosa = Color(0xFFFF2E88)
+val NeonCian = Color(0xFF00E5FF)
+val Tenue = Color(0x66FFFFFF)
+
+private val Esquema = darkColorScheme(
+    primary = NeonVerde,
+    onPrimary = Vacio,
+    secondary = NeonRosa,
+    tertiary = NeonCian,
+    background = Vacio,
+    onBackground = Color.White,
+    surface = Tecla,
+    onSurface = Color.White,
+)
+
+@Composable
+fun TrazoTheme(content: @Composable () -> Unit) {
+    MaterialTheme(colorScheme = Esquema, content = content)
+}
diff --git a/keyboard/src/main/res/drawable/ic_launcher_fg.xml b/keyboard/src/main/res/drawable/ic_launcher_fg.xml
new file mode 100644
index 0000000..69b8f19
--- /dev/null
+++ b/keyboard/src/main/res/drawable/ic_launcher_fg.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <!-- trazo: un gesto que sale del centro y se curva -->
+    <path
+        android:pathData="M 38 70 C 38 50 70 50 70 38"
+        android:strokeColor="#7CFF4F"
+        android:strokeWidth="5"
+        android:strokeLineCap="round" />
+    <!-- punto de origen (toque) -->
+    <path
+        android:fillColor="#00E5FF"
+        android:pathData="M 38 70 m -6 0 a 6 6 0 1 1 12 0 a 6 6 0 1 1 -12 0" />
+    <!-- punta del trazo -->
+    <path
+        android:fillColor="#FF2E88"
+        android:pathData="M 70 38 m -4 0 a 4 4 0 1 1 8 0 a 4 4 0 1 1 -8 0" />
+</vector>
diff --git a/keyboard/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/keyboard/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..088fcce
--- /dev/null
+++ b/keyboard/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_bg" />
+    <foreground android:drawable="@drawable/ic_launcher_fg" />
+</adaptive-icon>
diff --git a/keyboard/src/main/res/values/colors.xml b/keyboard/src/main/res/values/colors.xml
new file mode 100644
index 0000000..917256d
--- /dev/null
+++ b/keyboard/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="ic_bg">#050507</color>
+</resources>
diff --git a/keyboard/src/main/res/values/strings.xml b/keyboard/src/main/res/values/strings.xml
new file mode 100644
index 0000000..c8ed08f
--- /dev/null
+++ b/keyboard/src/main/res/values/strings.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">Trazo</string>
+    <string name="ime_label">Trazo</string>
+    <string name="subtype_es">Trazo · español</string>
+</resources>
diff --git a/keyboard/src/main/res/values/themes.xml b/keyboard/src/main/res/values/themes.xml
new file mode 100644
index 0000000..9873fdc
--- /dev/null
+++ b/keyboard/src/main/res/values/themes.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="Theme.Trazo" parent="android:Theme.Material.NoActionBar">
+        <item name="android:windowBackground">#050507</item>
+        <item name="android:statusBarColor">@android:color/transparent</item>
+        <item name="android:navigationBarColor">@android:color/transparent</item>
+    </style>
+</resources>
diff --git a/keyboard/src/main/res/xml/method.xml b/keyboard/src/main/res/xml/method.xml
new file mode 100644
index 0000000..b313201
--- /dev/null
+++ b/keyboard/src/main/res/xml/method.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<input-method xmlns:android="http://schemas.android.com/apk/res/android"
+    android:settingsActivity="com.rizoma.trazo.MainActivity">
+    <subtype
+        android:label="@string/subtype_es"
+        android:imeSubtypeLocale="es"
+        android:imeSubtypeMode="keyboard" />
+</input-method>
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 57e83ba..6fe071b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -21,5 +21,4 @@ dependencyResolutionManagement {
 
 rootProject.name = "rizoma"
 include(":launcher")
-// :keyboard se habilitará cuando exista el módulo (siguiente fase)
-// include(":keyboard")
+include(":keyboard")