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>()
var startOfSentance = 0
var lineBuilder = StringBuilder()
for (i in 0 until content.length) {
for (i in content.indices) {
val c = content[i] + ""
if (DELIMS.contains(c)) {
list.add(Sentence(content, startOfSentance, i))

View file

@ -1,6 +1,8 @@
package com.wbrawner.simplemarkdown.ui
import android.content.Intent
import android.text.SpannableString
import android.text.style.BackgroundColorSpan
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
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.SolidColor
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.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity
import androidx.navigation.NavController
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.model.Readability
import com.wbrawner.simplemarkdown.view.activity.Route
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
@OptIn(ExperimentalFoundationApi::class)
@Composable
@ -121,6 +129,11 @@ fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
}) { paddingValues ->
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { 2 }
val context = LocalContext.current
val enableReadability = remember {
PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(PREF_KEY_READABILITY, false)
}
Column(
modifier = Modifier
.fillMaxSize()
@ -139,14 +152,32 @@ fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
userScrollEnabled = !lockSwiping
) { page ->
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) {
BasicTextField(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
value = markdown,
onValueChange = { viewModel.updateMarkdown(it) },
textStyle = TextStyle.Default.copy(fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurface),
value = textFieldValue,
onValueChange = {
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),
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
fun MarkdownNavigationDrawer(
navigate: (Route) -> Unit, content: @Composable (drawerState: DrawerState) -> Unit

View file

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

View file

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