1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 13:57:10 +09:00

Editor | Improvement | Code tab Indentation (#2240)

Co-authored-by: Mikhail Iudin <mayudin@anytype.io>
This commit is contained in:
Mikhail 2022-05-17 12:25:21 +03:00 committed by GitHub
parent b81acb1313
commit 04f194f1f8
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 300 additions and 5 deletions

View file

@ -2,7 +2,10 @@ package com.anytypeio.anytype.core_ui.widgets.text
import android.content.Context
import android.text.Editable
import android.text.InputType.*
import android.text.InputType.TYPE_CLASS_TEXT
import android.text.InputType.TYPE_NULL
import android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE
import android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
import android.text.TextWatcher
import android.util.AttributeSet
import android.view.DragEvent
@ -10,7 +13,12 @@ import android.view.MotionEvent
import androidx.appcompat.widget.AppCompatEditText
import com.anytypeio.anytype.core_ui.features.editor.EditorTouchProcessor
import com.anytypeio.anytype.core_ui.tools.DefaultTextWatcher
import com.anytypeio.anytype.library_syntax_highlighter.*
import com.anytypeio.anytype.library_syntax_highlighter.Syntax
import com.anytypeio.anytype.library_syntax_highlighter.SyntaxHighlighter
import com.anytypeio.anytype.library_syntax_highlighter.SyntaxTextWatcher
import com.anytypeio.anytype.library_syntax_highlighter.Syntaxes
import com.anytypeio.anytype.library_syntax_highlighter.obtainGenericSyntaxRules
import com.anytypeio.anytype.library_syntax_highlighter.obtainSyntaxRules
import timber.log.Timber
class CodeTextInputWidget : AppCompatEditText, SyntaxHighlighter {
@ -49,12 +57,13 @@ class CodeTextInputWidget : AppCompatEditText, SyntaxHighlighter {
private fun setup() {
enableEditMode()
super.addTextChangedListener(MonospaceTabTextWatcher(paint.measureText(MEASURING_CHAR)))
}
private fun setupSyntaxHighlighter() {
addRules(context.obtainSyntaxRules(Syntaxes.KOTLIN))
highlight()
addTextChangedListener(syntaxTextWatcher)
super.addTextChangedListener(syntaxTextWatcher)
}
fun enableEditMode() {
@ -72,7 +81,7 @@ class CodeTextInputWidget : AppCompatEditText, SyntaxHighlighter {
}
override fun addTextChangedListener(watcher: TextWatcher) {
if (watcher !is SyntaxTextWatcher) watchers.add(watcher)
watchers.add(watcher)
super.addTextChangedListener(watcher)
}
@ -136,4 +145,6 @@ class CodeTextInputWidget : AppCompatEditText, SyntaxHighlighter {
if (hasFocus()) return super.onTouchEvent(event)
return editorTouchProcessor.process(this, event)
}
}
}
private const val MEASURING_CHAR = " "

View file

@ -0,0 +1,141 @@
package com.anytypeio.anytype.core_ui.widgets.text
import android.graphics.Canvas
import android.graphics.Paint
import android.text.Editable
import android.text.Spannable
import android.text.TextWatcher
import android.text.style.ReplacementSpan
import kotlin.math.max
class MonospaceTabTextWatcher(
private val oneLetterWidth: Float
) : TextWatcher {
private var start = 0
private var end = 0
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
this.start = start
end = start + max(count, before)
}
override fun afterTextChanged(s: Editable) {
applyTabWidth(s, start, end)
}
/**
* Function gets the substring and extracts ALL lines that intersects from
* [start] to [end] in this way:
* 1. Find a line that starts before [start],
* or include -1 (fake start index if we inside first line)
* 2. Find all "\n" characters indexes in the substring of [text] from [start] to [end]
* 3. Find a line that starts after [end],
* or include text.length (fake end index if we inside last line)
*/
private fun getAllNewLineCharacterIndexes(text: Editable, start: Int, end: Int): List<Int> {
val newLineCharacterIndices = mutableListOf<Int>()
val firstNewLineCharacterIndex = text.lastIndexOf('\n', start)
when {
firstNewLineCharacterIndex == 0 -> {
newLineCharacterIndices.add(-1)
newLineCharacterIndices.add(firstNewLineCharacterIndex)
}
firstNewLineCharacterIndex > 0 -> {
newLineCharacterIndices.add(firstNewLineCharacterIndex)
}
else -> {
newLineCharacterIndices.add(-1)
}
}
var searchFromIndex: Int = if (firstNewLineCharacterIndex == start) start + 1 else start
if (searchFromIndex == end) {
return newLineCharacterIndices
}
do {
val nextNewLineCharacterIndex = text.indexOf('\n', searchFromIndex)
if (nextNewLineCharacterIndex > 0) {
newLineCharacterIndices.add(nextNewLineCharacterIndex)
searchFromIndex = nextNewLineCharacterIndex + 1
} else {
newLineCharacterIndices.add(text.length)
break
}
} while (searchFromIndex in 0..end)
return newLineCharacterIndices
}
private fun fixTabsInSingleLine(text: Editable, start: Int, end: Int) {
var tabsBeforeCurrentAmount = 0
var tabsBeforeCurrentWidthSum = 0
var start = start
val offset = start
while (start <= end) {
val tabIndex = text.indexOf("\t", start)
if (tabIndex < 0 || tabIndex > end) break
val tabWidth = if (tabsBeforeCurrentAmount == 0) {
TAB_SIZE - (tabIndex - offset) % TAB_SIZE
} else {
TAB_SIZE - (tabIndex - offset - tabsBeforeCurrentAmount +
tabsBeforeCurrentWidthSum) % TAB_SIZE
}
tabsBeforeCurrentWidthSum += tabWidth
tabsBeforeCurrentAmount++
text.getSpans(tabIndex, tabIndex + 1, CustomTabWidthSpan::class.java)
.forEach { text.removeSpan(it) }
text.setSpan(
CustomTabWidthSpan((tabWidth * oneLetterWidth).toInt()),
tabIndex,
tabIndex + 1,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
start = tabIndex + 1
}
}
private fun applyTabWidth(text: Editable, start: Int, end: Int) {
if (text.isEmpty()) return
val allNewLineCharacterIndexes = getAllNewLineCharacterIndexes(text, start, end)
for (i in 1 until allNewLineCharacterIndexes.size) {
val start = allNewLineCharacterIndexes[i - 1] + 1
val end = allNewLineCharacterIndexes[i] - 1
fixTabsInSingleLine(text, start, end)
}
}
}
internal class CustomTabWidthSpan(val tabWidth: Int) : ReplacementSpan() {
override fun getSize(
paint: Paint,
text: CharSequence?,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
return tabWidth
}
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
}
}
private const val TAB_SIZE = 4

View file

@ -0,0 +1,143 @@
package com.anytypeio.anytype.core_ui.widgets.text
import android.os.Build
import android.text.SpannableStringBuilder
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.KArgumentCaptor
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.atLeast
import org.mockito.kotlin.atLeastOnce
import org.mockito.kotlin.spy
import org.mockito.kotlin.verify
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@Config(sdk = [Build.VERSION_CODES.P])
@RunWith(RobolectricTestRunner::class)
class MonospaceTabTextWatcherTest {
private val letterWidth = 1
private val watcher = MonospaceTabTextWatcher(letterWidth.toFloat())
@Test
fun `should fill tab indent - when less than TAB_SIZE characters before`() {
(0 until TAB_SIZE).forEach { n ->
val text = "a".repeat(n) + "\t"
val spannable = givenSpannable(text)
val captured = captureSpannableChanges(spannable)
val actualTabWidth = captured.firstValue.tabWidth
val expectedTabWidth = TAB_SIZE - n * letterWidth
assert(actualTabWidth == expectedTabWidth) {
"Error with index $n for expected tab size ${TAB_SIZE - n * letterWidth} " +
"when actual tab size is ${captured.lastValue.tabWidth}"
}
}
}
@Test
fun `should not fill tab indent - when TAB_SIZE characters before`() {
val text = "a".repeat(TAB_SIZE) + "\t"
val spannable = givenSpannable(text)
val captured = captureSpannableChanges(spannable)
assert(captured.firstValue.tabWidth == TAB_SIZE)
}
@Test
fun `should not fill tab indent for second tab - after indented tab before`() {
(0..TAB_SIZE).forEach { n ->
val text = "a".repeat(n) + "\t\t"
val spannable = givenSpannable(text)
val captured = captureSpannableChanges(spannable)
assert(captured.lastValue.tabWidth == TAB_SIZE)
}
}
@Test
fun `should fill second tab indent - when less than TAB_SIZE characters before`() {
(0 until TAB_SIZE).forEach { n ->
val text = "ab\t" + "a".repeat(n) + "\t"
val spannable = givenSpannable(text)
val captured = captureSpannableChanges(spannable)
val actualTabWidth = captured.lastValue.tabWidth
val expectedTabWidth = TAB_SIZE - n * letterWidth
assert(actualTabWidth == expectedTabWidth) {
"Error with index $n for expected tab size ${TAB_SIZE - n * letterWidth} " +
"when actual tab size is ${captured.lastValue.tabWidth}"
}
}
}
@Test
fun `should not fill any indent - when no tabs`() {
listOf("", " ", "\n", "\n\n", "\n \n").forEach { text ->
val spannable = givenSpannable(text)
val captured = captureSpannableChanges(spannable)
assert(captured.allValues.isEmpty())
}
}
@Test
fun `should fill indent properly - when multiple lines`() {
val text = "\nabc\t\nd\t\n"
val spannable = givenSpannable(text)
val captured = captureSpannableChanges(spannable)
assert(captured.firstValue.tabWidth == 1)
assert(captured.lastValue.tabWidth == 3)
}
@Test
fun `should fill indent only in substring - when range provided`() {
val text = "\nabc\t\nd\t\n\t"
val start = text.indexOf('a')
val end = text.indexOf('d')
val spannable = givenSpannable(text, start, end - start + 1)
val captured = captureSpannableChanges(spannable)
assert(captured.allValues.size == 2)
assert(captured.firstValue.tabWidth == 1)
assert(captured.lastValue.tabWidth == 3)
}
@Test
fun `should fill indent only in last tab - when range provided`() {
val text = "\nabc\t\nd\t\n\t"
val spannable = givenSpannable(text, text.length - 1, 1)
val captured = captureSpannableChanges(spannable)
assert(captured.allValues.size == 1)
assert(captured.firstValue.tabWidth == 4)
}
@Test
fun `should fill indent only in first tab - when range provided`() {
val text = "\t\nabc\t\nd\t\n\t"
val spannable = givenSpannable(text, 0, 1)
val captured = captureSpannableChanges(spannable)
assert(captured.allValues.size == 1)
assert(captured.firstValue.tabWidth == 4)
}
private fun captureSpannableChanges(spannable: SpannableStringBuilder): KArgumentCaptor<CustomTabWidthSpan> {
val captor = argumentCaptor<CustomTabWidthSpan>()
verify(spannable, atLeast(0)).setSpan(captor.capture(), any(), any(), any())
return captor
}
private fun givenSpannable(
text: String,
start: Int = 0,
count: Int = text.length
): SpannableStringBuilder {
val spannable = spy(SpannableStringBuilder(text))
watcher.onTextChanged(spannable, start, 0, count)
watcher.afterTextChanged(spannable)
return spannable
}
}
private const val TAB_SIZE = 4