Editor Kode Android: Bagian 1



Sebelum menyelesaikan pekerjaan pada editor kode saya, saya menginjak menyapu berkali-kali, mungkin mendekompilasi puluhan aplikasi serupa, dan dalam seri artikel ini saya akan berbicara tentang apa yang saya pelajari, kesalahan apa yang dapat dihindari dan banyak hal menarik lainnya.



pengantar



Halo semua! Dilihat dari judulnya, cukup jelas tentang apa itu, tetapi saya masih harus memasukkan beberapa kata saya sendiri sebelum beralih ke kode.



Saya memutuskan untuk membagi artikel menjadi 2 bagian, yang pertama kita akan secara bertahap menulis highlighting sintaks yang dioptimalkan dan penomoran baris, dan yang kedua kita akan menambahkan penyelesaian kode dan kesalahan menyoroti.



Untuk memulainya, kami akan membuat daftar apa yang harus dapat dilakukan oleh editor kami:



  • Penyorotan sintaksis
  • Tampilkan penomoran baris
  • Tampilkan opsi pelengkapan otomatis (saya akan memberi tahu Anda di bagian kedua)
  • Sorot kesalahan sintaks (saya akan memberi tahu Anda di bagian kedua)


Ini bukan seluruh daftar properti apa yang seharusnya dimiliki oleh editor kode modern, tetapi inilah yang ingin saya bicarakan dalam serangkaian artikel kecil ini.



MVP - Editor Teks Sederhana



Pada tahap ini, seharusnya tidak ada masalah - kami merentangkannya EditTextke layar penuh, menunjukkan gravitytransparan backgrounduntuk menghapus bilah dari bawah, ukuran font, warna teks, dll. Saya suka memulai dengan bagian visual, sehingga menjadi lebih mudah bagi saya untuk memahami apa yang hilang dalam aplikasi, dan detail apa yang masih perlu dikerjakan.



Pada tahap ini, saya juga melakukan memuat / menyimpan file ke dalam memori. Saya tidak akan memberikan kode, ada banyak contoh bekerja dengan file di Internet.



Penyorotan sintaksis



Segera setelah kami membaca persyaratan untuk editor, saatnya beralih ke yang paling menarik.



Jelas, untuk mengendalikan seluruh proses - untuk menanggapi input, menggambar nomor garis, kita harus menulis CustomViewdari mana mewarisi EditText. Kami melempar TextWatcheruntuk mendengarkan perubahan dalam teks dan mendefinisikan kembali metode afterTextChangeddi mana kami akan memanggil metode yang bertanggung jawab untuk menyoroti:



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() {
        //    
    }
}


T: Mengapa kita menggunakan TextWatchervariabel, karena Anda dapat mengimplementasikan antarmuka secara langsung di kelas?

A: Kebetulan kami TextWatchermemiliki metode yang bertentangan dengan metode yang ada di TextView:



//  TextWatcher
fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int)

//  TextView
fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int)


Kedua metode ini memiliki nama yang sama dan argumen yang sama, dan artinya tampaknya sama, tetapi masalahnya adalah bahwa metode onTextChangedy akan TextViewdipanggil bersama dengan onTextChangedy TextWatcher. Jika kita meletakkan log di tubuh metode, kita akan melihat apa yang onTextChangeddisebut dua kali:





Ini sangat penting jika kami berencana untuk menambahkan fungsi Undo / Redo. Juga, kita mungkin memerlukan saat di mana pendengar tidak akan berfungsi, di mana kita dapat menghapus tumpukan dengan perubahan teks. Kami tidak ingin dapat menekan Batalkan setelah membuka file baru dan mendapatkan teks yang sama sekali berbeda. Meskipun artikel ini tidak akan berbicara tentang Undo / Redo, penting untuk mempertimbangkan hal ini.



Oleh karena itu, untuk menghindari situasi seperti itu, Anda dapat menggunakan metode instalasi teks Anda sendiri daripada yang standar setText:



fun processText(newText: String) {
    removeTextChangedListener(textWatcher)
    // undoStack.clear()
    // redoStack.clear()
    setText(newText)
    addTextChangedListener(textWatcher)
}


