11package com.tool.tree.ui
22
33import android.graphics.Typeface
4+ import android.os.Handler
5+ import android.os.Looper
46import android.text.Editable
57import android.text.Spanned
68import android.text.TextWatcher
@@ -13,15 +15,7 @@ import java.util.Locale
1315
1416/* *
1517 * Lightweight syntax highlighter for plain EditText.
16- *
17- * Supported by default:
18- * - Shell scripts: .sh, .bash, .zsh, .ksh
19- * - XML files: .xml
20- * - build.prop / .prop / .properties
21- *
22- * Usage:
23- * val highlighter = SyntaxHighlighterFactory.create("sh", editText)
24- * highlighter?.attach()
18+ * Fully optimized using Debounce mechanism and Combined Named-Group Regex.
2519 */
2620object SyntaxHighlighterFactory {
2721 fun create (extension : String? , editText : EditText ): SyntaxHighlighter ? {
@@ -60,8 +54,13 @@ abstract class BaseSyntaxHighlighter(
6054 private var watcher: TextWatcher ? = null
6155 private var isHighlighting = false
6256
57+ // Handler để điều phối bộ hoãn thực thi (Debounce)
58+ private val mainHandler = Handler (Looper .getMainLooper())
59+ private val highlightRunnable = Runnable { highlight() }
60+
6361 companion object {
64- private const val MAX_HIGHLIGHT_LENGTH = 300_000
62+ private const val MAX_HIGHLIGHT_LENGTH = 150_000 // Giới hạn an toàn để tránh lag UI
63+ private const val DEBOUNCE_DELAY_MS = 150L // Đợi 150ms sau khi dừng gõ mới tô màu
6564 }
6665
6766 protected val editText: EditText ?
@@ -78,14 +77,18 @@ abstract class BaseSyntaxHighlighter(
7877 override fun onTextChanged (s : CharSequence? , start : Int , before : Int , count : Int ) = Unit
7978 override fun afterTextChanged (s : Editable ? ) {
8079 if (isHighlighting || s == null ) return
81- highlight()
80+
81+ // Hủy lịch trình cũ, lên lịch trình mới để gom các phím gõ nhanh lại
82+ mainHandler.removeCallbacks(highlightRunnable)
83+ mainHandler.postDelayed(highlightRunnable, DEBOUNCE_DELAY_MS )
8284 }
8385 }
8486 et.addTextChangedListener(watcher)
85- highlight()
87+ highlight() // Chạy lần đầu tiên ngay khi gắn vào
8688 }
8789
8890 override fun detach () {
91+ mainHandler.removeCallbacks(highlightRunnable)
8992 val et = editText ? : return
9093 watcher?.let { et.removeTextChangedListener(it) }
9194 watcher = null
@@ -97,27 +100,30 @@ abstract class BaseSyntaxHighlighter(
97100 if (isHighlighting) return
98101
99102 if (text.length > MAX_HIGHLIGHT_LENGTH ) {
100- // File quá lớn: tô màu lại toàn bộ trên mỗi lần gõ sẽ làm treo UI thread (ANR).
101- // Bỏ qua tô màu cú pháp cho các file lớn, người dùng vẫn gõ/sửa bình thường.
103+ // File quá lớn: Bỏ qua để giữ hiệu năng gõ mượt mà cho người dùng
102104 return
103105 }
104106
105107 isHighlighting = true
106108 try {
107109 applyHighlight(text)
108110 } catch (_: Throwable ) {
109- // Không để một lỗi ở bước tô màu cú pháp (vd. regex gặp nội dung bất thường)
110- // làm crash toàn bộ app — bỏ qua lần tô màu này là đủ, nội dung gõ vẫn giữ nguyên.
111+ // Tránh crash ứng dụng nếu có lỗi phân tích cú pháp xảy ra ngoài ý muốn
111112 } finally {
112113 isHighlighting = false
113114 }
114115 }
115116
116117 protected fun clearSpans (text : Editable ) {
117- val spans = text.getSpans(0 , text.length, Any ::class .java)
118- for (span in spans) {
119- when (span) {
120- is ForegroundColorSpan , is StyleSpan -> text.removeSpan(span)
118+ // Chỉ bóc tách đúng các loại Spans do hệ thống tô màu này định nghĩa
119+ val colorSpans = text.getSpans(0 , text.length, ForegroundColorSpan ::class .java)
120+ for (span in colorSpans) {
121+ text.removeSpan(span)
122+ }
123+ val styleSpans = text.getSpans(0 , text.length, StyleSpan ::class .java)
124+ for (span in styleSpans) {
125+ if (span.style == Typeface .BOLD ) {
126+ text.removeSpan(span)
121127 }
122128 }
123129 }
@@ -176,65 +182,43 @@ class ShellSyntaxHighlighter(editText: EditText) : BaseSyntaxHighlighter(
176182 " true" , " false" , " type" , " command" , " eval" , " let"
177183 )
178184
185+ private val shellRegex = Regex (
186+ " (?<COMMENT>(?m)#.*$)" +
187+ " |(?<STRING>\" (?:\\\\ .|[^\"\\\\ ])*\" |'(?:[^']*)'|`(?:\\\\ .|[^`\\\\ ])*`)" +
188+ " |(?<COMMAND>\\ $\\ ((?:[^()]*|\\ ([^()]*\\ ))*\\ ))" +
189+ " |(?<VARIABLE>\\ $\\ {[A-Za-z_][A-Za-z0-9_]*[^}]*\\ }|\\ $[A-Za-z_][A-Za-z0-9_]*)" +
190+ " |(?<NUMBER>(?<![A-Za-z0-9_])(?:0x[0-9A-Fa-f]+|[0-9]+)(?![A-Za-z0-9_]))" +
191+ " |(?<WORD>(?<![A-Za-z0-9_])[A-Za-z_][A-Za-z0-9_]*(?![A-Za-z0-9_]))" +
192+ " |(?<PUNCTUATION>&&|\\ |\\ ||\\ ||;|\\ (|\\ )|\\ {|\\ }|\\ [\\ [|\\ ]\\ ]|<|>)"
193+ )
194+
179195 override fun applyHighlight (text : Editable ) {
180196 clearSpans(text)
181197 val s = text.toString()
182198
183- // Comments
184- Regex (""" (?m)#.*$""" ).findAll(s).forEach {
185- color(text, it.range.first, it.range.last + 1 , commentColor())
186- }
187-
188- // Strings: double quotes, single quotes, backticks
189- // Lưu ý: nhánh escape (\\.) và nhánh "mọi ký tự khác" phải KHÔNG được chồng lấn nhau
190- // (cả hai cùng khớp được ký tự '\') — nếu chồng lấn, một chuỗi chưa đóng (thiếu dấu
191- // đóng ") kèm nhiều dấu '\' sẽ khiến regex engine backtrack theo cấp số nhân và ném
192- // StackOverflowError, làm crash app ngay khi người dùng đang gõ dở. Loại bỏ '\\' khỏi
193- // lớp ký tự [^"\\] để hai nhánh không còn khớp trùng nhau nữa.
194- Regex (""" "(?:\\.|[^"\\])*"""" ).findAll(s).forEach {
195- color(text, it.range.first, it.range.last + 1 , stringColor())
196- }
197- Regex (""" '(?:[^']*)'""" ).findAll(s).forEach {
198- color(text, it.range.first, it.range.last + 1 , stringColor())
199- }
200- Regex (""" `(?:\\.|[^`\\])*`""" ).findAll(s).forEach {
201- color(text, it.range.first, it.range.last + 1 , stringColor())
202- }
203-
204- // Command substitution
205- Regex (""" \$\((?:[^()]*|\([^()]*\))*\)""" ).findAll(s).forEach {
206- color(text, it.range.first, it.range.last + 1 , builtinColor())
207- }
208-
209- // Variables
210- Regex (""" \$\{[A-Za-z_][A-Za-z0-9_]*[^}]*}""" ).findAll(s).forEach {
211- color(text, it.range.first, it.range.last + 1 , numberColor())
212- }
213- Regex (""" \$[A-Za-z_][A-Za-z0-9_]*""" ).findAll(s).forEach {
214- color(text, it.range.first, it.range.last + 1 , numberColor())
215- }
216-
217- // Numbers
218- Regex (""" (?<![A-Za-z0-9_])(?:0x[0-9A-Fa-f]+|[0-9]+)(?![A-Za-z0-9_])""" ).findAll(s).forEach {
219- color(text, it.range.first, it.range.last + 1 , numberColor())
220- }
199+ shellRegex.findAll(s).forEach { result ->
200+ val groups = result.groups
201+ val start = result.range.first
202+ val end = result.range.last + 1
221203
222- // Keywords / builtins
223- Regex (""" (?<![A-Za-z0-9_])([A-Za-z_][A-Za-z0-9_]*)(?![A-Za-z0-9_])""" ).findAll(s).forEach {
224- val word = it.value
225204 when {
226- keywords.contains(word) -> {
227- color(text, it.range.first, it.range.last + 1 , keywordColor())
228- bold(text, it.range.first, it.range.last + 1 )
205+ groups[" COMMENT" ] != null -> color(text, start, end, commentColor())
206+ groups[" STRING" ] != null -> color(text, start, end, stringColor())
207+ groups[" COMMAND" ] != null -> color(text, start, end, builtinColor())
208+ groups[" VARIABLE" ] != null -> color(text, start, end, numberColor())
209+ groups[" NUMBER" ] != null -> color(text, start, end, numberColor())
210+ groups[" PUNCTUATION" ] != null -> color(text, start, end, punctuationColor())
211+ groups[" WORD" ] != null -> {
212+ val word = result.value
213+ if (keywords.contains(word)) {
214+ color(text, start, end, keywordColor())
215+ bold(text, start, end)
216+ } else if (builtins.contains(word)) {
217+ color(text, start, end, builtinColor())
218+ }
229219 }
230- builtins.contains(word) -> color(text, it.range.first, it.range.last + 1 , builtinColor())
231220 }
232221 }
233-
234- // Punctuation/operators
235- Regex (""" &&|\|\||\||;|\(|\)|\{|\}|\[\[|\]\]|<|>""" ).findAll(s).forEach {
236- color(text, it.range.first, it.range.last + 1 , punctuationColor())
237- }
238222 }
239223}
240224
@@ -247,44 +231,47 @@ class XmlSyntaxHighlighter(editText: EditText) : BaseSyntaxHighlighter(
247231 numberColor = 0xFFAA66CC .toInt(),
248232 punctuationColor = 0xFF808080 .toInt(),
249233) {
234+ private val xmlRegex = Regex (
235+ " (?<COMMENT>(?s)<!--.*?-->)" +
236+ " |(?<CDATA>(?s)<!\\ [CDATA\\ [.*?\\ ]\\ ]>)" +
237+ " |(?<TAG></?[A-Za-z_][A-Za-z0-9_:\\ -\\ .]*)" +
238+ " |(?<ATTR>\\ b[A-Za-z_][A-Za-z0-9_:\\ -\\ .]*(?=\\ s*=))" +
239+ " |(?<VALUE>\" (?:\\\\ .|[^\"\\\\ ])*\" )" +
240+ " |(?<ENTITY>&(?:amp|lt|gt|apos|quot|#x?[0-9A-Fa-f]+);)" +
241+ " |(?<PUNCTUATION></?|/>|>[^<]*)"
242+ )
243+
250244 override fun applyHighlight (text : Editable ) {
251245 clearSpans(text)
252246 val s = text.toString()
253247
254- // XML comments
255- Regex (""" (?s)<!--.*?-->""" ).findAll(s).forEach {
256- color(text, it.range.first, it.range.last + 1 , commentColor())
257- }
258-
259- // CDATA
260- Regex (""" (?s)<!\[CDATA\[.*?\]\]>""" ).findAll(s).forEach {
261- color(text, it.range.first, it.range.last + 1 , stringColor())
262- }
248+ xmlRegex.findAll(s).forEach { result ->
249+ val groups = result.groups
250+ val start = result.range.first
251+ val end = result.range.last + 1
263252
264- // Tags
265- Regex (""" </?[A-Za-z_][A-Za-z0-9_:\-\.]*""" ).findAll(s).forEach {
266- color(text, it.range.first, it.range.last + 1 , keywordColor())
267- bold(text, it.range.first, it.range.last + 1 )
268- }
269-
270- // Attribute names (the part before =")
271- Regex (""" \b[A-Za-z_][A-Za-z0-9_:\-\.]*(?=\s*=)""" ).findAll(s).forEach {
272- color(text, it.range.first, it.range.last + 1 , builtinColor())
273- }
274-
275- // Attribute values (xem ghi chú về backtracking ở ShellSyntaxHighlighter phía trên)
276- Regex (""" "(?:\\.|[^"\\])*"""" ).findAll(s).forEach {
277- color(text, it.range.first, it.range.last + 1 , stringColor())
278- }
279-
280- // Entities
281- Regex (""" &(?:amp|lt|gt|apos|quot|#x?[0-9A-Fa-f]+);""" ).findAll(s).forEach {
282- color(text, it.range.first, it.range.last + 1 , numberColor())
283- }
284-
285- // Angle brackets / punctuation
286- Regex (""" </?|/>|>""" ).findAll(s).forEach {
287- color(text, it.range.first, it.range.last + 1 , punctuationColor())
253+ when {
254+ groups[" COMMENT" ] != null -> color(text, start, end, commentColor())
255+ groups[" CDATA" ] != null -> color(text, start, end, stringColor())
256+ groups[" TAG" ] != null -> {
257+ color(text, start, end, keywordColor())
258+ bold(text, start, end)
259+ }
260+ groups[" ATTR" ] != null -> color(text, start, end, builtinColor())
261+ groups[" VALUE" ] != null -> color(text, start, end, stringColor())
262+ groups[" ENTITY" ] != null -> color(text, start, end, numberColor())
263+ groups[" PUNCTUATION" ] != null -> {
264+ // Chỉ tô màu ký tự phân tách thực tế (<, >, />, </) không bao gồm phần text trần bên ngoài
265+ val rawValue = result.value
266+ val punctEnd = when {
267+ rawValue.startsWith(" </" ) -> start + 2
268+ rawValue.startsWith(" />" ) -> start + 2
269+ rawValue.startsWith(" <" ) || rawValue.startsWith(" >" ) -> start + 1
270+ else -> end
271+ }
272+ color(text, start, punctEnd, punctuationColor())
273+ }
274+ }
288275 }
289276 }
290277}
@@ -298,39 +285,38 @@ class PropSyntaxHighlighter(editText: EditText) : BaseSyntaxHighlighter(
298285 numberColor = 0xFFAA66CC .toInt(),
299286 punctuationColor = 0xFF808080 .toInt(),
300287) {
288+ private val propRegex = Regex (
289+ " (?<COMMENT>(?m)^\\ s*[#!].*$)" +
290+ " |(?<KEYVALUE>(?m)^\\ s*([A-Za-z0-9_.-]+)(\\ s*=))" +
291+ " |(?<STRING>\" (?:\\\\ .|[^\"\\\\ ])*\" )" +
292+ " |(?<NUMBER>(?<![A-Za-z0-9_])(?:0x[0-9A-Fa-f]+|[0-9]+)(?![A-Za-z0-9_]))" +
293+ " |(?<SEPARATOR>=)"
294+ )
295+
301296 override fun applyHighlight (text : Editable ) {
302297 clearSpans(text)
303298 val s = text.toString()
304299
305- // Comments
306- Regex ( """ (?m)^\s*[#!].*$ """ ).findAll(s).forEach {
307- color(text, it.range.first, it .range.last + 1 , commentColor())
308- }
300+ propRegex.findAll(s).forEach { result ->
301+ val groups = result.groups
302+ val start = result .range.first
303+ val end = result.range.last + 1
309304
310- // Key = value
311- Regex (""" (?m)^\s*([A-Za-z0-9_.-]+)(\s*=)""" ).findAll(s).forEach {
312- val line = it.value
313- val keyEnd = line.indexOf(' =' )
314- if (keyEnd > 0 ) {
315- color(text, it.range.first, it.range.first + keyEnd, keywordColor())
316- color(text, it.range.first + keyEnd, it.range.first + keyEnd + 1 , punctuationColor())
317- bold(text, it.range.first, it.range.first + keyEnd)
305+ when {
306+ groups[" COMMENT" ] != null -> color(text, start, end, commentColor())
307+ groups[" KEYVALUE" ] != null -> {
308+ val line = result.value
309+ val keyEnd = line.indexOf(' =' )
310+ if (keyEnd > 0 ) {
311+ color(text, start, start + keyEnd, keywordColor())
312+ color(text, start + keyEnd, start + keyEnd + 1 , punctuationColor())
313+ bold(text, start, start + keyEnd)
314+ }
315+ }
316+ groups[" STRING" ] != null -> color(text, start, end, stringColor())
317+ groups[" NUMBER" ] != null -> color(text, start, end, numberColor())
318+ groups[" SEPARATOR" ] != null -> color(text, start, end, punctuationColor())
318319 }
319320 }
320-
321- // Quoted values (xem ghi chú về backtracking ở ShellSyntaxHighlighter phía trên)
322- Regex (""" "(?:\\.|[^"\\])*"""" ).findAll(s).forEach {
323- color(text, it.range.first, it.range.last + 1 , stringColor())
324- }
325-
326- // Numbers
327- Regex (""" (?<![A-Za-z0-9_])(?:0x[0-9A-Fa-f]+|[0-9]+)(?![A-Za-z0-9_])""" ).findAll(s).forEach {
328- color(text, it.range.first, it.range.last + 1 , numberColor())
329- }
330-
331- // Separators
332- Regex (""" =""" ).findAll(s).forEach {
333- color(text, it.range.first, it.range.last + 1 , punctuationColor())
334- }
335321 }
336- }
322+ }
0 commit comments