@@ -2,20 +2,27 @@ package com.tool.tree
22
33import android.content.Context
44import android.content.Intent
5+ import android.content.res.ColorStateList
56import android.os.Bundle
7+ import android.os.Handler
8+ import android.os.Looper
69import android.text.Editable
710import android.text.TextWatcher
11+ import android.util.TypedValue
12+ import android.view.Gravity
813import android.view.Menu
914import android.view.MenuItem
1015import android.view.View
1116import android.view.ViewGroup
1217import android.widget.HorizontalScrollView
18+ import android.widget.ImageButton
1319import android.widget.Toast
1420import androidx.activity.addCallback
1521import androidx.appcompat.app.AppCompatActivity
1622import androidx.appcompat.widget.Toolbar
1723import androidx.core.view.ViewCompat
1824import androidx.core.view.WindowInsetsCompat
25+ import androidx.core.widget.ImageViewCompat
1926import androidx.lifecycle.lifecycleScope
2027import com.omarea.common.shared.FileWrite
2128import com.omarea.common.shell.KeepShellPublic
@@ -57,6 +64,11 @@ class TextEditorActivity : AppCompatActivity() {
5764 " bash" to " bash"
5865 )
5966
67+ // Số bước Undo tối đa được giữ lại, và thời gian tạm dừng gõ (ms) để "chốt" một bước
68+ // Undo mới — gộp các ký tự gõ liên tục thành một bước duy nhất.
69+ private const val UNDO_HISTORY_LIMIT = 200
70+ private const val UNDO_DEBOUNCE_MS = 700L
71+
6072 fun start (
6173 context : Context ,
6274 file : String ,
@@ -92,6 +104,20 @@ class TextEditorActivity : AppCompatActivity() {
92104 private var syntaxHighlighter: SyntaxHighlighter ? = null
93105 private val progressBarDialog by lazy { ProgressBarDialog (this ) }
94106
107+ // --- Undo / Redo ---
108+ // Lưu lại toàn bộ nội dung tại mỗi "đợt" chỉnh sửa (gộp các ký tự gõ liên tục thành một
109+ // bước, thay vì lưu theo từng ký tự) để nút Undo/Redo hoạt động tự nhiên như trình soạn
110+ // thảo thông thường. EditText mặc định của Android không có sẵn undo/redo công khai nên
111+ // phải tự quản lý 2 stack này.
112+ private val undoStack = ArrayDeque <String >()
113+ private val redoStack = ArrayDeque <String >()
114+ private var pendingUndoSnapshot: String? = null
115+ private var isApplyingHistory = false
116+ private var undoButton: ImageButton ? = null
117+ private var redoButton: ImageButton ? = null
118+ private val undoHandler = Handler (Looper .getMainLooper())
119+ private val commitPendingUndoRunnable = Runnable { commitPendingUndoSnapshot() }
120+
95121 override fun onCreate (savedInstanceState : Bundle ? ) {
96122 super .onCreate(savedInstanceState)
97123 ThemeModeState .switchTheme(this )
@@ -101,9 +127,12 @@ class TextEditorActivity : AppCompatActivity() {
101127
102128 val toolbar = findViewById<View >(R .id.toolbar) as Toolbar
103129 setSupportActionBar(toolbar)
104- supportActionBar?.setHomeButtonEnabled(true )
105- supportActionBar?.setDisplayHomeAsUpEnabled(true )
106- toolbar.setNavigationOnClickListener { attemptClose() }
130+ // Bỏ nút back (mũi tên) trên toolbar theo yêu cầu — việc thoát trang giờ thực hiện qua
131+ // mục "Thoát" trong menu (xem onOptionsItemSelected). Nút back cứng/gesture của hệ
132+ // thống vẫn hoạt động bình thường nhờ onBackPressedDispatcher bên dưới.
133+ supportActionBar?.setHomeButtonEnabled(false )
134+ supportActionBar?.setDisplayHomeAsUpEnabled(false )
135+ setupUndoRedoButtons(toolbar)
107136
108137 onBackPressedDispatcher.addCallback(this ) { attemptClose() }
109138
@@ -124,12 +153,14 @@ class TextEditorActivity : AppCompatActivity() {
124153
125154 applyWrapState()
126155 setupCursorAutoScroll()
156+ setupUndoRedoTracking()
127157 loadFileContent()
128158 }
129159
130160 override fun onDestroy () {
131161 syntaxHighlighter?.detach()
132162 syntaxHighlighter = null
163+ undoHandler.removeCallbacksAndMessages(null )
133164 super .onDestroy()
134165 }
135166
@@ -227,6 +258,142 @@ class TextEditorActivity : AppCompatActivity() {
227258 syntaxHighlighter?.attach()
228259 }
229260
261+ /* *
262+ * Tạo 2 nút Undo/Redo và gắn vào bên trái của toolbar (thay cho vị trí nút back đã bỏ).
263+ */
264+ private fun setupUndoRedoButtons (toolbar : Toolbar ) {
265+ val tint = ColorStateList .valueOf(resolveThemeColor(android.R .attr.textColorPrimary))
266+ val buttonSize = (48 * resources.displayMetrics.density).toInt()
267+ val iconPadding = (12 * resources.displayMetrics.density).toInt()
268+
269+ fun makeButton (iconRes : Int , descRes : Int ): ImageButton {
270+ return ImageButton (this ).apply {
271+ setImageResource(iconRes)
272+ contentDescription = getString(descRes)
273+ setPadding(iconPadding, iconPadding, iconPadding, iconPadding)
274+ val outValue = TypedValue ()
275+ theme.resolveAttribute(
276+ android.R .attr.selectableItemBackgroundBorderless,
277+ outValue,
278+ true
279+ )
280+ setBackgroundResource(outValue.resourceId)
281+ ImageViewCompat .setImageTintList(this , tint)
282+ layoutParams = Toolbar .LayoutParams (buttonSize, buttonSize).apply {
283+ gravity = Gravity .START or Gravity .CENTER_VERTICAL
284+ }
285+ }
286+ }
287+
288+ undoButton = makeButton(R .drawable.ic_editor_undo, R .string.editor_undo).also {
289+ it.setOnClickListener { performUndo() }
290+ toolbar.addView(it)
291+ }
292+ redoButton = makeButton(R .drawable.ic_editor_redo, R .string.editor_redo).also {
293+ it.setOnClickListener { performRedo() }
294+ toolbar.addView(it)
295+ }
296+ refreshUndoRedoButtons()
297+ }
298+
299+ private fun resolveThemeColor (attr : Int ): Int {
300+ val typedValue = TypedValue ()
301+ theme.resolveAttribute(attr, typedValue, true )
302+ return typedValue.data
303+ }
304+
305+ /* *
306+ * Theo dõi nội dung để phục vụ Undo/Redo. Các thay đổi gõ liên tục (không dừng quá
307+ * [UNDO_DEBOUNCE_MS]) được gộp thành một bước, để Undo/Redo hoạt động theo "đợt chỉnh sửa"
308+ * thay vì theo từng ký tự.
309+ */
310+ private fun setupUndoRedoTracking () {
311+ binding.editorContent.addTextChangedListener(object : TextWatcher {
312+ override fun beforeTextChanged (s : CharSequence? , start : Int , count : Int , after : Int ) {
313+ if (isApplyingHistory) return
314+ if (pendingUndoSnapshot == null ) {
315+ pendingUndoSnapshot = s?.toString().orEmpty()
316+ }
317+ }
318+
319+ override fun onTextChanged (s : CharSequence? , start : Int , before : Int , count : Int ) = Unit
320+
321+ override fun afterTextChanged (s : Editable ? ) {
322+ if (isApplyingHistory) return
323+ redoStack.clear()
324+ undoHandler.removeCallbacks(commitPendingUndoRunnable)
325+ undoHandler.postDelayed(commitPendingUndoRunnable, UNDO_DEBOUNCE_MS )
326+ refreshUndoRedoButtons()
327+ }
328+ })
329+ }
330+
331+ private fun commitPendingUndoSnapshot () {
332+ val snapshot = pendingUndoSnapshot ? : return
333+ pendingUndoSnapshot = null
334+ val current = binding.editorContent.text?.toString().orEmpty()
335+ if (snapshot == current) return
336+
337+ if (undoStack.size >= UNDO_HISTORY_LIMIT ) {
338+ undoStack.removeFirst()
339+ }
340+ undoStack.addLast(snapshot)
341+ refreshUndoRedoButtons()
342+ }
343+
344+ private fun performUndo () {
345+ undoHandler.removeCallbacks(commitPendingUndoRunnable)
346+ val current = binding.editorContent.text?.toString().orEmpty()
347+
348+ val target: String
349+ val pending = pendingUndoSnapshot
350+ if (pending != null ) {
351+ // Đang có một đợt gõ chưa kịp "chốt" vào undoStack — hoàn tác thẳng về mốc đó.
352+ target = pending
353+ pendingUndoSnapshot = null
354+ } else {
355+ if (undoStack.isEmpty()) return
356+ target = undoStack.removeLast()
357+ }
358+
359+ redoStack.addLast(current)
360+ applyHistoryText(target)
361+ refreshUndoRedoButtons()
362+ }
363+
364+ private fun performRedo () {
365+ if (redoStack.isEmpty()) return
366+ undoHandler.removeCallbacks(commitPendingUndoRunnable)
367+ pendingUndoSnapshot = null
368+
369+ val current = binding.editorContent.text?.toString().orEmpty()
370+ val target = redoStack.removeLast()
371+
372+ if (undoStack.size >= UNDO_HISTORY_LIMIT ) {
373+ undoStack.removeFirst()
374+ }
375+ undoStack.addLast(current)
376+ applyHistoryText(target)
377+ refreshUndoRedoButtons()
378+ }
379+
380+ private fun applyHistoryText (text : String ) {
381+ isApplyingHistory = true
382+ val editText = binding.editorContent
383+ editText.setText(text)
384+ editText.setSelection(text.length.coerceAtLeast(0 ))
385+ isApplyingHistory = false
386+ }
387+
388+ private fun refreshUndoRedoButtons () {
389+ val canUndo = undoStack.isNotEmpty() || pendingUndoSnapshot != null
390+ val canRedo = redoStack.isNotEmpty()
391+ undoButton?.isEnabled = canUndo
392+ undoButton?.alpha = if (canUndo) 1f else 0.4f
393+ redoButton?.isEnabled = canRedo
394+ redoButton?.alpha = if (canRedo) 1f else 0.4f
395+ }
396+
230397 private fun loadFileContent () {
231398 progressBarDialog.showDialog(getString(R .string.please_wait))
232399 lifecycleScope.launch(Dispatchers .IO ) {
@@ -246,7 +413,14 @@ class TextEditorActivity : AppCompatActivity() {
246413 progressBarDialog.hideDialog()
247414 isNewFile = newFile
248415 savedContent = content
416+ isApplyingHistory = true
249417 binding.editorContent.setText(content)
418+ isApplyingHistory = false
419+ undoHandler.removeCallbacks(commitPendingUndoRunnable)
420+ pendingUndoSnapshot = null
421+ undoStack.clear()
422+ redoStack.clear()
423+ refreshUndoRedoButtons()
250424 binding.editorContent.hint = if (newFile) {
251425 getString(R .string.editor_hint_new_file)
252426 } else {
@@ -307,7 +481,11 @@ class TextEditorActivity : AppCompatActivity() {
307481 // Chỉ đổi setHorizontallyScrolling()/layoutParams thôi thì TextView không tự dựng lại
308482 // Layout nội bộ theo chiều rộng mới (văn bản đã dàn trang trước đó bị giữ nguyên), nên
309483 // phải set lại text để buộc nó dựng lại layout rồi khôi phục vị trí con trỏ.
484+ // Đây không phải là một thao tác chỉnh sửa nội dung thật sự nên không được tính vào
485+ // lịch sử Undo/Redo.
486+ isApplyingHistory = true
310487 editText.setText(currentText)
488+ isApplyingHistory = false
311489 editText.setSelection(
312490 cursorStart.coerceAtMost(currentText.length),
313491 cursorEnd.coerceAtMost(currentText.length)
@@ -366,6 +544,10 @@ class TextEditorActivity : AppCompatActivity() {
366544 applyWrapState()
367545 true
368546 }
547+ R .id.editor_menu_exit -> {
548+ attemptClose()
549+ true
550+ }
369551 android.R .id.home -> {
370552 attemptClose()
371553 true
0 commit comments