Tapi kembali ke lampu latar.



Banyak bahasa pemrograman memiliki hal yang luar biasa seperti RegEx , ini adalah alat yang memungkinkan Anda untuk mencari kecocokan teks dalam sebuah string. Saya sarankan Anda setidaknya membiasakan diri dengan kemampuan dasarnya, karena cepat atau lambat setiap programmer mungkin perlu "menarik" beberapa informasi dari teks.



Sekarang penting bagi kita untuk mengetahui hanya dua hal:



  1. Pola menentukan apa yang sebenarnya perlu kita temukan dalam teks
  2. Pencocokan akan berjalan melalui teks berusaha menemukan apa yang kami tentukan dalam Pola


Mungkin dia tidak menggambarkannya dengan benar, tetapi beginilah cara kerjanya.



Karena saya sedang menulis editor untuk JavaScript, berikut adalah pola kecil dengan kata kunci bahasa:



private val KEYWORDS = Pattern.compile(
    "\\b(function|var|this|if|else|break|case|try|catch|while|return|switch)\\b"
)


Tentu saja, harus ada lebih banyak kata di sini, dan kita juga membutuhkan pola untuk komentar, garis, angka, dll tetapi tugas saya adalah menunjukkan prinsip yang dengannya Anda dapat menemukan konten yang diinginkan dalam teks.



Selanjutnya, menggunakan Matcher, kita akan melihat seluruh teks dan mengatur rentang:



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
        )
    }
}


Mari saya jelaskan: kita mendapatkan objek Matcher dari Pattern , dan menunjukkan padanya area untuk mencari karakter (Oleh karena itu, dari 0 hingga text.lengthini semua teks). Selanjutnya, panggilan matcher.find()akan kembali truejika kecocokan ditemukan dalam teks, dan dengan bantuan panggilan matcher.start()dan matcher.end()kita akan mendapatkan posisi awal dan akhir dari kecocokan dalam teks. Mengetahui data ini, kita dapat menggunakan metode ini setSpanuntuk mewarnai area teks tertentu.



Ada banyak jenis bentang, tetapi biasanya digunakan untuk mengecat ulang teks ForegroundColorSpan.



Jadi ayo mulai!



Hasilnya memenuhi harapan persis sampai kita mulai mengedit file besar (dalam tangkapan layar file ~ 1000 baris)



. Faktanya adalah bahwa metode ini setSpanbekerja lambat, memuat UI Thread, dan mengingat bahwa metode afterTextChangedini dipanggil setelah setiap karakter dimasukkan, itu menjadi satu siksaan.



Cari solusinya



Hal pertama yang terlintas dalam pikiran adalah untuk melakukan operasi besar di aliran latar belakang. Tapi di sini ini adalah operasi yang sulit di setSpanseluruh teks, bukan garis biasa. (Saya pikir saya tidak perlu menjelaskan mengapa tidak mungkin menelepon setSpandari utas latar belakang).



Setelah sedikit mencari artikel fitur, kami menemukan bahwa jika kami ingin mencapai kehalusan, kami hanya perlu menyoroti bagian teks yang terlihat.



Baik! Ayo lakukan! Cuma ... bagaimana?



Optimasi



Meskipun saya menyebutkan bahwa kami hanya peduli dengan kinerja metode setSpan, saya masih merekomendasikan menempatkan RegEx bekerja di utas latar belakang untuk mencapai kehalusan maksimum.



Kami membutuhkan kelas yang akan memproses semua teks di latar belakang dan mengembalikan daftar rentang.

Saya tidak akan memberikan implementasi spesifik, tetapi jika ada yang tertarik, maka saya menggunakan yang cocok AsyncTaskuntuk itu ThreadPoolExecutor. (Ya, ya, AsyncTask pada tahun 2020) Hal



utama bagi kami adalah bahwa logika berikut dijalankan:



  1. Dalam Tugas beforeTextChanged berhenti yang mem-parsing teks
  2. Di kami afterTextChanged memulai Tugas yang mem-parsing teks
  3. Di akhir pekerjaannya, Tugas harus mengembalikan daftar rentang TextProcessor, yang, pada gilirannya, akan menyoroti hanya bagian yang terlihat


