Skip to content

Commit

Permalink
More realistic Spell Checker
Browse files Browse the repository at this point in the history
  • Loading branch information
Wavesonics committed Nov 18, 2024
1 parent 7a23839 commit 8b9d239
Show file tree
Hide file tree
Showing 3 changed files with 112 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,9 @@ public fun BasicRichTextEditor(
LaunchedEffect(interactionSource) {
scope.launch {
interactionSource.interactions.collect { interaction ->
if (interaction is PressInteraction.Press) {
state.getRichSpanByOffset(interaction.pressPosition)?.let { clickedSpan ->
onRichSpanClick(clickedSpan, interaction.pressPosition)
if (interaction is PressInteraction.Release) {
state.getRichSpanByOffset(interaction.press.pressPosition)?.let { clickedSpan ->
onRichSpanClick(clickedSpan, interaction.press.pressPosition)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
package com.mohamedrejeb.richeditor.sample.common.spellcheck

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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
Expand All @@ -25,7 +29,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
Expand All @@ -43,6 +49,13 @@ import com.mohamedrejeb.richeditor.sample.common.components.RichTextStyleRow
import com.mohamedrejeb.richeditor.sample.common.richeditor.SpellCheck
import com.mohamedrejeb.richeditor.sample.common.ui.theme.ComposeRichEditorTheme
import com.mohamedrejeb.richeditor.ui.BasicRichTextEditor
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand All @@ -67,21 +80,52 @@ fun SpellCheckContent() {
) { paddingValue ->

val richTextState = rememberRichTextState()
val spellChecker = rememberSpellChecker()
val spellChecker by rememberSpellChecker()
var lastTextHash = remember { -1 }

fun runSpellCheck() {
spellChecker ?: return
val sp = spellChecker ?: return
//println("Running spell check...")

richTextState.toText().getWords().forEach { (word, range) ->
println("Spell Checking word: $word")
val suggestions = spellChecker.lookup(word)
val suggestions = sp.lookup(word)
if (suggestions.spellingIsCorrect(word).not()) {
println("Misspelling found!")
//println("Misspelling found!")
richTextState.addRichSpan(SpellCheck, range)
}
}
}

// Run SpellCheck as soon as it is ready
LaunchedEffect(spellChecker) {
if (spellChecker != null) {
runSpellCheck()
}
}

val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
scope.launch {
// This is a very naive algorithm that just removes all spell check spans and
// reruns the entire spell check again
richTextState.textChanges.debounceUntilQuiescent(1.seconds).collect { updated ->
val newTextHash = updated.toText().hashCode()
if (lastTextHash != newTextHash) {
// Remove all existing spell checks
richTextState.getAllRichSpans()
.filter { it.richSpanStyle is SpellCheck }
.forEach { span ->
richTextState.removeRichSpan(SpellCheck, span.textRange)
}

runSpellCheck()

lastTextHash = newTextHash
}
}
}
}

var spellCheckWord by remember { mutableStateOf<RichSpan?>(null) }
var expanded by remember { mutableStateOf(false) }
var menuPosition by remember { mutableStateOf(Offset.Zero) }
Expand All @@ -107,43 +151,56 @@ fun SpellCheckContent() {
.fillMaxSize()
.padding(20.dp)
) {
Button(
onClick = ::runSpellCheck,
enabled = (spellChecker != null)
) {
Text("Run Spell Check")
}
RichTextStyleRow(
modifier = Modifier.fillMaxWidth(),
state = richTextState,
)

BasicRichTextEditor(
modifier = Modifier.fillMaxWidth(),
state = richTextState,
textStyle = TextStyle.Default.copy(color = Color.White),
cursorBrush = SolidColor(Color.White),
onRichSpanClick = { span, click ->
if (span.richSpanStyle is SpellCheck) {
println("On Click: $span")
spellCheckWord = span
menuPosition = click
expanded = true
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
BasicRichTextEditor(
modifier = Modifier.fillMaxSize(),
state = richTextState,
textStyle = TextStyle.Default.copy(color = Color.White),
cursorBrush = SolidColor(Color.White),
onRichSpanClick = { span, click ->
if (span.richSpanStyle is SpellCheck) {
println("On Click: $span")
spellCheckWord = span
menuPosition = click
expanded = true
}
},
)

SpellCheckDropdown(
spellCheckWord,
menuPosition,
spellChecker,
dismiss = ::clearSpellCheck,
correctSpelling = { span, correction ->
println("Correcting spelling to: $correction")
richTextState.replaceTextRange(span.textRange, correction)
clearSpellCheck()
}
},
)
)
}

SpellCheckDropdown(
spellCheckWord,
menuPosition,
spellChecker,
dismiss = ::clearSpellCheck,
correctSpelling = { span, correction ->
println("Correcting spelling to: $correction")
richTextState.replaceTextRange(span.textRange, correction)
clearSpellCheck()
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
) {
if (spellChecker == null) {
CircularProgressIndicator(modifier = Modifier.size(25.dp))
Text(" Loading Dictionary...")
} else {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Loaded",
)
Text(" Spell Check Ready!")
}
)
}
}
}
}
Expand Down Expand Up @@ -185,7 +242,7 @@ fun SpellCheckDropdown(
spellChecker ?: return@LaunchedEffect

val suggestions = spellChecker.lookupCompound(word.text)
if(word.text.isSpelledCorrectly(suggestions).not()) {
if (word.text.isSpelledCorrectly(suggestions).not()) {
suggestionItems = suggestions
}
}
Expand All @@ -204,4 +261,16 @@ fun SpellCheckDropdown(
}
}
}
}

private fun <T> Flow<T>.debounceUntilQuiescent(duration: Duration): Flow<T> = channelFlow {
var job: Job? = null
collect { value ->
job?.cancel()
job = launch {
delay(duration)
send(value)
job = null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ package com.mohamedrejeb.richeditor.sample.common.spellcheck

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.darkrockstudios.symspellkt.api.SpellChecker
import com.darkrockstudios.symspellkt.impl.SymSpell
import com.darkrockstudios.symspellkt.impl.loadUniGramLine
Expand All @@ -18,9 +17,9 @@ import org.jetbrains.compose.resources.ExperimentalResourceApi

@OptIn(ExperimentalResourceApi::class)
@Composable
fun rememberSpellChecker(): SpellChecker? {
fun rememberSpellChecker(): MutableState<SpellChecker?> {
val scope = rememberCoroutineScope()
var spellChecker by remember { mutableStateOf<SpellChecker?>(null) }
val spellChecker = remember { mutableStateOf<SpellChecker?>(null) }

LaunchedEffect(Unit) {
scope.launch(Dispatchers.Default) {
Expand All @@ -34,7 +33,7 @@ fun rememberSpellChecker(): SpellChecker? {
yield()
}

spellChecker = checker
spellChecker.value = checker
}
}

Expand Down

0 comments on commit 8b9d239

Please sign in to comment.