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>
Semántico sayk'uy
Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.
17 qillqakuna · +719 −2
- keyboard/build.gradle.kts +47 −0
- keyboard/proguard-rules.pro +1 −0
- keyboard/src/main/AndroidManifest.xml +33 −0
- keyboard/src/main/java/com/rizoma/trazo/MainActivity.kt +97 −0
- keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt +112 −0
- keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt +80 −0
- keyboard/src/main/java/com/rizoma/trazo/model/KeyLayout.kt +75 −0
- keyboard/src/main/java/com/rizoma/trazo/ui/KeyCell.kt +94 −0
- keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt +97 −0
- keyboard/src/main/java/com/rizoma/trazo/ui/theme/Theme.kt +30 −0
- keyboard/src/main/res/drawable/ic_launcher_fg.xml +21 −0
- keyboard/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 −0
- keyboard/src/main/res/values/colors.xml +4 −0
- keyboard/src/main/res/values/strings.xml +6 −0
- keyboard/src/main/res/values/themes.xml +8 −0
- keyboard/src/main/res/xml/method.xml +8 −0
- settings.gradle.kts +1 −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")