Dan ya, kami juga akan menulis rentang kami sendiri:



data class SyntaxHighlightSpan(
    private val color: Int,
    val start: Int,
    val end: Int
) : CharacterStyle() {

    //     italic, ,   
    override fun updateDrawState(textPaint: TextPaint?) {
        textPaint?.color = color
    }
}


Dengan demikian, kode editor berubah menjadi sesuatu yang serupa:



Banyak kode
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() {
        //     
    }
}




Karena saya tidak menunjukkan implementasi khusus pemrosesan di latar belakang, mari kita bayangkan bahwa kami menulis yang tertentu JavaScriptStyleryang akan melakukan semua yang ada di latar belakang yang kami lakukan sebelumnya di Thread UI - jalankan melalui seluruh teks untuk mencari kecocokan dan mengisi daftar rentang, dan pada akhirnya karyanya akan mengembalikan hasilnya setSpansCallback. Pada saat ini, metode akan diluncurkan updateSyntaxHighlightingyang akan melalui daftar rentang dan hanya menampilkan yang saat ini terlihat di layar.



Bagaimana memahami teks apa yang jatuh ke area yang terlihat?



Saya akan merujuk ke artikel ini , di sana penulis menyarankan menggunakan sesuatu seperti ini:



val topVisibleLine = scrollY / lineHeight
val bottomVisibleLine = topVisibleLine + height / lineHeight + 1 // height -  View
val lineStart = layout.getLineStart(topVisibleLine)
val lineEnd = layout.getLineEnd(bottomVisibleLine)


Dan itu berhasil! Sekarang mari kita beralih topVisibleLineitu bottomVisibleLineuntuk memisahkan metode dan menambahkan beberapa pemeriksaan tambahan, dalam hal sesuatu yang tidak beres:



Metode baru
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
}




Hal terakhir yang harus dilakukan adalah menelusuri daftar rentang dan warna teks:



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
        )
    }
}


Jangan takut akan hal yang menakutkan if, tetapi itu hanya memeriksa apakah rentang dari daftar jatuh ke area yang terlihat.



Nah, apakah itu berhasil?



Ini berfungsi, tetapi ketika mengedit teks, bentang tidak diperbarui, Anda dapat memperbaiki situasi dengan menghapus teks dari semua bentang sebelum menayangkan yang baru:



// :  getSpans   core-ktx
val textSpans = text.getSpans<SyntaxHighlightSpan>(0, text.length)
for (span in textSpans) {
    text.removeSpan(span)
}


Tiang lain - setelah menutup keyboard, sepotong teks tetap tidak menyala, perbaiki:



override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    updateSyntaxHighlighting()
}


Hal utama adalah jangan lupa untuk menunjukkan adjustResizedalam manifes.



Bergulir



Berbicara tentang menggulir, saya akan merujuk ke artikel ini lagi . Penulis menyarankan menunggu 500ms setelah akhir bergulir, yang bertentangan dengan rasa keindahan saya. Saya tidak ingin menunggu lampu latar memuat, saya ingin melihat hasilnya secara instan.



Juga, penulis membuat argumen bahwa meluncurkan parser setelah setiap pixel "digulir" itu mahal, dan saya sepenuhnya setuju dengan ini (umumnya saya menyarankan Anda membaca artikelnya sepenuhnya, itu kecil, tetapi ada banyak hal menarik). Tetapi kenyataannya adalah bahwa kita sudah memiliki daftar bentang siap pakai, dan kita tidak perlu meluncurkan parser.



Cukup memanggil metode yang bertanggung jawab untuk memperbarui sorotan:



override fun onScrollChanged(horiz: Int, vert: Int, oldHoriz: Int, oldVert: Int) {
    super.onScrollChanged(horiz, vert, oldHoriz, oldVert)
    updateSyntaxHighlighting()
}


Penomoran baris



