Implement Readability Highlighting in compose

This could still use some performance tweaks to make it run a bit better but at least it works
This commit is contained in:
William Brawner 2023-09-11 21:58:19 -06:00
parent c7e54f21d9
commit 493444aaab
4 changed files with 322 additions and 279 deletions

View file

@ -8,7 +8,7 @@ class Readability(private val content: String) {
val list = ArrayList<Sentence>() val list = ArrayList<Sentence>()
var startOfSentance = 0 var startOfSentance = 0
var lineBuilder = StringBuilder() var lineBuilder = StringBuilder()
for (i in 0 until content.length) { for (i in content.indices) {
val c = content[i] + "" val c = content[i] + ""
if (DELIMS.contains(c)) { if (DELIMS.contains(c)) {
list.add(Sentence(content, startOfSentance, i)) list.add(Sentence(content, startOfSentance, i))

View file

@ -1,6 +1,8 @@
package com.wbrawner.simplemarkdown.ui package com.wbrawner.simplemarkdown.ui
import android.content.Intent import android.content.Intent
import android.text.SpannableString
import android.text.style.BackgroundColorSpan
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -48,16 +50,22 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity import androidx.core.content.ContextCompat.startActivity
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.R import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.model.Readability
import com.wbrawner.simplemarkdown.view.activity.Route import com.wbrawner.simplemarkdown.view.activity.Route
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@ -121,6 +129,11 @@ fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
}) { paddingValues -> }) { paddingValues ->
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { 2 } val pagerState = rememberPagerState { 2 }
val context = LocalContext.current
val enableReadability = remember {
PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(PREF_KEY_READABILITY, false)
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -139,14 +152,32 @@ fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
userScrollEnabled = !lockSwiping userScrollEnabled = !lockSwiping
) { page -> ) { page ->
val markdown by viewModel.markdownUpdates.collectAsState() val markdown by viewModel.markdownUpdates.collectAsState()
var textFieldValue by remember {
val annotatedMarkdown = if (enableReadability) {
markdown.annotateReadability()
} else {
AnnotatedString(markdown)
}
mutableStateOf(TextFieldValue(annotatedMarkdown))
}
if (page == 0) { if (page == 0) {
BasicTextField( BasicTextField(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(8.dp), .padding(8.dp),
value = markdown, value = textFieldValue,
onValueChange = { viewModel.updateMarkdown(it) }, onValueChange = {
textStyle = TextStyle.Default.copy(fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurface), textFieldValue = if (enableReadability) {
it.copy(annotatedString = it.text.annotateReadability())
} else {
it
}
viewModel.updateMarkdown(it.text)
},
textStyle = TextStyle.Default.copy(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface) cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
) )
@ -159,6 +190,18 @@ fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
} }
} }
private fun String.annotateReadability(): AnnotatedString {
val readability = Readability(this)
val annotated = AnnotatedString.Builder(this)
for (sentence in readability.sentences()) {
var color = Color.Transparent
if (sentence.syllableCount() > 25) color = Color(229, 232, 42, 100)
if (sentence.syllableCount() > 35) color = Color(193, 66, 66, 100)
annotated.addStyle(SpanStyle(background = color), sentence.start(), sentence.end())
}
return annotated.toAnnotatedString()
}
@Composable @Composable
fun MarkdownNavigationDrawer( fun MarkdownNavigationDrawer(
navigate: (Route) -> Unit, content: @Composable (drawerState: DrawerState) -> Unit navigate: (Route) -> Unit, content: @Composable (drawerState: DrawerState) -> Unit

View file

@ -1,179 +1,179 @@
package com.wbrawner.simplemarkdown.view.fragment //package com.wbrawner.simplemarkdown.view.fragment
//
import android.annotation.SuppressLint //import android.annotation.SuppressLint
import android.graphics.Color //import android.graphics.Color
import android.os.Bundle //import android.os.Bundle
import android.text.Editable //import android.text.Editable
import android.text.SpannableString //import android.text.SpannableString
import android.text.TextWatcher //import android.text.TextWatcher
import android.text.style.BackgroundColorSpan //import android.text.style.BackgroundColorSpan
import android.view.LayoutInflater //import android.view.LayoutInflater
import android.view.MotionEvent //import android.view.MotionEvent
import android.view.View //import android.view.View
import android.view.ViewGroup //import android.view.ViewGroup
import android.widget.EditText //import android.widget.EditText
import android.widget.TextView //import android.widget.TextView
import androidx.core.widget.NestedScrollView //import androidx.core.widget.NestedScrollView
import androidx.fragment.app.Fragment //import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels //import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer //import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope //import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager //import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.R //import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.model.Readability //import com.wbrawner.simplemarkdown.model.Readability
import com.wbrawner.simplemarkdown.utility.hideKeyboard //import com.wbrawner.simplemarkdown.utility.hideKeyboard
import com.wbrawner.simplemarkdown.utility.showKeyboard //import com.wbrawner.simplemarkdown.utility.showKeyboard
import com.wbrawner.simplemarkdown.view.ViewPagerPage //import com.wbrawner.simplemarkdown.view.ViewPagerPage
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel //import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.coroutines.* //import kotlinx.coroutines.*
import timber.log.Timber //import timber.log.Timber
import kotlin.math.abs //import kotlin.math.abs
//
class EditFragment : Fragment(), ViewPagerPage { //class EditFragment : Fragment(), ViewPagerPage {
private var markdownEditor: EditText? = null // private var markdownEditor: EditText? = null
private var markdownEditorScroller: NestedScrollView? = null // private var markdownEditorScroller: NestedScrollView? = null
private val viewModel: MarkdownViewModel by viewModels({ requireParentFragment() }) // private val viewModel: MarkdownViewModel by viewModels({ requireParentFragment() })
private var readabilityWatcher: TextWatcher? = null // private var readabilityWatcher: TextWatcher? = null
private val markdownWatcher = object : TextWatcher { // private val markdownWatcher = object : TextWatcher {
private var searchFor = "" // private var searchFor = ""
//
override fun afterTextChanged(s: Editable?) { // override fun afterTextChanged(s: Editable?) {
val searchText = s.toString().trim() // val searchText = s.toString().trim()
if (searchText == searchFor) // if (searchText == searchFor)
return // return
//
searchFor = searchText // searchFor = searchText
//
lifecycleScope.launch { // lifecycleScope.launch {
delay(50) // delay(50)
if (searchText != searchFor) // if (searchText != searchFor)
return@launch // return@launch
viewModel.updateMarkdown(searchText) // viewModel.updateMarkdown(searchText)
} // }
} // }
//
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { // override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
} // }
//
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { // override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
} // }
} // }
//
@SuppressLint("ClickableViewAccessibility") // @SuppressLint("ClickableViewAccessibility")
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, // override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? = // savedInstanceState: Bundle?): View? =
inflater.inflate(R.layout.fragment_edit, container, false) // inflater.inflate(R.layout.fragment_edit, container, false)
//
@SuppressLint("ClickableViewAccessibility") // @SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { // override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) // super.onViewCreated(view, savedInstanceState)
markdownEditor = view.findViewById(R.id.markdown_edit) // markdownEditor = view.findViewById(R.id.markdown_edit)
markdownEditorScroller = view.findViewById(R.id.markdown_edit_container) // markdownEditorScroller = view.findViewById(R.id.markdown_edit_container)
markdownEditor?.addTextChangedListener(markdownWatcher) // markdownEditor?.addTextChangedListener(markdownWatcher)
//
var touchDown = 0L // var touchDown = 0L
var oldX = 0f // var oldX = 0f
var oldY = 0f // var oldY = 0f
markdownEditorScroller!!.setOnTouchListener { _, event -> // markdownEditorScroller!!.setOnTouchListener { _, event ->
// The ScrollView's onClickListener doesn't seem to be called, so I've had to // // The ScrollView's onClickListener doesn't seem to be called, so I've had to
// implement a sort of custom click listener that checks that the tap was both quick // // implement a sort of custom click listener that checks that the tap was both quick
// and didn't drag. // // and didn't drag.
when (event?.action) { // when (event?.action) {
MotionEvent.ACTION_DOWN -> { // MotionEvent.ACTION_DOWN -> {
touchDown = System.currentTimeMillis() // touchDown = System.currentTimeMillis()
oldX = event.rawX // oldX = event.rawX
oldY = event.rawY // oldY = event.rawY
} // }
MotionEvent.ACTION_UP -> { // MotionEvent.ACTION_UP -> {
if (System.currentTimeMillis() - touchDown < 150 // if (System.currentTimeMillis() - touchDown < 150
&& abs(event.rawX - oldX) < 25 // && abs(event.rawX - oldX) < 25
&& abs(event.rawY - oldY) < 25) // && abs(event.rawY - oldY) < 25)
markdownEditor?.showKeyboard() // markdownEditor?.showKeyboard()
} // }
} // }
false // false
} // }
markdownEditor?.setText(viewModel.markdownUpdates.value) // markdownEditor?.setText(viewModel.markdownUpdates.value)
viewModel.editorActions.observe(viewLifecycleOwner, Observer { // viewModel.editorActions.observe(viewLifecycleOwner, Observer {
if (it.consumed.getAndSet(true)) return@Observer // if (it.consumed.getAndSet(true)) return@Observer
if (it is MarkdownViewModel.EditorAction.Load) { // if (it is MarkdownViewModel.EditorAction.Load) {
markdownEditor?.apply { // markdownEditor?.apply {
removeTextChangedListener(markdownWatcher) // removeTextChangedListener(markdownWatcher)
setText(it.markdown) // setText(it.markdown)
addTextChangedListener(markdownWatcher) // addTextChangedListener(markdownWatcher)
} // }
} // }
}) // })
lifecycleScope.launch { // lifecycleScope.launch {
val enableReadability = withContext(Dispatchers.IO) { // val enableReadability = withContext(Dispatchers.IO) {
context?.let { // context?.let {
PreferenceManager.getDefaultSharedPreferences(it) // PreferenceManager.getDefaultSharedPreferences(it)
.getBoolean(getString(R.string.readability_enabled), false) // .getBoolean(getString(R.string.readability_enabled), false)
} ?: false // } ?: false
} // }
if (enableReadability) { // if (enableReadability) {
if (readabilityWatcher == null) { // if (readabilityWatcher == null) {
readabilityWatcher = ReadabilityTextWatcher() // readabilityWatcher = ReadabilityTextWatcher()
} // }
markdownEditor?.addTextChangedListener(readabilityWatcher) // markdownEditor?.addTextChangedListener(readabilityWatcher)
} else { // } else {
readabilityWatcher?.let { // readabilityWatcher?.let {
markdownEditor?.removeTextChangedListener(it) // markdownEditor?.removeTextChangedListener(it)
} // }
readabilityWatcher = null // readabilityWatcher = null
} // }
} // }
} // }
//
override fun onSelected() { // override fun onSelected() {
markdownEditor?.showKeyboard() // markdownEditor?.showKeyboard()
} // }
//
override fun onDeselected() { // override fun onDeselected() {
markdownEditor?.hideKeyboard() // markdownEditor?.hideKeyboard()
} // }
//
inner class ReadabilityTextWatcher : TextWatcher { // inner class ReadabilityTextWatcher : TextWatcher {
private var previousValue = "" // private var previousValue = ""
private var searchFor = "" // private var searchFor = ""
//
override fun afterTextChanged(s: Editable?) { // override fun afterTextChanged(s: Editable?) {
val searchText = s.toString().trim() // val searchText = s.toString().trim()
if (searchText == searchFor) // if (searchText == searchFor)
return // return
//
searchFor = searchText // searchFor = searchText
//
lifecycleScope.launch { // lifecycleScope.launch {
delay(250) // delay(250)
if (searchText != searchFor) // if (searchText != searchFor)
return@launch // return@launch
val start = System.currentTimeMillis() // val start = System.currentTimeMillis()
if (searchFor.isEmpty()) return@launch // if (searchFor.isEmpty()) return@launch
if (previousValue == searchFor) return@launch // if (previousValue == searchFor) return@launch
val readability = Readability(searchFor) // val readability = Readability(searchFor)
val span = SpannableString(searchFor) // val span = SpannableString(searchFor)
for (sentence in readability.sentences()) { // for (sentence in readability.sentences()) {
var color = Color.TRANSPARENT // var color = Color.TRANSPARENT
if (sentence.syllableCount() > 25) color = Color.argb(100, 229, 232, 42) // if (sentence.syllableCount() > 25) color = Color.argb(100, 229, 232, 42)
if (sentence.syllableCount() > 35) color = Color.argb(100, 193, 66, 66) // if (sentence.syllableCount() > 35) color = Color.argb(100, 193, 66, 66)
Timber.d("Sentence start: ${sentence.start()} end: ${ // Timber.d("Sentence start: ${sentence.start()} end: ${
sentence // sentence
.end() // .end()
}") // }")
span.setSpan(BackgroundColorSpan(color), sentence.start(), sentence.end(), 0) // span.setSpan(BackgroundColorSpan(color), sentence.start(), sentence.end(), 0)
} // }
markdownEditor?.setTextKeepState(span, TextView.BufferType.SPANNABLE) // markdownEditor?.setTextKeepState(span, TextView.BufferType.SPANNABLE)
previousValue = searchFor // previousValue = searchFor
val timeTakenMs = System.currentTimeMillis() - start // val timeTakenMs = System.currentTimeMillis() - start
Timber.d("Handled markdown in $timeTakenMs ms") // Timber.d("Handled markdown in $timeTakenMs ms")
} // }
} // }
//
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { // override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
} // }
//
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { // override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
} // }
} // }
} //}

View file

@ -1,96 +1,96 @@
package com.wbrawner.simplemarkdown.view.fragment //package com.wbrawner.simplemarkdown.view.fragment
//
import android.content.Context //import android.content.Context
import android.content.res.Configuration.UI_MODE_NIGHT_MASK //import android.content.res.Configuration.UI_MODE_NIGHT_MASK
import android.content.res.Configuration.UI_MODE_NIGHT_YES //import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.os.Bundle //import android.os.Bundle
import android.view.LayoutInflater //import android.view.LayoutInflater
import android.view.View //import android.view.View
import android.view.ViewGroup //import android.view.ViewGroup
import android.webkit.WebView //import android.webkit.WebView
import androidx.appcompat.app.AppCompatDelegate //import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.Fragment //import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels //import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope //import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager //import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.BuildConfig //import com.wbrawner.simplemarkdown.BuildConfig
import com.wbrawner.simplemarkdown.R //import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.utility.toHtml //import com.wbrawner.simplemarkdown.utility.toHtml
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel //import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.coroutines.* //import kotlinx.coroutines.*
//
class PreviewFragment : Fragment() { //class PreviewFragment : Fragment() {
private val viewModel: MarkdownViewModel by viewModels({ requireParentFragment() }) // private val viewModel: MarkdownViewModel by viewModels({ requireParentFragment() })
private var markdownPreview: WebView? = null // private var markdownPreview: WebView? = null
private var style: String = "" // private var style: String = ""
//
override fun onCreateView( // override fun onCreateView(
inflater: LayoutInflater, // inflater: LayoutInflater,
container: ViewGroup?, // container: ViewGroup?,
savedInstanceState: Bundle? // savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_preview, container, false) // ): View? = inflater.inflate(R.layout.fragment_preview, container, false)
//
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { // override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
markdownPreview = view.findViewById(R.id.markdown_view) // markdownPreview = view.findViewById(R.id.markdown_view)
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) // WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
lifecycleScope.launch { // lifecycleScope.launch {
val isNightMode = AppCompatDelegate.getDefaultNightMode() == // val isNightMode = AppCompatDelegate.getDefaultNightMode() ==
AppCompatDelegate.MODE_NIGHT_YES // AppCompatDelegate.MODE_NIGHT_YES
|| requireContext().resources.configuration.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES // || requireContext().resources.configuration.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES
val defaultCssId = if (isNightMode) { // val defaultCssId = if (isNightMode) {
R.string.pref_custom_css_default_dark // R.string.pref_custom_css_default_dark
} else { // } else {
R.string.pref_custom_css_default // R.string.pref_custom_css_default
} // }
val css = withContext(Dispatchers.IO) { // val css = withContext(Dispatchers.IO) {
val context = context ?: return@withContext null // val context = context ?: return@withContext null
@Suppress("ConstantConditionIf") // @Suppress("ConstantConditionIf")
if (!BuildConfig.ENABLE_CUSTOM_CSS) { // if (!BuildConfig.ENABLE_CUSTOM_CSS) {
context.getString(defaultCssId) // context.getString(defaultCssId)
} else { // } else {
PreferenceManager.getDefaultSharedPreferences(context) // PreferenceManager.getDefaultSharedPreferences(context)
.getString( // .getString(
getString(R.string.pref_custom_css), // getString(R.string.pref_custom_css),
getString(defaultCssId) // getString(defaultCssId)
) // )
} // }
} // }
style = String.format(FORMAT_CSS, css ?: "") // style = String.format(FORMAT_CSS, css ?: "")
} // }
} // }
//
override fun onAttach(context: Context) { // override fun onAttach(context: Context) {
super.onAttach(context) // super.onAttach(context)
updateWebContent(viewModel.markdownUpdates.value ?: "") // updateWebContent(viewModel.markdownUpdates.value ?: "")
// viewModel.markdownUpdates.observe(this, { //// viewModel.markdownUpdates.observe(this, {
// updateWebContent(it) //// updateWebContent(it)
// }) //// })
} // }
//
private fun updateWebContent(markdown: String) { // private fun updateWebContent(markdown: String) {
markdownPreview?.post { // markdownPreview?.post {
lifecycleScope.launch { // lifecycleScope.launch {
markdownPreview?.loadDataWithBaseURL(null, // markdownPreview?.loadDataWithBaseURL(null,
style + markdown.toHtml(), // style + markdown.toHtml(),
"text/html", // "text/html",
"UTF-8", null // "UTF-8", null
) // )
} // }
} // }
} // }
//
override fun onDestroyView() { // override fun onDestroyView() {
markdownPreview?.let { // markdownPreview?.let {
(it.parent as ViewGroup).removeView(it) // (it.parent as ViewGroup).removeView(it)
it.destroy() // it.destroy()
markdownPreview = null // markdownPreview = null
} // }
super.onDestroyView() // super.onDestroyView()
} // }
//
companion object { // companion object {
var FORMAT_CSS = "<style>" + // var FORMAT_CSS = "<style>" +
"%s" + // "%s" +
"</style>" // "</style>"
} // }
} //}