Skip to content

Commit b74ba4b

Browse files
committed
Update
1 parent 3c04b43 commit b74ba4b

3 files changed

Lines changed: 337 additions & 6 deletions

File tree

app/src/main/java/com/tool/tree/TextEditorActivity.kt

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import com.omarea.krscript.model.ActionNode
2626
import com.omarea.krscript.model.RunnableNode
2727
import com.omarea.krscript.ui.DialogLogFragment
2828
import com.tool.tree.databinding.ActivityTextEditorBinding
29+
import com.tool.tree.ui.SyntaxHighlighter
30+
import com.tool.tree.ui.SyntaxHighlighterFactory
2931
import kotlinx.coroutines.Dispatchers
3032
import kotlinx.coroutines.launch
3133
import kotlinx.coroutines.withContext
@@ -52,9 +54,7 @@ class TextEditorActivity : AppCompatActivity() {
5254
// Các đuôi file được coi là script shell, cho phép "chạy thử nghiệm"
5355
private val RUNNABLE_EXTENSIONS = mapOf(
5456
"sh" to "sh",
55-
"bash" to "bash",
56-
"zsh" to "zsh",
57-
"ksh" to "ksh"
57+
"bash" to "bash"
5858
)
5959

6060
fun start(
@@ -89,6 +89,7 @@ class TextEditorActivity : AppCompatActivity() {
8989
private var isSaving = false
9090
private var noWrapContainer: HorizontalScrollView? = null
9191
private var mainListBaseBottomMargin = 0
92+
private var syntaxHighlighter: SyntaxHighlighter? = null
9293
private val progressBarDialog by lazy { ProgressBarDialog(this) }
9394

9495
override fun onCreate(savedInstanceState: Bundle?) {
@@ -112,7 +113,6 @@ class TextEditorActivity : AppCompatActivity() {
112113
finish()
113114
return
114115
}
115-
116116
filePath = extraFile
117117
configDir = intent.getStringExtra(EXTRA_DIR).orEmpty()
118118
absoluteFilePath = resolveAbsolutePath(configDir, filePath)
@@ -127,6 +127,12 @@ class TextEditorActivity : AppCompatActivity() {
127127
loadFileContent()
128128
}
129129

130+
override fun onDestroy() {
131+
syntaxHighlighter?.detach()
132+
syntaxHighlighter = null
133+
super.onDestroy()
134+
}
135+
130136
/**
131137
* App đang dùng chế độ edge-to-edge (decorFitsSystemWindows = false) nên hệ thống sẽ
132138
* không tự đẩy layout lên khi hiện bàn phím dù có khai báo windowSoftInputMode="adjustResize"
@@ -215,6 +221,12 @@ class TextEditorActivity : AppCompatActivity() {
215221

216222
private fun runnableInterpreter(): String? = RUNNABLE_EXTENSIONS[fileExtension()]
217223

224+
private fun setupSyntaxHighlighting() {
225+
syntaxHighlighter?.detach()
226+
syntaxHighlighter = SyntaxHighlighterFactory.createForPath(absoluteFilePath, binding.editorContent)
227+
syntaxHighlighter?.attach()
228+
}
229+
218230
private fun loadFileContent() {
219231
progressBarDialog.showDialog(getString(R.string.please_wait))
220232
lifecycleScope.launch(Dispatchers.IO) {
@@ -240,6 +252,8 @@ class TextEditorActivity : AppCompatActivity() {
240252
} else {
241253
getString(R.string.editor_hint_empty)
242254
}
255+
256+
setupSyntaxHighlighting()
243257
}
244258
}
245259
}
@@ -494,4 +508,4 @@ class TextEditorActivity : AppCompatActivity() {
494508
dialog.isCancelable = false
495509
dialog.show(supportFragmentManager, "editor-run-test")
496510
}
497-
}
511+
}
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
package com.tool.tree.ui
2+
3+
import android.graphics.Typeface
4+
import android.text.Editable
5+
import android.text.TextWatcher
6+
import android.text.style.ForegroundColorSpan
7+
import android.text.style.StyleSpan
8+
import android.widget.EditText
9+
import androidx.annotation.ColorInt
10+
import java.lang.ref.WeakReference
11+
import java.util.Locale
12+
13+
/**
14+
* Lightweight syntax highlighter for plain EditText.
15+
*
16+
* Supported by default:
17+
* - Shell scripts: .sh, .bash, .zsh, .ksh
18+
* - XML files: .xml
19+
* - build.prop / .prop / .properties
20+
*
21+
* Usage:
22+
* val highlighter = SyntaxHighlighterFactory.create("sh", editText)
23+
* highlighter?.attach()
24+
*/
25+
object SyntaxHighlighterFactory {
26+
fun create(extension: String?, editText: EditText): SyntaxHighlighter? {
27+
val ext = extension.orEmpty().lowercase(Locale.ROOT)
28+
return when (ext) {
29+
"sh", "bash", "zsh", "ksh" -> ShellSyntaxHighlighter(editText)
30+
"xml" -> XmlSyntaxHighlighter(editText)
31+
"prop", "properties" -> PropSyntaxHighlighter(editText)
32+
else -> null
33+
}
34+
}
35+
36+
fun createForPath(path: String?, editText: EditText): SyntaxHighlighter? {
37+
val ext = path.orEmpty().substringAfterLast('.', "")
38+
return create(ext, editText)
39+
}
40+
}
41+
42+
interface SyntaxHighlighter {
43+
fun attach()
44+
fun detach()
45+
fun highlight()
46+
}
47+
48+
abstract class BaseSyntaxHighlighter(
49+
editText: EditText,
50+
@ColorInt private val keywordColor: Int,
51+
@ColorInt private val builtinColor: Int,
52+
@ColorInt private val stringColor: Int,
53+
@ColorInt private val commentColor: Int,
54+
@ColorInt private val numberColor: Int,
55+
@ColorInt private val punctuationColor: Int,
56+
) : SyntaxHighlighter {
57+
58+
private val editTextRef = WeakReference(editText)
59+
private var watcher: TextWatcher? = null
60+
private var isHighlighting = false
61+
62+
protected val editText: EditText?
63+
get() = editTextRef.get()
64+
65+
protected abstract fun applyHighlight(text: Editable)
66+
67+
override fun attach() {
68+
val et = editText ?: return
69+
if (watcher != null) return
70+
71+
watcher = object : TextWatcher {
72+
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
73+
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
74+
override fun afterTextChanged(s: Editable?) {
75+
if (isHighlighting || s == null) return
76+
highlight()
77+
}
78+
}
79+
et.addTextChangedListener(watcher)
80+
highlight()
81+
}
82+
83+
override fun detach() {
84+
val et = editText ?: return
85+
watcher?.let { et.removeTextChangedListener(it) }
86+
watcher = null
87+
}
88+
89+
override fun highlight() {
90+
val et = editText ?: return
91+
val text = et.text ?: return
92+
if (isHighlighting) return
93+
94+
isHighlighting = true
95+
try {
96+
applyHighlight(text)
97+
} finally {
98+
isHighlighting = false
99+
}
100+
}
101+
102+
protected fun clearSpans(text: Editable) {
103+
val spans = text.getSpans(0, text.length, Any::class.java)
104+
for (span in spans) {
105+
when (span) {
106+
is ForegroundColorSpan, is StyleSpan -> text.removeSpan(span)
107+
}
108+
}
109+
}
110+
111+
protected fun color(text: Editable, start: Int, end: Int, @ColorInt color: Int) {
112+
if (start < 0 || end <= start || start >= text.length) return
113+
val safeEnd = end.coerceAtMost(text.length)
114+
text.setSpan(
115+
ForegroundColorSpan(color),
116+
start,
117+
safeEnd,
118+
Editable.SPAN_EXCLUSIVE_EXCLUSIVE
119+
)
120+
}
121+
122+
protected fun bold(text: Editable, start: Int, end: Int) {
123+
if (start < 0 || end <= start || start >= text.length) return
124+
val safeEnd = end.coerceAtMost(text.length)
125+
text.setSpan(
126+
StyleSpan(Typeface.BOLD),
127+
start,
128+
safeEnd,
129+
Editable.SPAN_EXCLUSIVE_EXCLUSIVE
130+
)
131+
}
132+
133+
protected fun keywordColor(): Int = keywordColor
134+
protected fun builtinColor(): Int = builtinColor
135+
protected fun stringColor(): Int = stringColor
136+
protected fun commentColor(): Int = commentColor
137+
protected fun numberColor(): Int = numberColor
138+
protected fun punctuationColor(): Int = punctuationColor
139+
}
140+
141+
class ShellSyntaxHighlighter(editText: EditText) : BaseSyntaxHighlighter(
142+
editText = editText,
143+
keywordColor = 0xFF3D8BFF.toInt(),
144+
builtinColor = 0xFFFFA000.toInt(),
145+
stringColor = 0xFF2EAD4B.toInt(),
146+
commentColor = 0xFF8A8A8A.toInt(),
147+
numberColor = 0xFFAA66CC.toInt(),
148+
punctuationColor = 0xFF808080.toInt(),
149+
) {
150+
151+
private val keywords = setOf(
152+
"if", "then", "else", "elif", "fi",
153+
"for", "while", "until", "do", "done",
154+
"case", "esac", "in", "function", "select",
155+
"time", "coproc"
156+
)
157+
158+
private val builtins = setOf(
159+
"echo", "cd", "pwd", "export", "unset", "alias", "exec",
160+
"source", ".", "printf", "test", "read", "shift", "set",
161+
"local", "return", "trap", "kill", "wait", "jobs", "fg", "bg",
162+
"true", "false", "type", "command", "eval", "let"
163+
)
164+
165+
override fun applyHighlight(text: Editable) {
166+
clearSpans(text)
167+
val s = text.toString()
168+
169+
// Comments
170+
Regex("(?m)#.*$").findAll(s).forEach {
171+
color(text, it.range.first, it.range.last + 1, commentColor())
172+
}
173+
174+
// Strings: double quotes, single quotes, backticks
175+
Regex("\"(?:\\.|[^\"])*\"").findAll(s).forEach {
176+
color(text, it.range.first, it.range.last + 1, stringColor())
177+
}
178+
Regex("'(?:[^']*)'").findAll(s).forEach {
179+
color(text, it.range.first, it.range.last + 1, stringColor())
180+
}
181+
Regex("`(?:\\.|[^`])*`").findAll(s).forEach {
182+
color(text, it.range.first, it.range.last + 1, stringColor())
183+
}
184+
185+
// Command substitution
186+
Regex("\\$\\((?:[^()]*|\\([^()]*\\))*\\)").findAll(s).forEach {
187+
color(text, it.range.first, it.range.last + 1, builtinColor())
188+
}
189+
190+
// Variables
191+
Regex("\\$\{[A-Za-z_][A-Za-z0-9_]*[^}]*}").findAll(s).forEach {
192+
color(text, it.range.first, it.range.last + 1, numberColor())
193+
}
194+
Regex("\\$[A-Za-z_][A-Za-z0-9_]*").findAll(s).forEach {
195+
color(text, it.range.first, it.range.last + 1, numberColor())
196+
}
197+
198+
// Numbers
199+
Regex("(?<![A-Za-z0-9_])(?:0x[0-9A-Fa-f]+|[0-9]+)(?![A-Za-z0-9_])").findAll(s).forEach {
200+
color(text, it.range.first, it.range.last + 1, numberColor())
201+
}
202+
203+
// Keywords / builtins
204+
Regex("(?<![A-Za-z0-9_])([A-Za-z_][A-Za-z0-9_]*)(?![A-Za-z0-9_])").findAll(s).forEach {
205+
val word = it.value
206+
when {
207+
keywords.contains(word) -> {
208+
color(text, it.range.first, it.range.last + 1, keywordColor())
209+
bold(text, it.range.first, it.range.last + 1)
210+
}
211+
builtins.contains(word) -> color(text, it.range.first, it.range.last + 1, builtinColor())
212+
}
213+
}
214+
215+
// Punctuation/operators
216+
Regex("&&|\|\||\\||;|\\(|\\)|\\{|\\}|\\[\\[|\\]\\]|<|>").findAll(s).forEach {
217+
color(text, it.range.first, it.range.last + 1, punctuationColor())
218+
}
219+
}
220+
}
221+
222+
class XmlSyntaxHighlighter(editText: EditText) : BaseSyntaxHighlighter(
223+
editText = editText,
224+
keywordColor = 0xFF3D8BFF.toInt(),
225+
builtinColor = 0xFFFFA000.toInt(),
226+
stringColor = 0xFF2EAD4B.toInt(),
227+
commentColor = 0xFF8A8A8A.toInt(),
228+
numberColor = 0xFFAA66CC.toInt(),
229+
punctuationColor = 0xFF808080.toInt(),
230+
) {
231+
override fun applyHighlight(text: Editable) {
232+
clearSpans(text)
233+
val s = text.toString()
234+
235+
// XML comments
236+
Regex("(?s)<!--.*?-->").findAll(s).forEach {
237+
color(text, it.range.first, it.range.last + 1, commentColor())
238+
}
239+
240+
// CDATA
241+
Regex("(?s)<!\[CDATA\[.*?\]\]>").findAll(s).forEach {
242+
color(text, it.range.first, it.range.last + 1, stringColor())
243+
}
244+
245+
// Tags
246+
Regex("</?[A-Za-z_][A-Za-z0-9_:\\-\.]*").findAll(s).forEach {
247+
color(text, it.range.first, it.range.last + 1, keywordColor())
248+
bold(text, it.range.first, it.range.last + 1)
249+
}
250+
251+
// Attribute names
252+
Regex("\b[A-Za-z_][A-Za-z0-9_:\\-\.]*=(?=\")").findAll(s).forEach {
253+
color(text, it.range.first, it.range.last, builtinColor())
254+
}
255+
256+
// Attribute values
257+
Regex("\"(?:\\.|[^\"])*\"").findAll(s).forEach {
258+
color(text, it.range.first, it.range.last + 1, stringColor())
259+
}
260+
261+
// Entities
262+
Regex("&(?:amp|lt|gt|apos|quot|#x?[0-9A-Fa-f]+);").findAll(s).forEach {
263+
color(text, it.range.first, it.range.last + 1, numberColor())
264+
}
265+
266+
// Angle brackets / punctuation
267+
Regex("</?|/>|>").findAll(s).forEach {
268+
color(text, it.range.first, it.range.last + 1, punctuationColor())
269+
}
270+
}
271+
}
272+
273+
class PropSyntaxHighlighter(editText: EditText) : BaseSyntaxHighlighter(
274+
editText = editText,
275+
keywordColor = 0xFF3D8BFF.toInt(),
276+
builtinColor = 0xFFFFA000.toInt(),
277+
stringColor = 0xFF2EAD4B.toInt(),
278+
commentColor = 0xFF8A8A8A.toInt(),
279+
numberColor = 0xFFAA66CC.toInt(),
280+
punctuationColor = 0xFF808080.toInt(),
281+
) {
282+
override fun applyHighlight(text: Editable) {
283+
clearSpans(text)
284+
val s = text.toString()
285+
286+
// Comments
287+
Regex("(?m)^\s*[#!].*$").findAll(s).forEach {
288+
color(text, it.range.first, it.range.last + 1, commentColor())
289+
}
290+
291+
// Key = value
292+
Regex("(?m)^\s*([A-Za-z0-9_.-]+)(\s*=)").findAll(s).forEach {
293+
val line = it.value
294+
val keyEnd = line.indexOf('=')
295+
if (keyEnd > 0) {
296+
color(text, it.range.first, it.range.first + keyEnd, keywordColor())
297+
color(text, it.range.first + keyEnd, it.range.first + keyEnd + 1, punctuationColor())
298+
bold(text, it.range.first, it.range.first + keyEnd)
299+
}
300+
}
301+
302+
// Quoted values
303+
Regex("\"(?:\\.|[^\"])*\"").findAll(s).forEach {
304+
color(text, it.range.first, it.range.last + 1, stringColor())
305+
}
306+
307+
// Numbers
308+
Regex("(?<![A-Za-z0-9_])(?:0x[0-9A-Fa-f]+|[0-9]+)(?![A-Za-z0-9_])").findAll(s).forEach {
309+
color(text, it.range.first, it.range.last + 1, numberColor())
310+
}
311+
312+
// Separators
313+
Regex("=").findAll(s).forEach {
314+
color(text, it.range.first, it.range.last + 1, punctuationColor())
315+
}
316+
}
317+
}

0 commit comments

Comments
 (0)