Jika kita menambahkan satu lagi ke markup, TextViewakan bermasalah untuk menautkannya bersama-sama (misalnya, secara sinkron memperbarui ukuran teks), dan bahkan jika kita memiliki file besar, kita harus sepenuhnya memperbarui teks dengan angka setelah setiap huruf dimasukkan, yang tidak terlalu keren. Oleh karena itu, kami akan menggunakan cara standar apa pun CustomView- menggambar Canvasdi onDraw, cepat dan tidak sulit.



Pertama, mari kita tentukan apa yang akan kita gambar:



  • Nomor baris
  • Garis vertikal yang memisahkan bidang input dari nomor-nomor baris


Pertama-tama Anda harus menghitung dan mengatur di paddingsebelah kiri editor sehingga tidak ada konflik dengan teks yang dicetak.



Untuk melakukan ini, kita akan menulis fungsi yang akan memperbarui indentasi sebelum menggambar:



Perbarui Indent
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)
    }
}




Penjelasan:



Pertama, kami menemukan jumlah baris dalam EditText(jangan bingung dengan jumlah " \n" dalam teks), dan mengambil jumlah karakter dari nomor ini. Misalnya, jika kita memiliki 100 baris, maka variabelnya gutterDigitCountadalah 3, karena jumlah 100 tepat 3 karakter. Tetapi katakanlah kita hanya memiliki 1 baris, yang berarti bahwa lekukan 1 karakter secara visual akan tampak kecil, dan untuk ini kita menggunakan variabel jumlah untuk menetapkan indentasi minimum yang ditampilkan 3 karakter, bahkan jika kita memiliki kurang dari 100 baris kode.



Bagian ini adalah yang paling membingungkan dari semua, tetapi jika Anda serius membacanya beberapa kali (melihat kode), maka semuanya akan menjadi jelas.



Selanjutnya, atur indentasi dengan precomputing widestNumberdan widestWidth.



Ayo mulai menggambar



Sayangnya, jika kita ingin menggunakan pembungkus teks Android standar pada baris baru, kita harus menyulap, yang akan membawa kita banyak waktu dan bahkan lebih banyak kode, yang akan cukup untuk seluruh artikel, oleh karena itu, untuk mengurangi waktu Anda (dan waktu moderator habr), kami akan memungkinkan horisontal gulir agar semua baris saling berurutan:



setHorizontallyScrolling(true)


Nah, sekarang Anda bisa mulai menggambar, mendeklarasikan variabel dengan tipe Paint:



private val gutterTextPaint = Paint() //  
private val gutterDividerPaint = Paint() //  


initAtur warna teks dan warna pemisah di suatu tempat di blok. Penting untuk diingat bahwa jika Anda mengubah font teks, maka font Painttersebut harus diterapkan secara manual, untuk ini saya menyarankan Anda untuk mengganti metode setTypeface. Begitu juga dengan ukuran teks.



Kemudian kami mengganti metode 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
    )
}


Kami melihat hasilnya



Itu terlihat keren.



Apa yang telah kita lakukan onDraw? Sebelum memanggil super-metode, kami memperbarui lekukan, setelah itu kami memberikan angka hanya di area yang terlihat, dan pada akhirnya kami menggambar garis vertikal yang secara visual memisahkan penomoran baris dari editor kode.



Untuk kecantikan, Anda juga dapat mengecat ulang lekukan dalam warna yang berbeda, sorot secara visual garis di mana kursor berada, tetapi saya akan menyerahkannya kepada Anda.



Kesimpulan



Pada artikel ini, kami menulis editor kode responsif dengan penyorotan sintaks dan penomoran baris, dan pada bagian selanjutnya kita akan menambahkan penyelesaian kode yang mudah dan penyorotan kesalahan sintaks saat mengedit.



Saya juga akan meninggalkan tautan ke kode sumber editor kode GitHub saya , di sana Anda tidak hanya akan menemukan fitur yang saya bicarakan dalam artikel ini, tetapi juga banyak lainnya yang telah diabaikan.



UPD: Bagian kedua sudah keluar.



Ajukan pertanyaan dan sarankan topik untuk diskusi, karena saya bisa saja melewatkan sesuatu.



Terima kasih!



All Articles