Trazo: pulido v0.4 — háptica, palabra siguiente, emojis y ajustes
- Háptica: ime/Haptics (Vibrator + VibrationEffect, permiso VIBRATE) con intensidad suave/medio/fuerte; el KeyboardController la dispara en cada acción (cursor con tick suave). - Palabra siguiente: SuggestionEngine aprende bigramas (b:prev>next, persistidos) y predice tras espacio/sugerencia, encadenable; filtra a palabras conocidas para no propagar erratas. Diccionarios separados es/en. - Emojis: nueva Layer.EMOJIS con catálogo (model/Emoji) y panel desplazable (ui/EmojiGrid, recientes incluidos); tecla 😊 en la fila de funciones. - Ajustes: data/TrazoSettings (DataStore reactivo, estado Compose compartido entre IME y Activity) + pantalla en MainActivity con toggles de vibración e intensidad, doble-espacio→". " e idiomas de predicción es/en. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
10 archivos · +541 −71
- keyboard/src/main/AndroidManifest.xml +3 −0
- keyboard/src/main/java/com/rizoma/trazo/MainActivity.kt +119 −15
- keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt +6 −2
- keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt +72 −16
- keyboard/src/main/java/com/rizoma/trazo/data/TrazoSettings.kt +78 −0
- keyboard/src/main/java/com/rizoma/trazo/ime/Haptics.kt +40 −0
- keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt +70 −13
- keyboard/src/main/java/com/rizoma/trazo/model/Emoji.kt +44 −0
- keyboard/src/main/java/com/rizoma/trazo/ui/EmojiGrid.kt +69 −0
- keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt +40 −25
diff --git a/keyboard/src/main/AndroidManifest.xml b/keyboard/src/main/AndroidManifest.xml
index 436fe0b..deac597 100644
--- a/keyboard/src/main/AndroidManifest.xml
+++ b/keyboard/src/main/AndroidManifest.xml
@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <!-- Retorno háptico al teclear. -->
+ <uses-permission android:name="android.permission.VIBRATE" />
+
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
diff --git a/keyboard/src/main/java/com/rizoma/trazo/MainActivity.kt b/keyboard/src/main/java/com/rizoma/trazo/MainActivity.kt
index d16c93d..6a9d740 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/MainActivity.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/MainActivity.kt
@@ -11,22 +11,31 @@ 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.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
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.layout.width
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
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 androidx.core.view.WindowCompat
+import androidx.lifecycle.lifecycleScope
+import com.rizoma.trazo.data.HapticLevel
+import com.rizoma.trazo.data.TrazoSettings
import com.rizoma.trazo.ui.theme.NeonCian
import com.rizoma.trazo.ui.theme.NeonRosa
import com.rizoma.trazo.ui.theme.NeonVerde
@@ -35,17 +44,20 @@ 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. */
+/** Pantalla para activar Trazo, elegirlo como teclado y ajustar sus preferencias. */
class MainActivity : ComponentActivity() {
+
+ private lateinit var settings: TrazoSettings
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ settings = TrazoSettings(this, lifecycleScope)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
TrazoTheme {
Setup(
- onActivar = {
- startActivity(Intent(Settings.ACTION_INPUT_METHOD_SETTINGS))
- },
+ settings = settings,
+ onActivar = { startActivity(Intent(Settings.ACTION_INPUT_METHOD_SETTINGS)) },
onElegir = {
(getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
.showInputMethodPicker()
@@ -57,30 +69,54 @@ class MainActivity : ComponentActivity() {
}
@Composable
-private fun Setup(onActivar: () -> Unit, onElegir: () -> Unit) {
+private fun Setup(settings: TrazoSettings, onActivar: () -> Unit, onElegir: () -> Unit) {
Column(
Modifier
.fillMaxSize()
.background(Vacio)
- .padding(horizontal = 32.dp),
- verticalArrangement = Arrangement.Center,
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 32.dp, vertical = 56.dp),
) {
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))
+ Text("teclado de trazos — un toque, ocho gestos.", color = Tenue, fontSize = 15.sp)
+ Spacer(Modifier.height(36.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)
+
+ Spacer(Modifier.height(40.dp))
+ Seccion("ajustes")
+ Spacer(Modifier.height(12.dp))
+
+ Toggle("vibración al teclear", settings.haptics, NeonCian) { settings.setHaptics(it) }
+ if (settings.haptics) {
+ Spacer(Modifier.height(10.dp))
+ NivelHaptico(settings.hapticLevel) { settings.setHapticLevel(it) }
+ }
+
+ Spacer(Modifier.height(10.dp))
+ Toggle("doble espacio → punto", settings.doubleSpacePeriod, NeonVerde) {
+ settings.setDoubleSpacePeriod(it)
+ }
+
+ Spacer(Modifier.height(24.dp))
+ Seccion("idiomas de predicción")
+ Spacer(Modifier.height(12.dp))
+ Toggle("español", settings.spanish, NeonRosa) { settings.setSpanish(it) }
+ Spacer(Modifier.height(10.dp))
+ Toggle("inglés", settings.english, NeonCian) { settings.setEnglish(it) }
}
}
@Composable
-private fun Paso(num: String, label: String, accent: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
+private fun Seccion(titulo: String) {
+ Text(titulo, color = Tenue, fontSize = 13.sp, fontWeight = FontWeight.Medium)
+}
+
+@Composable
+private fun Paso(num: String, label: String, accent: Color, onClick: () -> Unit) {
Column(
Modifier
.fillMaxWidth()
@@ -92,6 +128,74 @@ private fun Paso(num: String, label: String, accent: androidx.compose.ui.graphic
) {
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)
+ Text(label, color = Color.White, fontSize = 17.sp)
+ }
+}
+
+@Composable
+private fun Toggle(label: String, value: Boolean, accent: Color, onChange: (Boolean) -> Unit) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(14.dp))
+ .background(Tecla)
+ .clickable { onChange(!value) }
+ .padding(horizontal = 18.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(label, color = Color.White, fontSize = 16.sp, modifier = Modifier.weight(1f))
+ Pill(value, accent)
+ }
+}
+
+/** Indicador tipo interruptor: relleno y a la derecha cuando está activo. */
+@Composable
+private fun Pill(on: Boolean, accent: Color) {
+ Box(
+ Modifier
+ .width(46.dp)
+ .height(26.dp)
+ .clip(RoundedCornerShape(13.dp))
+ .background(if (on) accent.copy(alpha = 0.28f) else Color(0xFF1B1B26))
+ .border(1.dp, if (on) accent else Tenue, RoundedCornerShape(13.dp)),
+ contentAlignment = if (on) Alignment.CenterEnd else Alignment.CenterStart,
+ ) {
+ Box(
+ Modifier
+ .padding(3.dp)
+ .width(20.dp)
+ .height(20.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(if (on) accent else Tenue),
+ )
+ }
+}
+
+@Composable
+private fun NivelHaptico(level: HapticLevel, onSelect: (HapticLevel) -> Unit) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ HapticLevel.entries.forEach { opt ->
+ val selected = opt == level
+ Box(
+ Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(12.dp))
+ .background(if (selected) NeonCian.copy(alpha = 0.18f) else Tecla)
+ .border(
+ 1.dp,
+ if (selected) NeonCian else Tenue.copy(alpha = 0.4f),
+ RoundedCornerShape(12.dp),
+ )
+ .clickable { onSelect(opt) }
+ .padding(vertical = 12.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = opt.name.lowercase(),
+ color = if (selected) NeonCian else Tenue,
+ fontSize = 14.sp,
+ )
+ }
+ }
}
}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt b/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
index 3d2c656..536c387 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
@@ -18,6 +18,8 @@ import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.rizoma.trazo.data.SuggestionEngine
+import com.rizoma.trazo.data.TrazoSettings
+import com.rizoma.trazo.ime.Haptics
import com.rizoma.trazo.ime.ImeHost
import com.rizoma.trazo.ime.KeyboardController
import com.rizoma.trazo.ui.KeyboardScreen
@@ -95,13 +97,15 @@ class TrazoImeService : InputMethodService(), LifecycleOwner, ViewModelStoreOwne
}
private lateinit var engine: SuggestionEngine
+ private lateinit var settings: TrazoSettings
private lateinit var controller: KeyboardController
override fun onCreate() {
super.onCreate()
savedStateController.performRestore(null)
engine = SuggestionEngine(this, scope)
- controller = KeyboardController(host, engine)
+ settings = TrazoSettings(this, scope)
+ controller = KeyboardController(host, engine, settings, Haptics(this))
lifecycleRegistry.currentState = Lifecycle.State.CREATED
}
@@ -120,7 +124,7 @@ class TrazoImeService : InputMethodService(), LifecycleOwner, ViewModelStoreOwne
setViewTreeSavedStateRegistryOwner(this@TrazoImeService)
setContent {
TrazoTheme {
- KeyboardScreen(controller)
+ KeyboardScreen(controller, settings)
}
}
}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt b/keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt
index 13f8de1..557cc2d 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt
@@ -29,18 +29,22 @@ class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
private val appContext = context.applicationContext
private val store = appContext.trazoLearnedStore
- private val base = ConcurrentHashMap<String, Int>()
+ // Diccionarios separados por idioma para poder activarlos/desactivarlos.
+ private val baseEs = ConcurrentHashMap<String, Int>()
+ private val baseEn = ConcurrentHashMap<String, Int>()
private val learned = ConcurrentHashMap<String, Int>()
+ // Bigramas: palabra previa -> {palabra siguiente -> veces}. Alimenta "palabra siguiente".
+ private val bigrams = ConcurrentHashMap<String, ConcurrentHashMap<String, Int>>()
init {
scope.launch(Dispatchers.IO) {
- loadFile("dict_es.txt")
- loadFile("dict_en.txt")
- loadLearned()
+ loadFile("dict_es.txt", baseEs)
+ loadFile("dict_en.txt", baseEn)
+ loadPersisted()
}
}
- private fun loadFile(name: String) {
+ private fun loadFile(name: String, into: ConcurrentHashMap<String, Int>) {
runCatching {
appContext.assets.open(name).bufferedReader().useLines { lines ->
var i = 0
@@ -48,7 +52,7 @@ class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
val w = line.trim().lowercase()
if (w.isNotEmpty()) {
val weight = maxOf(1, BASE_TOP - i)
- base.merge(w, weight) { a, b -> maxOf(a, b) }
+ into.merge(w, weight) { a, b -> maxOf(a, b) }
i++
}
}
@@ -56,28 +60,47 @@ class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
}
}
- private suspend fun loadLearned() {
+ private suspend fun loadPersisted() {
runCatching {
store.data.first().asMap().forEach { (key, value) ->
- if (key.name.startsWith(W) && value is Int) {
- learned[key.name.removePrefix(W)] = value
+ val name = key.name
+ if (value !is Int) return@forEach
+ when {
+ name.startsWith(W) -> learned[name.removePrefix(W)] = value
+ name.startsWith(B) -> {
+ val rest = name.removePrefix(B)
+ val sep = rest.indexOf(SEP)
+ if (sep > 0 && sep < rest.length - 1) {
+ val prev = rest.substring(0, sep)
+ val next = rest.substring(sep + 1)
+ bigrams.getOrPut(prev) { ConcurrentHashMap() }[next] = value
+ }
+ }
}
}
}
}
- /** Completa el prefijo; mayor peso primero, a igual peso la más corta. */
- fun suggest(prefix: String, limit: Int = 3): List<String> {
+ private fun inBase(w: String) = baseEs.containsKey(w) || baseEn.containsKey(w)
+
+ /** Completa el prefijo en los idiomas activos; mayor peso primero, a igual peso la más corta. */
+ fun suggest(prefix: String, useEs: Boolean, useEn: Boolean, limit: Int = 3): List<String> {
val p = prefix.trim().lowercase()
if (p.isEmpty()) return emptyList()
val candidates = HashMap<String, Int>()
- base.forEach { (w, weight) ->
- if (w.length > p.length && w.startsWith(p)) candidates[w] = weight
+ fun scan(map: ConcurrentHashMap<String, Int>) {
+ map.forEach { (w, weight) ->
+ if (w.length > p.length && w.startsWith(p)) {
+ candidates[w] = maxOf(candidates[w] ?: 0, weight)
+ }
+ }
}
+ if (useEs) scan(baseEs)
+ if (useEn) scan(baseEn)
learned.forEach { (w, count) ->
// Solo palabras nuevas ya "promovidas"; las del diccionario suman refuerzo.
- if (w.length > p.length && w.startsWith(p) && (count >= PROMOTE || base.containsKey(w))) {
+ if (w.length > p.length && w.startsWith(p) && (count >= PROMOTE || inBase(w))) {
candidates[w] = (candidates[w] ?: 0) + count * LEARN_INC
}
}
@@ -87,10 +110,21 @@ class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
.take(limit)
}
+ /** Palabras más probables tras [prev], según los bigramas aprendidos. */
+ fun nextWords(prev: String, limit: Int = 3): List<String> {
+ val a = clean(prev) ?: return emptyList()
+ val map = bigrams[a] ?: return emptyList()
+ return map.entries
+ // Evita propagar erratas: la siguiente debe ser palabra conocida.
+ .filter { inBase(it.key) || (learned[it.key] ?: 0) >= PROMOTE }
+ .sortedByDescending { it.value }
+ .map { it.key }
+ .take(limit)
+ }
+
/** Aprende/refuerza una palabra escrita por el usuario (se promueve sola al llegar a PROMOTE). */
fun learn(raw: String) {
- val w = raw.trim().lowercase()
- if (w.length < 2 || !w.all { it.isLetter() }) return
+ val w = clean(raw) ?: return
learned.merge(w, 1) { a, _ -> a + 1 }
scope.launch(Dispatchers.IO) {
runCatching {
@@ -102,10 +136,32 @@ class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
}
}
+ /** Registra que [next] siguió a [prev] (para la predicción de palabra siguiente). */
+ fun learnBigram(prev: String, next: String) {
+ val a = clean(prev) ?: return
+ val b = clean(next) ?: return
+ bigrams.getOrPut(a) { ConcurrentHashMap() }.merge(b, 1) { x, _ -> x + 1 }
+ scope.launch(Dispatchers.IO) {
+ runCatching {
+ store.edit { prefs ->
+ val key = intPreferencesKey(B + a + SEP + b)
+ prefs[key] = (prefs[key] ?: 0) + 1
+ }
+ }
+ }
+ }
+
+ private fun clean(raw: String): String? {
+ val w = raw.trim().lowercase()
+ return if (w.length >= 2 && w.all { it.isLetter() }) w else null
+ }
+
private companion object {
const val BASE_TOP = 5000
const val LEARN_INC = 400
const val PROMOTE = 2
const val W = "w:"
+ const val B = "b:"
+ const val SEP = '>' // separador prev/next; seguro porque las palabras son solo letras
}
}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/data/TrazoSettings.kt b/keyboard/src/main/java/com/rizoma/trazo/data/TrazoSettings.kt
new file mode 100644
index 0000000..dd3c0da
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/data/TrazoSettings.kt
@@ -0,0 +1,78 @@
+package com.rizoma.trazo.data
+
+import android.content.Context
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.MutablePreferences
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+/** Intensidad del retorno háptico al teclear. */
+enum class HapticLevel { SUAVE, MEDIO, FUERTE }
+
+private val Context.trazoSettingsStore: DataStore<Preferences> by preferencesDataStore(name = "trazo_settings")
+
+/**
+ * Preferencias de Trazo, persistidas en DataStore y expuestas como estado de Compose.
+ * Un único colector mantiene en sincronía cualquier instancia (la del IME y la de la
+ * pantalla de ajustes), así que tocar un toggle se refleja al instante en el teclado.
+ */
+class TrazoSettings(context: Context, private val scope: CoroutineScope) {
+
+ private val store = context.applicationContext.trazoSettingsStore
+
+ var haptics by mutableStateOf(true); private set
+ var hapticLevel by mutableStateOf(HapticLevel.MEDIO); private set
+ var doubleSpacePeriod by mutableStateOf(true); private set
+ var spanish by mutableStateOf(true); private set
+ var english by mutableStateOf(true); private set
+ var recentEmojis by mutableStateOf<List<String>>(emptyList()); private set
+
+ init {
+ scope.launch {
+ store.data.collect { p ->
+ haptics = p[HAPTICS] ?: true
+ hapticLevel = runCatching { HapticLevel.valueOf(p[HAPTIC_LEVEL] ?: "") }
+ .getOrDefault(HapticLevel.MEDIO)
+ doubleSpacePeriod = p[DOUBLE_SPACE] ?: true
+ spanish = p[LANG_ES] ?: true
+ english = p[LANG_EN] ?: true
+ recentEmojis = p[RECENT_EMOJIS]?.split(" ")?.filter { it.isNotEmpty() } ?: emptyList()
+ }
+ }
+ }
+
+ fun setHaptics(v: Boolean) = persist { it[HAPTICS] = v }
+ fun setHapticLevel(v: HapticLevel) = persist { it[HAPTIC_LEVEL] = v.name }
+ fun setDoubleSpacePeriod(v: Boolean) = persist { it[DOUBLE_SPACE] = v }
+ fun setSpanish(v: Boolean) = persist { it[LANG_ES] = v }
+ fun setEnglish(v: Boolean) = persist { it[LANG_EN] = v }
+
+ /** Empuja un emoji a la cabeza de "recientes" (sin duplicar, acotado). */
+ fun pushRecentEmoji(e: String) {
+ val updated = (listOf(e) + recentEmojis.filter { it != e }).take(RECENTS_MAX)
+ persist { it[RECENT_EMOJIS] = updated.joinToString(" ") }
+ }
+
+ private fun persist(block: (MutablePreferences) -> Unit) {
+ scope.launch(Dispatchers.IO) { runCatching { store.edit(block) } }
+ }
+
+ private companion object {
+ val HAPTICS = booleanPreferencesKey("haptics")
+ val HAPTIC_LEVEL = stringPreferencesKey("haptic_level")
+ val DOUBLE_SPACE = booleanPreferencesKey("double_space_period")
+ val LANG_ES = booleanPreferencesKey("lang_es")
+ val LANG_EN = booleanPreferencesKey("lang_en")
+ val RECENT_EMOJIS = stringPreferencesKey("recent_emojis")
+ const val RECENTS_MAX = 24
+ }
+}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/ime/Haptics.kt b/keyboard/src/main/java/com/rizoma/trazo/ime/Haptics.kt
new file mode 100644
index 0000000..8d07e6c
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/ime/Haptics.kt
@@ -0,0 +1,40 @@
+package com.rizoma.trazo.ime
+
+import android.content.Context
+import android.os.Build
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+import com.rizoma.trazo.data.HapticLevel
+
+/**
+ * Retorno háptico del teclado. Usa el Vibrator del sistema con control de amplitud
+ * cuando el dispositivo lo soporta; si no, cae a la vibración por defecto. Todo es
+ * best-effort: sin vibrador o con cualquier fallo, simplemente no hace nada.
+ */
+class Haptics(context: Context) {
+
+ private val vibrator: Vibrator? = run {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
+ vm?.defaultVibrator
+ } else {
+ @Suppress("DEPRECATION")
+ context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
+ }
+ }
+
+ fun tap(level: HapticLevel) {
+ val v = vibrator ?: return
+ if (!v.hasVibrator()) return
+ val (ms, amp) = when (level) {
+ HapticLevel.SUAVE -> 8L to 70
+ HapticLevel.MEDIO -> 14L to 130
+ HapticLevel.FUERTE -> 22L to 210
+ }
+ runCatching {
+ val amplitude = if (v.hasAmplitudeControl()) amp else VibrationEffect.DEFAULT_AMPLITUDE
+ v.vibrate(VibrationEffect.createOneShot(ms, amplitude))
+ }
+ }
+}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt b/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt
index 02eabeb..7e630e9 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt
@@ -3,7 +3,9 @@ package com.rizoma.trazo.ime
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
+import com.rizoma.trazo.data.HapticLevel
import com.rizoma.trazo.data.SuggestionEngine
+import com.rizoma.trazo.data.TrazoSettings
import com.rizoma.trazo.model.Layouts
/** Puente con el editor real. Lo implementa el InputMethodService. */
@@ -20,7 +22,7 @@ interface ImeHost {
}
enum class ShiftState { OFF, ON, LOCK }
-enum class Layer { LETTERS, SYMBOLS }
+enum class Layer { LETTERS, SYMBOLS, EMOJIS }
/**
* Toda la lógica de escritura. La palabra en curso vive como "texto en composición"
@@ -29,6 +31,8 @@ enum class Layer { LETTERS, SYMBOLS }
class KeyboardController(
private val host: ImeHost,
private val engine: SuggestionEngine,
+ private val settings: TrazoSettings,
+ private val haptics: Haptics,
) {
var shift by mutableStateOf(ShiftState.ON)
@@ -39,11 +43,14 @@ class KeyboardController(
private set
private val composing = StringBuilder()
+ // Última palabra cerrada: ancla de los bigramas para predecir la siguiente.
+ private var lastWord: String? = null
- val keys get() = if (layer == Layer.LETTERS) Layouts.letters else Layouts.symbols
+ val keys get() = if (layer == Layer.SYMBOLS) Layouts.symbols else Layouts.letters
val shifted get() = shift != ShiftState.OFF
fun onChar(raw: Char) {
+ haptic()
val out = if (shifted) raw.uppercaseChar() else raw
if (layer == Layer.LETTERS && raw.isLetter()) {
composing.append(out)
@@ -59,18 +66,23 @@ class KeyboardController(
}
fun onSpace() {
+ haptic()
finishWord()
val before = host.textBeforeCursor(2)
- if (before.length == 2 && before[1] == ' ' && before[0].isLetterOrDigit()) {
+ if (settings.doubleSpacePeriod &&
+ before.length == 2 && before[1] == ' ' && before[0].isLetterOrDigit()
+ ) {
host.backspace()
host.commit(". ")
} else {
host.commit(" ")
}
syncAutoShift()
+ showNextWords()
}
fun onBackspace() {
+ haptic()
if (composing.isNotEmpty()) {
composing.deleteCharAt(composing.length - 1)
if (composing.isEmpty()) {
@@ -82,17 +94,22 @@ class KeyboardController(
}
} else {
host.backspace()
+ lastWord = null
+ suggestions = emptyList()
syncAutoShift()
}
}
fun onEnter() {
+ haptic()
finishWord()
host.newline()
+ lastWord = null
syncAutoShift()
}
fun onShift() {
+ haptic()
shift = when (shift) {
ShiftState.OFF -> ShiftState.ON
ShiftState.ON -> ShiftState.LOCK
@@ -100,28 +117,54 @@ class KeyboardController(
}
}
+ /** Alterna letras ↔ símbolos (desde emojis, vuelve a letras). */
fun toggleLayer() {
+ haptic()
finishWord()
layer = if (layer == Layer.LETTERS) Layer.SYMBOLS else Layer.LETTERS
}
- /** Elige una sugerencia: reemplaza la palabra en composición y deja un espacio. */
+ fun showEmojis() {
+ haptic()
+ finishWord()
+ layer = Layer.EMOJIS
+ }
+
+ /** Inserta un emoji y lo recuerda como reciente. */
+ fun onEmoji(emoji: String) {
+ haptic()
+ finishWord()
+ host.commit(emoji)
+ settings.pushRecentEmoji(emoji)
+ lastWord = null
+ suggestions = emptyList()
+ }
+
+ /**
+ * Elige una sugerencia. Si hay palabra en composición, la reemplaza; si no, es una
+ * predicción de palabra siguiente y se inserta en el cursor. En ambos casos refuerza
+ * el bigrama y encadena nuevas predicciones.
+ */
fun commitSuggestion(word: String) {
- val cased = if (composing.firstOrNull()?.isUpperCase() == true) {
- word.replaceFirstChar { it.uppercaseChar() }
- } else {
- word
- }
- host.commit("$cased ") // commitText reemplaza la región en composición
+ haptic()
+ val capitalize = composing.firstOrNull()?.isUpperCase() == true ||
+ (composing.isEmpty() && shifted)
+ val cased = if (capitalize) word.replaceFirstChar { it.uppercaseChar() } else word
+ host.commit("$cased ") // commitText reemplaza la región en composición si la hay
+ lastWord?.let { engine.learnBigram(it, word) }
engine.learn(word)
+ lastWord = word.lowercase()
composing.clear()
- suggestions = emptyList()
syncAutoShift()
+ showNextWords()
}
fun moveCursor(steps: Int) {
+ if (settings.haptics) haptics.tap(HapticLevel.SUAVE)
if (composing.isNotEmpty()) finishWord()
host.moveCursor(steps)
+ lastWord = null
+ suggestions = emptyList()
syncAutoShift()
}
@@ -129,6 +172,7 @@ class KeyboardController(
fun reset() {
composing.clear()
suggestions = emptyList()
+ lastWord = null
}
/** El editor terminó la composición por su cuenta (cursor movido fuera, etc.). */
@@ -136,6 +180,7 @@ class KeyboardController(
if (composing.isEmpty()) return
composing.clear()
suggestions = emptyList()
+ lastWord = null
}
fun hasComposing(): Boolean = composing.isNotEmpty()
@@ -146,16 +191,28 @@ class KeyboardController(
shift = if (host.cursorCapsMode() != 0) ShiftState.ON else ShiftState.OFF
}
+ private fun haptic() {
+ if (settings.haptics) haptics.tap(settings.hapticLevel)
+ }
+
private fun finishWord() {
if (composing.isNotEmpty()) {
host.finishComposing()
- engine.learn(composing.toString())
+ val word = composing.toString()
+ engine.learn(word)
+ lastWord?.let { engine.learnBigram(it, word) }
+ lastWord = word.lowercase().takeIf { it.length >= 2 && it.all { c -> c.isLetter() } }
composing.clear()
}
suggestions = emptyList()
}
+ private fun showNextWords() {
+ val prev = lastWord
+ suggestions = if (prev != null) engine.nextWords(prev, 3) else emptyList()
+ }
+
private fun refreshSuggestions() {
- suggestions = engine.suggest(composing.toString(), 3)
+ suggestions = engine.suggest(composing.toString(), settings.spanish, settings.english, 3)
}
}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/model/Emoji.kt b/keyboard/src/main/java/com/rizoma/trazo/model/Emoji.kt
new file mode 100644
index 0000000..433c139
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/model/Emoji.kt
@@ -0,0 +1,44 @@
+package com.rizoma.trazo.model
+
+/** Catálogo curado de emojis por categoría para la capa de emojis de Trazo. */
+object Emojis {
+
+ val categories: List<Pair<String, List<String>>> = listOf(
+ "caras" to listOf(
+ "😀", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉",
+ "😊", "😇", "🥰", "😍", "😘", "😋", "😛", "🤪", "🤔", "🤨",
+ "😐", "😑", "😏", "😒", "🙄", "😔", "😴", "😌", "😬", "😳",
+ "🥺", "😢", "😭", "😤", "😠", "😎", "🤩", "🥳", "😱", "🤯",
+ ),
+ "gestos" to listOf(
+ "👍", "👎", "👌", "🤌", "✌️", "🤞", "🤟", "🤙", "👈", "👉",
+ "👆", "👇", "✋", "🤚", "👏", "🙌", "🤝", "🙏", "💪", "👀",
+ "🫶", "🤲", "☝️", "✊",
+ ),
+ "corazones" to listOf(
+ "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔",
+ "❣️", "💕", "💞", "💓", "💗", "💖", "✨", "💫", "⭐", "🌟",
+ "🔥", "💥", "💯", "🎉",
+ ),
+ "animales" to listOf(
+ "🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯",
+ "🦁", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🦄", "🐝", "🦋",
+ "🐢", "🐙", "🦀", "🐠",
+ ),
+ "comida" to listOf(
+ "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🍒", "🍑",
+ "🥭", "🍍", "🥝", "🍅", "🥑", "🌽", "🌶️", "🍕", "🍔", "🍟",
+ "🌮", "🍣", "🍦", "☕", "🍺", "🍷",
+ ),
+ "objetos" to listOf(
+ "📱", "💻", "⌨️", "🖥️", "🎧", "📷", "🔋", "💡", "🔑", "🔒",
+ "✏️", "📌", "📎", "✂️", "📚", "💰", "🎁", "🎈", "🏆", "⚽",
+ "🎮", "🎯", "🚀", "🎵",
+ ),
+ "símbolos" to listOf(
+ "✅", "❌", "⭕", "❗", "❓", "💤", "🔴", "🟠", "🟡", "🟢",
+ "🔵", "🟣", "➕", "➖", "♻️", "⚡", "☀️", "🌙", "☁️", "🌈",
+ "💧", "❄️", "🌊", "💀",
+ ),
+ )
+}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/ui/EmojiGrid.kt b/keyboard/src/main/java/com/rizoma/trazo/ui/EmojiGrid.kt
new file mode 100644
index 0000000..a3d6886
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/ui/EmojiGrid.kt
@@ -0,0 +1,69 @@
+package com.rizoma.trazo.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyGridScope
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.trazo.model.Emojis
+import com.rizoma.trazo.ui.theme.Tenue
+
+/**
+ * Panel de emojis desplazable: primero "recientes" (si los hay), luego las categorías.
+ * Sustituye al grid 3x3 cuando la capa de emojis está activa.
+ */
+@Composable
+fun EmojiPad(
+ recents: List<String>,
+ onEmoji: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(8),
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ if (recents.isNotEmpty()) {
+ header("recientes")
+ items(recents, key = { "r:$it" }) { EmojiCell(it, onEmoji) }
+ }
+ Emojis.categories.forEach { (name, list) ->
+ header(name)
+ items(list, key = { "$name:$it" }) { EmojiCell(it, onEmoji) }
+ }
+ }
+}
+
+private fun LazyGridScope.header(text: String) {
+ item(span = { GridItemSpan(maxLineSpan) }) {
+ Text(
+ text = text,
+ color = Tenue,
+ fontSize = 11.sp,
+ modifier = Modifier.padding(start = 10.dp, top = 10.dp, bottom = 2.dp),
+ )
+ }
+}
+
+@Composable
+private fun EmojiCell(emoji: String, onEmoji: (String) -> Unit) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(42.dp)
+ .clickable { onEmoji(emoji) },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(text = emoji, fontSize = 24.sp)
+ }
+}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt b/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt
index ea448e2..abbf561 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt
@@ -28,6 +28,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.rizoma.trazo.data.TrazoSettings
import com.rizoma.trazo.ime.KeyboardController
import com.rizoma.trazo.ime.Layer
import com.rizoma.trazo.ime.ShiftState
@@ -41,7 +42,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
-fun KeyboardScreen(controller: KeyboardController) {
+fun KeyboardScreen(controller: KeyboardController, settings: TrazoSettings) {
Column(
Modifier
.fillMaxWidth()
@@ -52,34 +53,48 @@ fun KeyboardScreen(controller: KeyboardController) {
) {
SuggestionBar(controller.suggestions, onPick = controller::commitSuggestion)
- val keys = controller.keys
- 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(),
- )
+ if (controller.layer == Layer.EMOJIS) {
+ EmojiPad(
+ recents = settings.recentEmojis,
+ onEmoji = controller::onEmoji,
+ modifier = Modifier.fillMaxWidth().height(210.dp),
+ )
+ Row(Modifier.fillMaxWidth().height(52.dp)) {
+ FuncKey("ABC", NeonCian, Modifier.weight(1.4f), onClick = controller::toggleLayer)
+ SpaceKey(Modifier.weight(3f), onSpace = controller::onSpace, onCursor = controller::moveCursor)
+ BackspaceKey(Modifier.weight(1.4f), onBackspace = controller::onBackspace)
+ }
+ } else {
+ val keys = controller.keys
+ 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(),
+ )
+ }
}
}
- }
- Row(Modifier.fillMaxWidth().height(52.dp)) {
- val shiftAccent = when (controller.shift) {
- ShiftState.OFF -> Tenue
- ShiftState.ON -> NeonVerde
- ShiftState.LOCK -> NeonRosa
+ 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("😊", NeonRosa, Modifier.weight(1f), onClick = controller::showEmojis)
+ SpaceKey(Modifier.weight(3f), onSpace = controller::onSpace, onCursor = controller::moveCursor)
+ BackspaceKey(Modifier.weight(1f), onBackspace = controller::onBackspace)
+ FuncKey("↵", NeonVerde, Modifier.weight(1f), onClick = controller::onEnter)
}
- FuncKey("⇧", shiftAccent, Modifier.weight(1f), onClick = controller::onShift)
- FuncKey(
- if (controller.layer == Layer.LETTERS) "?123" else "ABC",
- NeonCian, Modifier.weight(1f), onClick = controller::toggleLayer,
- )
- SpaceKey(Modifier.weight(3f), onSpace = controller::onSpace, onCursor = controller::moveCursor)
- BackspaceKey(Modifier.weight(1f), onBackspace = controller::onBackspace)
- FuncKey("↵", NeonVerde, Modifier.weight(1f), onClick = controller::onEnter)
}
}
}