Antes de terminar el trabajo en mi editor de código, pisé un rastrillo muchas veces, probablemente descompuse docenas de aplicaciones similares, y en esta serie de artículos hablaré sobre lo que aprendí, qué errores se pueden evitar y muchas otras cosas interesantes.
Introducción
¡Hola a todos! A juzgar por el nombre, está bastante claro lo que se discutirá, pero aún así tengo que insertar algunas palabras propias antes de pasar al código.
Decidí dividir el artículo en 2 partes, en la primera escribiremos paso a paso el resaltado de sintaxis optimizado y la numeración de líneas, y en la segunda agregaremos la finalización del código y el resaltado de errores.
Primero, hagamos una lista de lo que nuestro editor debería poder:
- Resaltar sintaxis
- Mostrar numeración de línea
- Mostrar opciones de autocompletado (te lo diré en la segunda parte)
- Resaltar errores de sintaxis (lo diré en la segunda parte)
Esta no es la lista completa de las propiedades que debe tener un editor de código moderno, pero esto es exactamente de lo que quiero hablar en esta pequeña serie de artículos.
MVP - Editor de texto simple
En esta etapa, no debería haber ningún problema: estírese
EditText
a la pantalla completa, indique gravity
transparente background
para eliminar la tira de la parte inferior, tamaño de fuente, color de texto, etc. Me gusta comenzar con la parte visual, por lo que me resulta más fácil entender lo que falta en la aplicación y en qué detalles todavía tengo que trabajar.
En esta etapa, también cargué / guardé archivos en la memoria. No daré el código, hay una gran cantidad de ejemplos de trabajo con archivos en Internet.
Resaltado de sintaxis
Tan pronto como leamos los requisitos para el editor, es hora de pasar a lo más interesante.
Obviamente, para controlar todo el proceso: para responder a la entrada, dibujar números de línea, tendremos que escribir
CustomView
heredando de EditText
. Nos lanzamos TextWatcher
para escuchar los cambios en el texto y redefinimos el método afterTextChanged
en el que llamaremos al método responsable de resaltar:
class TextProcessor @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.editTextStyle
) : EditText(context, attrs, defStyleAttr) {
private val textWatcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
syntaxHighlight()
}
}
private fun syntaxHighlight() {
//
}
}
P: ¿Por qué lo usamos
TextWatcher
como variable, porque puede implementar la interfaz directamente en la clase?
R: Dio la casualidad de que
TextWatcher
hay un método que está en conflicto con un método ya existente en TextView
:
// TextWatcher
fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int)
// TextView
fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int)
Ambos métodos tienen el mismo nombre y los mismos argumentos, y parecen tener el mismo significado, pero el problema es que el método
onTextChanged
y se TextView
llamará junto con onTextChanged
y TextWatcher
. Si colocamos los registros en el cuerpo del método, veremos lo que se onTextChanged
llama dos veces:
Esto es muy crítico si planeamos agregar la funcionalidad Deshacer / Rehacer. Además, es posible que necesitemos un momento en el que los oyentes no funcionen, en el que podamos borrar la pila con cambios de texto. No queremos poder presionar Deshacer después de abrir un nuevo archivo y obtener un texto completamente diferente. Aunque Deshacer / Rehacer no se discutirá en este artículo, es importante tener en cuenta este punto.
En consecuencia, para evitar tal situación, puede usar su propio método para configurar el texto en lugar del estándar
setText
:
fun processText(newText: String) {
removeTextChangedListener(textWatcher)
// undoStack.clear()
// redoStack.clear()
setText(newText)
addTextChangedListener(textWatcher)
}
Pero volvamos a lo más destacado.
Muchos lenguajes de programación tienen algo tan maravilloso como RegEx , una herramienta que le permite buscar coincidencias de texto en una cadena. Le recomiendo que al menos se familiarice con sus capacidades básicas, porque tarde o temprano cualquier programador puede necesitar "extraer" alguna información del texto.
Ahora es importante que sepamos solo dos cosas:
- El patrón determina exactamente qué necesitamos encontrar en el texto
- Matcher recorrerá el texto tratando de encontrar lo que especificamos en Pattern
Tal vez no lo describió correctamente, pero así es como funciona.
Como estoy escribiendo un editor para JavaScript, aquí hay un pequeño patrón con palabras clave de lenguaje:
private val KEYWORDS = Pattern.compile(
"\\b(function|var|this|if|else|break|case|try|catch|while|return|switch)\\b"
)
Por supuesto, debería haber muchas más palabras aquí, y también necesitamos patrones para comentarios, líneas, números, etc. pero mi tarea es demostrar el principio por el cual puedes encontrar el contenido deseado en el texto.
A continuación, con la ayuda de Matcher, revisaremos todo el texto y estableceremos los tramos:
private fun syntaxHighlight() {
val matcher = KEYWORDS.matcher(text)
matcher.region(0, text.length)
while (matcher.find()) {
text.setSpan(
ForegroundColorSpan(Color.parseColor("#7F0055")),
matcher.start(),
matcher.end(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
Me explico: tenemos la Matcher objeto de la Patrón , e indicar a que el área a buscar en símbolos (De acuerdo con ello, de 0 a
text.length
esto es el texto completo). A continuación, la llamada matcher.find()
volverá true
si un partido se encuentra en el texto, y con la ayuda de la llamada matcher.start()
y matcher.end()
vamos a obtener la posición de inicio y al final del partido en el texto. Conociendo estos datos, podemos usar el método setSpan
para colorear ciertas áreas del texto.
Hay muchos tipos de tramos, pero generalmente se usa para volver a pintar el texto
ForegroundColorSpan
.
¡Entonces comencemos!
El resultado corresponde exactamente a las expectativas hasta que comenzamos a editar un archivo grande (en la captura de pantalla, un archivo de ~ 1000 líneas).
El hecho es que el método
setSpan
funciona lentamente, cargando el hilo de la interfaz de usuario, y considerando que el método afterTextChanged
se llama después de cada carácter ingresado, se convierte en Un tormento.
Encontrar una solución
Lo primero que viene a la mente es mover una operación pesada a un hilo de fondo. Pero la operación pesada aquí está en
setSpan
todo el texto, no en la temporada regular. (Creo que no necesito explicar por qué es imposible llamar setSpan
desde un hilo de fondo).
Después de un poco de búsqueda de artículos, descubrimos que si queremos lograr suavidad, solo tenemos que resaltar la parte visible del texto.
¡Correcto! ¡Vamos a hacerlo! ¿Así cómo?
Mejoramiento
Aunque mencioné que solo nos importa el rendimiento del método
setSpan
, todavía recomiendo poner el trabajo de RegEx en un hilo de fondo para lograr la máxima suavidad.
Necesitamos una clase que procese todo el texto en segundo plano y devuelva una lista de tramos.
No daré una implementación específica, pero si alguien está interesado, entonces uso una que
AsyncTask
funcione ThreadPoolExecutor
. (Sí, AsyncTask en 2020) Lo
principal para nosotros es que se ejecuta esta lógica:
- La tarea
beforeTextChanged
Stop que analiza el texto - En comenzamos
afterTextChanged
la Tarea que analiza el texto - Al final de su trabajo, la tarea debe devolver la lista de tramos
TextProcessor
, que, a su vez, resaltará solo la parte visible
Y sí, los tramos también escribirán los suyos:
data class SyntaxHighlightSpan(
private val color: Int,
val start: Int,
val end: Int
) : CharacterStyle() {
// italic, ,
override fun updateDrawState(textPaint: TextPaint?) {
textPaint?.color = color
}
}
Por lo tanto, el código del editor se convierte en algo como esto:
Mucho código
class TextProcessor @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.editTextStyle
) : EditText(context, attrs, defStyleAttr) {
private val textWatcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
cancelSyntaxHighlighting()
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
syntaxHighlight()
}
}
private var syntaxHighlightSpans: List<SyntaxHighlightSpan> = emptyList()
private var javaScriptStyler: JavaScriptStyler? = null
fun processText(newText: String) {
removeTextChangedListener(textWatcher)
// undoStack.clear()
// redoStack.clear()
setText(newText)
addTextChangedListener(textWatcher)
// syntaxHighlight()
}
private fun syntaxHighlight() {
javaScriptStyler = JavaScriptStyler()
javaScriptStyler?.setSpansCallback { spans ->
syntaxHighlightSpans = spans
updateSyntaxHighlighting()
}
javaScriptStyler?.runTask(text.toString())
}
private fun cancelSyntaxHighlighting() {
javaScriptStyler?.cancelTask()
}
private fun updateSyntaxHighlighting() {
//
}
}
Como no mostré una implementación específica de procesamiento en segundo plano, imagine que escribimos una cierta
JavaScriptStyler
que en el fondo hará lo mismo que hicimos antes en el hilo de la interfaz de usuario: ejecute todo el texto en busca de coincidencias y complete la lista de tramos, y al final de su trabajo devolverá el resultado a setSpansCallback
. En este momento, se lanzará un método updateSyntaxHighlighting
que recorrerá la lista de tramos y mostrará solo los que están actualmente visibles en la pantalla.
¿Cómo entender qué texto cae en el área visible?
Me referiré a este artículo , donde el autor sugiere usar algo como esto:
val topVisibleLine = scrollY / lineHeight
val bottomVisibleLine = topVisibleLine + height / lineHeight + 1 // height - View
val lineStart = layout.getLineStart(topVisibleLine)
val lineEnd = layout.getLineEnd(bottomVisibleLine)
¡Y funciona! Ahora vamos a poner
topVisibleLine
que bottomVisibleLine
en métodos separados y añadir un par de comprobaciones adicionales en caso de que algo va mal:
Nuevos métodos
private fun getTopVisibleLine(): Int {
if (lineHeight == 0) {
return 0
}
val line = scrollY / lineHeight
if (line < 0) {
return 0
}
return if (line >= lineCount) {
lineCount - 1
} else line
}
private fun getBottomVisibleLine(): Int {
if (lineHeight == 0) {
return 0
}
val line = getTopVisibleLine() + height / lineHeight + 1
if (line < 0) {
return 0
}
return if (line >= lineCount) {
lineCount - 1
} else line
}
Lo último que debe hacerse es revisar la lista resultante de tramos y colorear el texto:
for (span in syntaxHighlightSpans) {
val isInText = span.start >= 0 && span.end <= text.length
val isValid = span.start <= span.end
val isVisible = span.start in lineStart..lineEnd
|| span.start <= lineEnd && span.end >= lineStart
if (isInText && isValid && isVisible)) {
text.setSpan(
span,
if (span.start < lineStart) lineStart else span.start,
if (span.end > lineEnd) lineEnd else span.end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
No se alarme por el miedo
if
'pero, solo verifica si el lapso de la lista cae en el área visible.
Bueno, ¿funciona?
Funciona, pero al editar el texto, los tramos no se actualizan, puede solucionar la situación borrando el texto de todos los tramos antes de superponer otros nuevos:
// : getSpans core-ktx
val textSpans = text.getSpans<SyntaxHighlightSpan>(0, text.length)
for (span in textSpans) {
text.removeSpan(span)
}
Otra jamba: después de cerrar el teclado, un fragmento de texto permanece apagado, corríjalo:
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
updateSyntaxHighlighting()
}
Lo principal es no olvidar indicar
adjustResize
en el manifiesto.
Desplazamiento
Hablando de desplazamiento, me referiré a este artículo nuevamente . El autor sugiere esperar 500 ms después del final del desplazamiento, lo que contradice mi sentido de la belleza. No quiero esperar a que se cargue la luz de fondo, quiero ver el resultado al instante.
El autor también argumenta que iniciar el analizador después de cada píxel "desplazado" es costoso, y estoy completamente de acuerdo con esto (generalmente recomiendo que lea su artículo por completo, es pequeño, pero hay muchas cosas interesantes). Pero el hecho es que ya tenemos una lista de tramos listos y no necesitamos lanzar el analizador.
Es suficiente llamar al método responsable de actualizar el resaltado:
override fun onScrollChanged(horiz: Int, vert: Int, oldHoriz: Int, oldVert: Int) {
super.onScrollChanged(horiz, vert, oldHoriz, oldVert)
updateSyntaxHighlighting()
}
Numeración de linea
Si agregamos otro al marcado,
TextView
será problemático vincularlos (por ejemplo, para actualizar sincrónicamente el tamaño del texto), y si tenemos un archivo grande, tendremos que actualizar completamente el texto con números después de cada letra ingresada, lo cual no es muy bueno. Por lo tanto, vamos a utilizar cualquier medio estándar CustomView
- sobre la base Canvas
de onDraw
, es rápido y no es difícil.
Primero, determinemos qué dibujaremos:
- Línea de números
- La línea vertical que separa el campo de entrada de los números de línea.
Primero debe calcular y establecer a la
padding
izquierda del editor para que no haya conflictos con el texto impreso.
Para hacer esto, escribimos una función que actualizará la sangría antes de renderizar:
Actualización de sangría
private var gutterWidth = 0
private var gutterDigitCount = 0
private var gutterMargin = 4.dpToPx() //
...
private fun updateGutter() {
var count = 3
var widestNumber = 0
var widestWidth = 0f
gutterDigitCount = lineCount.toString().length
for (i in 0..9) {
val width = paint.measureText(i.toString())
if (width > widestWidth) {
widestNumber = i
widestWidth = width
}
}
if (gutterDigitCount >= count) {
count = gutterDigitCount
}
val builder = StringBuilder()
for (i in 0 until count) {
builder.append(widestNumber.toString())
}
gutterWidth = paint.measureText(builder.toString()).toInt()
gutterWidth += gutterMargin
if (paddingLeft != gutterWidth + gutterMargin) {
setPadding(gutterWidth + gutterMargin, gutterMargin, paddingRight, 0)
}
}
Explicación:
Primero, descubrimos el número de líneas en
EditText
(que no debe confundirse con el número de " \n
" en el texto) y tomamos el número de caracteres de este número. Por ejemplo, si tenemos 100 líneas, entonces la variable gutterDigitCount
será igual a 3, porque hay exactamente 3 caracteres en 100. Pero digamos que solo tenemos 1 línea, lo que significa que una sangría de 1 carácter aparecerá visualmente pequeña, y para esto usamos la variable de conteo para establecer la sangría mínima visualizada de 3 caracteres, incluso si tenemos menos de 100 líneas de código.
Esta parte fue la más confusa de todas, pero si lo leyó cuidadosamente varias veces (mirando el código), entonces todo quedará claro.
A continuación, establezca la sangría precomputando
widestNumber
y widestWidth
.
Empecemos a dibujar
Desafortunadamente, si queremos usar el ajuste de texto estándar de Androyd en una nueva línea, tendremos que conjurarlo, lo que nos llevará mucho tiempo e incluso más código, que será suficiente para todo el artículo, por lo que para reducir su tiempo (y el tiempo del moderador del centro), habilitaremos horizontal desplazamiento para que todas las líneas vayan una tras otra:
setHorizontallyScrolling(true)
Bueno, ahora puedes comenzar a dibujar, declarar variables con tipo
Paint
:
private val gutterTextPaint = Paint() //
private val gutterDividerPaint = Paint() //
init
Establezca el color del texto y el color del separador
en algún lugar del bloque. Es importante recordar que si cambia la fuente del texto, entonces la fuente Paint
deberá aplicarse manualmente, para esto le aconsejo que anule el método setTypeface
. Del mismo modo con el tamaño del texto.
Luego redefina el método
onDraw
:
override fun onDraw(canvas: Canvas?) {
updateGutter()
super.onDraw(canvas)
var topVisibleLine = getTopVisibleLine()
val bottomVisibleLine = getBottomVisibleLine()
val textRight = (gutterWidth - gutterMargin / 2) + scrollX
while (topVisibleLine <= bottomVisibleLine) {
canvas?.drawText(
(topVisibleLine + 1).toString(),
textRight.toFloat(),
(layout.getLineBaseline(topVisibleLine) + paddingTop).toFloat(),
gutterTextPaint
)
topVisibleLine++
}
canvas?.drawLine(
(gutterWidth + scrollX).toFloat(),
scrollY.toFloat(),
(gutterWidth + scrollX).toFloat(),
(scrollY + height).toFloat(),
gutterDividerPaint
)
}
Nos fijamos en el resultado
Se ve genial.
¿En qué hemos hecho
onDraw
? Antes de llamar al super
método, actualizamos la sangría, después de lo cual dibujamos los números solo en el área visible, y al final dibujamos una línea vertical que separa visualmente la numeración de la línea del editor de código.
Por belleza, también puede volver a pintar la sangría en un color diferente, resaltar visualmente la línea en la que se encuentra el cursor, pero lo dejaré a su discreción.
Conclusión
En este artículo, escribimos un editor de código receptivo con resaltado de sintaxis y numeración de líneas, y en la siguiente parte agregaremos la finalización de código conveniente y el resaltado de sintaxis justo durante la edición.
También dejaré un enlace al código fuente de mi editor de código GitHub , allí encontrará no solo las características de las que hablé en este artículo, sino también muchas otras que han sido ignoradas.
UPD: La segunda parte ya está fuera.
Haga preguntas y sugiera temas para el debate, porque bien podría haber pasado algo por alto.
¡Gracias!