@@ -5,15 +5,19 @@ import android.content.ClipData
55import android.content.ClipboardManager
66import android.content.Context
77import android.content.DialogInterface
8+ import android.graphics.Color
89import android.os.Build
910import android.os.Bundle
1011import android.os.Message
1112import android.text.Editable
1213import android.text.SpannableString
1314import android.text.SpannableStringBuilder
1415import android.text.Spanned
16+ import android.text.TextPaint
1517import android.text.method.LinkMovementMethod
18+ import android.text.style.ClickableSpan
1619import android.text.style.ForegroundColorSpan
20+ import android.text.style.UnderlineSpan
1721import android.view.KeyEvent
1822import android.view.LayoutInflater
1923import android.view.View
@@ -313,6 +317,25 @@ class DialogLogFragment : DialogFragment() {
313317 fun onCompleted ()
314318 }
315319
320+ /* *
321+ * Span dùng để hiển thị 1 đáp án của "choose:[...]" dưới dạng link có thể ấn NGAY TRONG
322+ * log (tương tự URLSpan cho hyperlink), bên cạnh cách chọn bằng nút bấm đã có sẵn.
323+ * Không giữ tham chiếu tới View/Editable - chỉ gọi callback [onTap] với giá trị tương ứng,
324+ * để nơi gọi (onChooseRequest) tự quyết định phải làm gì tiếp theo (ghi stdin, cập nhật
325+ * giao diện...). Nhờ vậy có thể tái sử dụng đúng 1 luồng xử lý dùng chung cho cả nút bấm
326+ * lẫn link trong log.
327+ */
328+ private class ChoiceSpan (val value : String , private val onTap : (String ) -> Unit ) : ClickableSpan() {
329+ override fun onClick (widget : View ) {
330+ onTap(value)
331+ }
332+
333+ override fun updateDrawState (ds : TextPaint ) {
334+ super .updateDrawState(ds)
335+ ds.isUnderlineText = true
336+ }
337+ }
338+
316339 class MyShellHandler (
317340 context : Context ,
318341 private var actionEventHandler : IActionEventHandler ? ,
@@ -335,6 +358,14 @@ class DialogLogFragment : DialogFragment() {
335358 private val basicColor = getColor(R .color.kr_shell_log_basic)
336359 private val scriptColor = getColor(R .color.kr_shell_log_script)
337360 private val endColor = getColor(R .color.kr_shell_log_end)
361+
362+ // Màu cho các đáp án "choose:[...]" hiển thị dạng link ngay trong log (giống URL):
363+ // - choiceLinkColor: màu đáp án còn có thể ấn (chưa trả lời)
364+ // - choiceAnsweredColor: màu đáp án ĐÃ chọn, dùng để đánh dấu kết quả
365+ // - choiceDisabledColor: màu các đáp án còn lại (không được chọn) sau khi đã trả lời
366+ private val choiceLinkColor = Color .parseColor(" #4FC3F7" )
367+ private val choiceAnsweredColor = Color .parseColor(" #7CFC00" )
368+ private val choiceDisabledColor = Color .parseColor(" #808080" )
338369 private var hasError = false
339370 private var lineCount = 0
340371
@@ -449,6 +480,34 @@ class DialogLogFragment : DialogFragment() {
449480 val minComfortableWidthPx = dpToPx(40f )
450481 val buttonHeightPx = dpToPx(40f )
451482
483+ // Người dùng có 2 cách trả lời: ấn nút bên dưới, HOẶC ấn thẳng vào đáp án hiển thị
484+ // dạng link ngay trong log. Cờ này đảm bảo dù chọn cách nào trước, đáp án cũng chỉ
485+ // được gửi (writeInput) đúng 1 lần, và cách còn lại sẽ được vô hiệu hoá/cập nhật
486+ // giao diện ngay sau đó.
487+ val answered = AtomicBoolean (false )
488+ val choiceSpans = mutableListOf<ChoiceSpan >()
489+
490+ fun onAnswered (value : String ) {
491+ if (! answered.compareAndSet(false , true )) return
492+ writeInput(value)
493+ row.visibility = View .GONE
494+ container.removeAllViews()
495+
496+ // Vô hiệu hoá + tô lại màu các link trong log: đáp án vừa chọn được tô sáng,
497+ // các đáp án còn lại chuyển màu xám (không còn ấn được vì đã gỡ ClickableSpan).
498+ val editable = logViewRef.get()?.editableText ? : return
499+ for (span in choiceSpans) {
500+ val start = editable.getSpanStart(span)
501+ val end = editable.getSpanEnd(span)
502+ if (start < 0 || end < 0 ) continue
503+ editable.removeSpan(span)
504+ editable.setSpan(
505+ ForegroundColorSpan (if (span.value == value) choiceAnsweredColor else choiceDisabledColor),
506+ start, end, Spanned .SPAN_EXCLUSIVE_EXCLUSIVE
507+ )
508+ }
509+ }
510+
452511 fun buildButtons (availableWidth : Int ) {
453512 inputRowRef.get()?.visibility = View .GONE
454513 container.removeAllViews()
@@ -488,9 +547,7 @@ class DialogLogFragment : DialogFragment() {
488547 if (index < count - 1 ) it.marginEnd = gapPx
489548 }
490549 setOnClickListener {
491- writeInput(option.value)
492- row.visibility = View .GONE
493- container.removeAllViews()
550+ onAnswered(option.value)
494551 }
495552 }
496553 container.addView(button)
@@ -525,6 +582,81 @@ class DialogLogFragment : DialogFragment() {
525582 // có lượt layout nào được lên lịch (ví dụ view vừa addView() xong).
526583 measureView.requestLayout()
527584 }
585+
586+ // Đồng thời chèn các đáp án dạng link ngay trong log (giống hyperlink URL), đi qua
587+ // ĐÚNG cơ chế dispatchLogUpdate() như mọi dòng log khác, để logBuffer/uiAppliedLength
588+ // luôn nhất quán - không thao tác trực tiếp lên editableText ở đây.
589+ val builder = SpannableStringBuilder ()
590+ builder.append(' \n ' )
591+ for (option in options) {
592+ val start = builder.length
593+ builder.append(option.label)
594+ val end = builder.length
595+ val span = ChoiceSpan (option.value) { value -> onAnswered(value) }
596+ choiceSpans.add(span)
597+ builder.setSpan(span, start, end, Spanned .SPAN_EXCLUSIVE_EXCLUSIVE )
598+ builder.setSpan(ForegroundColorSpan (choiceLinkColor), start, end, Spanned .SPAN_EXCLUSIVE_EXCLUSIVE )
599+ builder.setSpan(UnderlineSpan (), start, end, Spanned .SPAN_EXCLUSIVE_EXCLUSIVE )
600+ builder.append(' \n ' )
601+ }
602+ dispatchLogUpdate(builder)
603+ }
604+
605+ /* *
606+ * Hiện các đáp án dưới dạng LINK ngay trong log (không có nút bấm riêng), cho cú pháp
607+ * "pick:[...]" / "pickv:[...]" (dọc) / "pickh:[...]" (ngang). Tách biệt hoàn toàn với
608+ * onChooseRequest() ở trên - không tạo nút, không dùng chooseRow/chooseOptionsContainer.
609+ */
610+ override fun onPickRequest (options : MutableList <ChoiceOption >, vertical : Boolean ) {
611+ val answered = AtomicBoolean (false )
612+ val choiceSpans = mutableListOf<ChoiceSpan >()
613+
614+ fun onAnswered (value : String ) {
615+ if (! answered.compareAndSet(false , true )) return
616+ writeInput(value)
617+
618+ val editable = logViewRef.get()?.editableText ? : return
619+ for (span in choiceSpans) {
620+ val start = editable.getSpanStart(span)
621+ val end = editable.getSpanEnd(span)
622+ if (start < 0 || end < 0 ) continue
623+ editable.removeSpan(span)
624+ editable.setSpan(
625+ ForegroundColorSpan (if (span.value == value) choiceAnsweredColor else choiceDisabledColor),
626+ start, end, Spanned .SPAN_EXCLUSIVE_EXCLUSIVE
627+ )
628+ }
629+ }
630+
631+ fun appendChoice (builder : SpannableStringBuilder , text : String , value : String ) {
632+ val start = builder.length
633+ builder.append(text)
634+ val end = builder.length
635+ val span = ChoiceSpan (value) { v -> onAnswered(v) }
636+ choiceSpans.add(span)
637+ builder.setSpan(span, start, end, Spanned .SPAN_EXCLUSIVE_EXCLUSIVE )
638+ builder.setSpan(ForegroundColorSpan (choiceLinkColor), start, end, Spanned .SPAN_EXCLUSIVE_EXCLUSIVE )
639+ builder.setSpan(UnderlineSpan (), start, end, Spanned .SPAN_EXCLUSIVE_EXCLUSIVE )
640+ }
641+
642+ val builder = SpannableStringBuilder ()
643+ builder.append(' \n ' )
644+ if (vertical) {
645+ // Xếp DỌC: mỗi đáp án 1 dòng, dạng "1. Nhãn", cách nhau 1 dòng trống để tạo
646+ // vùng chạm đủ lớn trên di động. Toàn bộ "1. Nhãn" (kể cả số thứ tự) đều bấm được.
647+ options.forEachIndexed { index, option ->
648+ appendChoice(builder, " ${index + 1 } . ${option.label} " , option.value)
649+ builder.append(" \n\n " )
650+ }
651+ } else {
652+ // Xếp NGANG: dạng "[ Nhãn ]" nối cạnh nhau trên cùng 1 dòng.
653+ options.forEachIndexed { index, option ->
654+ appendChoice(builder, " [ ${option.label} ]" , option.value)
655+ if (index != options.lastIndex) builder.append(" " )
656+ }
657+ builder.append(' \n ' )
658+ }
659+ dispatchLogUpdate(builder)
528660 }
529661
530662 override fun handleMessage (msg : Message ) {
@@ -621,20 +753,60 @@ class DialogLogFragment : DialogFragment() {
621753 private fun updateLogWithColor (text : String , forcedColor : Int? ) {
622754 var parsedLog: CharSequence = AnsiColorParser .parse(text)
623755
624- // Lệnh `clear` trong script sẽ phát ra mã CSI xoá màn hình (ESC[2J/ESC[3J), được
625- // AnsiColorParser nhận diện ở trên. Xoá sạch logBuffer ngay tại đây (background
626- // thread) và đánh dấu invalid từ vị trí 0 để lần flushToUi() kế tiếp thay thế TOÀN
627- // BỘ nội dung cũ đang hiển thị bằng nội dung mới (thường là rỗng), giống hệt cách
628- // xử lý ghi đè do '\r' - không cần đụng trực tiếp vào UI thread, tránh race với
629- // pendingUiUpdate/flushToUi.
630- if (AnsiColorParser .consumePendingClear()) {
631- synchronized(logBuffer) {
632- logBuffer.clear()
633- lineCount = 0
634- lineStart = 0
635- pendingOverwrite = false
636- markInvalidFrom(0 )
756+ // Lệnh `clear` (hoặc chương trình vẽ lại 1 phần màn hình như progress bar) sẽ phát ra
757+ // mã CSI "Erase in Display" (ESC[nJ), được AnsiColorParser nhận diện ở trên. Vì log
758+ // buffer ở đây chỉ append tuần tự (không theo dõi toạ độ cursor thật như terminal),
759+ // ta xấp xỉ "vị trí cursor" như sau:
760+ // - Nếu vừa gặp '\r' (pendingOverwrite = true, đang chờ ghi đè dòng hiện tại) -> cursor
761+ // coi như đang ở ĐẦU dòng hiện tại (lineStart).
762+ // - Ngược lại (append bình thường, chưa ghi đè gì) -> cursor coi như đang ở CUỐI buffer.
763+ // Xấp xỉ này khớp chính xác với các chương trình dùng '\r' để vẽ lại dòng (progress
764+ // bar, spinner...), nhưng sẽ không chính xác nếu chương trình dùng CUP (ESC[r;cH) để
765+ // di chuyển cursor tự do - các mã di chuyển cursor này hiện chưa được theo dõi.
766+ when (AnsiColorParser .consumePendingErase()) {
767+ 2 , 3 -> {
768+ // Xoá toàn bộ màn hình (lệnh `clear`)
769+ synchronized(logBuffer) {
770+ logBuffer.clear()
771+ lineCount = 0
772+ lineStart = 0
773+ pendingOverwrite = false
774+ markInvalidFrom(0 )
775+ }
776+ }
777+ 0 -> {
778+ // Xoá từ cursor (xấp xỉ) tới hết buffer
779+ synchronized(logBuffer) {
780+ val cursorPos = (if (pendingOverwrite) lineStart else logBuffer.length)
781+ .coerceIn(0 , logBuffer.length)
782+ if (cursorPos < logBuffer.length) {
783+ logBuffer.delete(cursorPos, logBuffer.length)
784+ markInvalidFrom(cursorPos)
785+ }
786+ pendingOverwrite = false
787+ }
788+ }
789+ 1 -> {
790+ // Xoá từ đầu buffer tới cursor (xấp xỉ). Vì phần đầu bị xoá, mọi toạ độ phía
791+ // sau đều dịch chuyển - xử lý tương tự logic "cắt tỉa 5000 dòng" bên dưới.
792+ synchronized(logBuffer) {
793+ val cursorPos = (if (pendingOverwrite) lineStart else logBuffer.length)
794+ .coerceIn(0 , logBuffer.length)
795+ if (cursorPos > 0 ) {
796+ var removedLines = 0
797+ for (k in 0 until cursorPos) {
798+ if (logBuffer[k] == ' \n ' ) removedLines++
799+ }
800+ logBuffer.delete(0 , cursorPos)
801+ lineCount = (lineCount - removedLines).coerceAtLeast(0 )
802+ lineStart = (lineStart - cursorPos).coerceAtLeast(0 )
803+ uiAppliedLength = (uiAppliedLength - cursorPos).coerceAtLeast(0 )
804+ uiInvalidFrom = 0
805+ }
806+ pendingOverwrite = false
807+ }
637808 }
809+ else -> {}
638810 }
639811
640812 if (forcedColor != null && ! text.contains(" \u001B [" )) {
0 commit comments