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:
parent
b81acb1313
commit
04f194f1f8
3 changed files with 300 additions and 5 deletions
|
@ -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 = " "
|
|
@ -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
|
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue