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
EditText
ke layar penuh, menunjukkan gravity
transparan background
untuk 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
CustomView
dari mana mewarisi EditText
. Kami melempar TextWatcher
untuk mendengarkan perubahan dalam teks dan mendefinisikan kembali metode afterTextChanged
di 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
TextWatcher
variabel, karena Anda dapat mengimplementasikan antarmuka secara langsung di kelas?
A: Kebetulan kami
TextWatcher
memiliki 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
onTextChanged
y akan TextView
dipanggil bersama dengan onTextChanged
y TextWatcher
. Jika kita meletakkan log di tubuh metode, kita akan melihat apa yang onTextChanged
disebut 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:
- Pola menentukan apa yang sebenarnya perlu kita temukan dalam teks
- 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.length
ini semua teks). Selanjutnya, panggilan matcher.find()
akan kembali true
jika 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 setSpan
untuk 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
setSpan
bekerja lambat, memuat UI Thread, dan mengingat bahwa metode afterTextChanged
ini 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
setSpan
seluruh teks, bukan garis biasa. (Saya pikir saya tidak perlu menjelaskan mengapa tidak mungkin menelepon setSpan
dari 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
AsyncTask
untuk itu ThreadPoolExecutor
. (Ya, ya, AsyncTask pada tahun 2020) Hal
utama bagi kami adalah bahwa logika berikut dijalankan:
- Dalam Tugas
beforeTextChanged
berhenti yang mem-parsing teks - Di kami
afterTextChanged
memulai Tugas yang mem-parsing teks - 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
JavaScriptStyler
yang 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 updateSyntaxHighlighting
yang 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
topVisibleLine
itu bottomVisibleLine
untuk 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
adjustResize
dalam 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,
TextView
akan 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 Canvas
di 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
padding
sebelah 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 gutterDigitCount
adalah 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
widestNumber
dan 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() //
init
Atur warna teks dan warna pemisah
di suatu tempat di blok. Penting untuk diingat bahwa jika Anda mengubah font teks, maka font Paint
tersebut 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!