![](https://habrastorage.org/webt/wa/40/up/wa40upkppe3jl3-ncpi6dxvvrok.png)
Вот и подошло время для публикации второй части, сегодня мы продолжим разрабатывать наш редактор кода и добавим в него автодополнение и подсветку ошибок, а также поговорим почему любой редактор кода на
EditText
будет лагать.Перед дальнейшим прочтением настоятельно рекомендую ознакомиться с первой частью.
Вступление
![](https://habrastorage.org/webt/k7/jb/80/k7jb80b1ecbhvyvfzihzwa3ixkq.jpeg)
В этой части мы добавим автодополнение кода и подсветку ошибок.
Автодополнение кода
Для начала представим как это должно работать:
- Пользователь пишет слово
- После ввода N первых символов появляется окошко с подсказками
- При нажатии на подсказку слово автоматически «допечатывается»
- Окошко с подсказками закрывается, и курсор переносится в конец слова
- Если пользователь ввел слово отображаемое в подсказке сам, то окошко с подсказками должно автоматически закрыться
Ничего не напоминает? В андройде уже есть компонент с точно такой же логикой —
MultiAutoCompleteTextView
, поэтому писать костыли с PopupWindow
нам не придется (их уже написали за нас). Первым шагом поменяем родителя у нашего класса:
class TextProcessor @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.autoCompleteTextViewStyle
) : MultiAutoCompleteTextView(context, attrs, defStyleAttr)
Теперь нам нужно написать
ArrayAdapter
который будет отображать найденные результаты. Полного кода адаптера не будет, примеры реализации можно найти в интернете. Но на моменте с фильтрацией я всё таки остановлюсь.Чтобы
ArrayAdapter
мог понимать какие подсказки нужно отобразить, нам нужно переопределить метод getFilter
:override fun getFilter(): Filter {
return object : Filter() {
private val suggestions = mutableListOf<String>()
override fun performFiltering(constraint: CharSequence?): FilterResults {
// ...
}
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
clear() // необходимо очистить старый список
addAll(suggestions)
notifyDataSetChanged()
}
}
}
И в методе
performFiltering
наполнить список suggestions
из слов, основываясь на слове которое начал вводить пользователь (содержится в переменной constraint
).Откуда взять данные перед фильтрацией?
Тут всё зависит от вас — можно использовать какой-нибудь интерпретатор для подбора только валидных вариантов, либо сканировать весь текст при открытии файла. Для простоты примера я буду использовать уже готовый список вариантов автодополнения:
private val staticSuggestions = mutableListOf(
"function",
"return",
"var",
"const",
"let",
"null"
...
)
...
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filterResults = FilterResults()
val input = constraint.toString()
suggestions.clear() // очищаем старый список
for (suggestion in staticSuggestions) {
if (suggestion.startsWith(input, ignoreCase = true) &&
!suggestion.equals(input, ignoreCase = true)) {
suggestions.add(suggestion)
}
}
filterResults.values = suggestions
filterResults.count = suggestions.size
return filterResults
}
Логика фильтрации тут довольно примитивная, проходимся по всему списку и игнорируя регистр сравниваем начало строки.
Установили адаптер, пишем текст — не работает. Что не так? По первой ссылке в гугле натыкаемся на ответ, в котором говорится что мы забыли установить
Tokenizer
.Для чего нужен Tokenizer?
Говоря простым языком,
Tokenizer
помогает MultiAutoCompleteTextView
понять, после какого введенного символа можно считать ввод слова завершенным. Также у него есть готовая реализация в виде CommaTokenizer
с разделением слов на запятые, что в данном случае нам не подходит.Что ж, раз
CommaTokenizer
нас не устраивает, тогда напишем свой:
Кастомный Tokenizer
class SymbolsTokenizer : MultiAutoCompleteTextView.Tokenizer {
companion object {
private const val TOKEN = "!@#$%^&*()_+-={}|[]:;'<>/<.? \r\n\t"
}
override fun findTokenStart(text: CharSequence, cursor: Int): Int {
var i = cursor
while (i > 0 && !TOKEN.contains(text[i - 1])) {
i--
}
while (i < cursor && text[i] == ' ') {
i++
}
return i
}
override fun findTokenEnd(text: CharSequence, cursor: Int): Int {
var i = cursor
while (i < text.length) {
if (TOKEN.contains(text[i - 1])) {
return i
} else {
i++
}
}
return text.length
}
override fun terminateToken(text: CharSequence): CharSequence = text
}
Разбираемся:
TOKEN
— строка с символами которые отделяют одно слово от другого. В методах findTokenStart
и findTokenEnd
мы проходимся по тексту в поисках этих самых отделяющих символов. Метод terminateToken
позволяет вернуть измененный результат, но нам он не нужен, поэтому просто возвращаем текст без изменений.![](https://habrastorage.org/webt/5a/jm/ds/5ajmds7nuntnzejvhl62fjjldpg.jpeg)
Ещё я предпочитаю добавлять задержку на ввод в 2 символа перед отображением списка:
textProcessor.threshold = 2
Устанавливаем, запускаем, пишем текст — работает! Вот только почему-то окошко с подсказками странно себя ведет — отображается во всю ширину, высота у него маленькая, да и по идее оно ведь должно появляться под курсором, как будем фиксить?
Исправляем визуальные недостатки
Вот тут и начинается самое интересное, ведь API позволяет нам изменять не только размеры окна, но и его положение.
Для начала определимся с размерами. На мой взгляд, наиболее удобным вариантом будет окошко размером с половину от высоты и ширины экрана, но т.к размер нашей
View
изменяется в зависимости от состояния клавиатуры, подбирать размеры будем в методе onSizeChanged
:override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
updateSyntaxHighlighting()
dropDownWidth = w * 1 / 2
dropDownHeight = h * 1 / 2
}
![](https://habrastorage.org/webt/8r/cy/xd/8rcyxdh5sunmxjrtmntjb_90oam.png)
Если с перемещением по X всё довольно просто — берем координату начала буквы и устанавливаем это значение в
dropDownHorizontalOffset
, то с подбором высоты будет сложнее.Гугля про свойства шрифтов можно наткнуться на вот такой пост. Картинка которую прикрепил автор наглядно показывает, какими свойствами мы можем воспользоваться для вычисления вертикальной координаты.
![](https://habrastorage.org/webt/br/zj/ep/brzjepbqxlsdbtcrrh-z58hkc7q.png)
Теперь напишем метод, который будем вызывать при изменении текста в
onTextChanged
:private fun onPopupChangePosition() {
val line = layout.getLineForOffset(selectionStart) // строка с курсором
val x = layout.getPrimaryHorizontal(selectionStart) // координата курсора
val y = layout.getLineBaseline(line) // тот самый baseline
val offsetHorizontal = x + gutterWidth // нумерация строк тоже часть отступа
dropDownHorizontalOffset = offsetHorizontal.toInt()
val offsetVertical = y - scrollY // -scrollY чтобы не "заезжать" за экран
dropDownVerticalOffset = offsetVertical
}
Вроде ничего не забыли — смещение по X работает, но смещение по Y рассчитывается неправильно. Это потому что мы не указали
dropDownAnchor
в разметке:android:dropDownAnchor="@id/toolbar"
Указав
Toolbar
в качестве dropDownAnchor
мы даём виджету понять, что выпадающий список будет отображаться под ним.Теперь если мы начнем редактировать текст то всё будет работать, но со временем мы заметим — если окошко не помещается под курсором, оно переносится вверх с огромным отступом, что выглядит некрасиво. Самое время написать костыль:
![](https://habrastorage.org/webt/oc/0p/kn/oc0pkntbfin2nlh284dfzjbyzzo.gif)
val offset = offsetVertical + dropDownHeight
if (offset < getVisibleHeight()) {
dropDownVerticalOffset = offsetVertical
} else {
dropDownVerticalOffset = offsetVertical - dropDownHeight
}
...
private fun getVisibleHeight(): Int {
val rect = Rect()
getWindowVisibleDisplayFrame(rect)
return rect.bottom - rect.top
}
Нам не нужно изменять отступ если сумма
offsetVertical + dropDownHeight
меньше видимой высоты экрана, ведь в таком случае окошко помещается под курсором. Но если всё таки больше, то вычитаем из отступа dropDownHeight
— так оно поместится над курсором без огромного отступа который добавляет сам виджет.P.S На гифке можно заметить промаргивания клавиатуры, и честно говоря я не знаю как это исправить, поэтому если у вас есть решение — пишите.
Подсветка ошибок
С подсветкой ошибок всё гораздо проще чем кажется, т.к сами мы напрямую не можем определять синтаксические ошибки в коде — будем использовать стороннюю библиотеку-парсер. Т.к я пишу редактор для JavaScript, мой выбор пал на Rhino — популярный JavaScript-движок который проверен временем и всё ещё поддерживается.
Как парсить будем?
Запуск Rhino довольно тяжелая операция, поэтому запускать парсер после каждого введенного символа (как мы делали с подсветкой) вообще не вариант. Для решения этой проблемы я буду использовать библиотеку RxBinding, а для тех кто не хочет тащить в проект RxJava можно попробовать подобные варианты.
Оператор
debounce
поможет нам добиться желаемого, а если вы с ним не знакомы то советую почитать вот эту статью.textProcessor.textChangeEvents()
.skipInitialValue()
.debounce(1500, TimeUnit.MILLISECONDS)
.filter { it.text.isNotEmpty() }
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
// Запуск парсера будет тут
}
.disposeOnFragmentDestroyView()
Теперь напишем модель которую нам будет возвращать парсер:
data class ParseResult(val exception: RhinoException?)
Предлагаю использовать такую логику: если ошибок не найдено, то
exception
будет null
. В противном случае мы получим объект RhinoException
который содержит в себе всю необходимую информацию — номер строки, сообщение об ошибке, StackTrace и т.д.Ну и собственно, сам парсинг:
// Это должно выполняться в фоне !
val context = Context.enter() // org.mozilla.javascript.Context
context.optimizationLevel = -1
context.maximumInterpreterStackDepth = 1
try {
val scope = context.initStandardObjects()
context.evaluateString(scope, sourceCode, fileName, 1, null)
return ParseResult(null)
} catch (e: RhinoException) {
return ParseResult(e)
} finally {
Context.exit()
}
Разбираемся:
Самое главное тут это метод
evaluateString
— он позволяет запустить код, который мы передали в качестве строки sourceCode
. В fileName
указывается имя файла — оно будет отображаться в ошибках, единица — номер строки для начала отсчета, последний аргумент это security domain, но он нам не нужен, поэтому ставим null
.optimizationLevel и maximumInterpreterStackDepth
Параметр
optimizationLevel
со значением от 1 до 9 позволяет включить определенные «оптимизации» кода (data flow analysis, type flow analysis и т.д), что превратит простую проверку синтаксических ошибок в очень длительную операцию, а нам это не к чему.Если же использовать его со значением 0, то все эти «оптимизации» применяться не будут, однако, если я правильно понял, Rhino по-прежнему будет использовать часть ресурсов не нужных для простой проверки ошибок, а значит нам это не подходит.
Остаётся только отрицательное значение — указав -1 мы активируем режим «интерпретатора», а это именно то что нам нужно. В документации сказано что это самый быстрый и экономичный вариант работы Rhino.
Параметр
maximumInterpreterStackDepth
позволяет ограничить количество рекурсивных вызовов. Представим что будет если не указать этот параметр:
- Пользователь напишет следующий код:
function recurse() { recurse(); } recurse();
- Rhino запустит код, и через секунду наше приложение вылетит с
OutOfMemoryError
. Конец.
Отображение ошибок
Как я говорил ранее, как только мы получим
ParseResult
содержащий RhinoException
, у нас появится весь необходимый набор данных для отображения, в том числе и номер строки — нужно лишь вызвать метод lineNumber()
.Теперь напишем спан с красной волнистой линией, который я скопировал на StackOverflow. Кода много, но логика простая — рисуем две короткие красные линии под разным углом.
ErrorSpan.kt
class ErrorSpan(
private val lineWidth: Float = 1 * Resources.getSystem().displayMetrics.density + 0.5f,
private val waveSize: Float = 3 * Resources.getSystem().displayMetrics.density + 0.5f,
private val color: Int = Color.RED
) : LineBackgroundSpan {
override fun drawBackground(
canvas: Canvas,
paint: Paint,
left: Int,
right: Int,
top: Int,
baseline: Int,
bottom: Int,
text: CharSequence,
start: Int,
end: Int,
lineNumber: Int
) {
val width = paint.measureText(text, start, end)
val linePaint = Paint(paint)
linePaint.color = color
linePaint.strokeWidth = lineWidth
val doubleWaveSize = waveSize * 2
var i = left.toFloat()
while (i < left + width) {
canvas.drawLine(i, bottom.toFloat(), i + waveSize, bottom - waveSize, linePaint)
canvas.drawLine(i + waveSize, bottom - waveSize, i + doubleWaveSize, bottom.toFloat(), linePaint)
i += doubleWaveSize
}
}
}
Теперь можно написать метод установки спана на проблемную строку:
fun setErrorLine(lineNumber: Int) {
if (lineNumber in 0 until lineCount) {
val lineStart = layout.getLineStart(lineNumber)
val lineEnd = layout.getLineEnd(lineNumber)
text.setSpan(ErrorSpan(), lineStart, lineEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
Важно помнить, что т.к результат приходит с задержкой, пользователь может успеть стереть пару строк кода, и тогда
lineNumber
может оказаться невалидным.![](https://habrastorage.org/webt/dt/ca/u_/dtcau_aadqxl5tbptmvzj3woqx4.png)
IndexOutOfBoundsException
мы добавляем проверку в самом начале. Ну а дальше по знакомой схеме вычисляем первый и последний символ строки, после чего устанавливаем спан.Главное не забыть очистить текст от уже установленных спанов в
afterTextChanged
:fun clearErrorSpans() {
val spans = text.getSpans<ErrorSpan>(0, text.length)
for (span in spans) {
text.removeSpan(span)
}
}
Почему редакторы кода лагают?
За две статьи мы написали неплохой редактор кода наследуясь от
EditText
и MultiAutoCompleteTextView
, но производительностью при работе с большими файлами похвастаться не можем. Если открыть тот же TextView.java на 9к+ строк кода то любой текстовый редактор написанный по такому же принципу как наш будет лагать.
Q: А почему QuickEdit тогда не лагает?
A: Потому что под капотом он не использует ни
EditText
, ни TextView
.В последнее время набирают популярность редакторы кода на CustomView (вот и вот, ну или вот и вот, их очень много). Исторически так сложилось, что TextView имеет слишком много лишней логики, которая не нужна редакторам кода. Первое что приходит на ум — Autofill, Emoji, Compound Drawables, кликабельные ссылки и т.д.
Если я правильно понял, авторы библиотек просто избавились от всего этого, в следствие чего получили текстовый редактор способный работать с файлами в миллион строк без особой нагрузки на UI Thread. (Хотя частично могу ошибаться, в исходниках не сильно разобрался)
Есть ещё один вариант, но на мой взгляд менее привлекательный — редакторы кода на WebView (вот и вот, их тоже очень много). Мне они не нравятся потому что UI на WebView выглядит хуже чем нативный, да и редакторам на CustomView они так же проигрывают по производительности.
Заключение
Если ваша задача написать редактор кода и выйти в топ Google Play — не тратьте время и возьмите готовую библиотеку на CustomView. Если же вы хотите получить уникальный опыт — пишите всё сами, используя нативные виджеты.
Также оставлю ссылку на исходники моего редактора кода на GitHub, там вы найдёте не только те фичи о которых я рассказал за эти две статьи, но и много других которые остались без внимания.
Спасибо!