From 0119c7cb0ea3cf692aca7442e794d912211305d0 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 02:14:25 +0000 Subject: [PATCH] Add expandable terminal reading summaries --- .../neatstudio/tmuxandroid/MainActivity.java | 20 ++- .../tmuxandroid/TerminalScreenBuffer.java | 121 ++++++++++++++---- 2 files changed, 114 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java index 7b98ad0..7618098 100644 --- a/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java +++ b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java @@ -23,6 +23,7 @@ import android.os.Looper; import android.provider.Settings; import android.text.TextUtils; import android.text.InputType; +import android.text.method.LinkMovementMethod; import android.view.Gravity; import android.view.HapticFeedbackConstants; import android.view.KeyEvent; @@ -146,6 +147,7 @@ public final class MainActivity extends Activity { private int lastActiveSessionCount = -1; private TerminalScreenBuffer terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS); private final StringBuilder queuedTerminalInput = new StringBuilder(); + private final Set terminalExpandedBlocks = new HashSet<>(); private boolean terminalConnected; private boolean terminalConnecting; private boolean terminalRenderPending; @@ -1603,6 +1605,7 @@ public final class MainActivity extends Activity { terminalSelectionEnabled = false; terminalFollowOutput = true; terminalKeyPage = 0; + terminalExpandedBlocks.clear(); terminalImagePath = ""; terminalImagePreviewBar = null; terminalImagePreview = null; @@ -1739,6 +1742,7 @@ public final class MainActivity extends Activity { terminalReadingMode = reading; if (terminalText != null) { terminalText.setHorizontallyScrolling(!reading); + terminalText.setMovementMethod(reading ? LinkMovementMethod.getInstance() : null); } styleTerminalReadingButton(); renderTerminalNow(); @@ -3293,9 +3297,19 @@ public final class MainActivity extends Activity { return; } lastTerminalRenderMs = System.currentTimeMillis(); - terminalText.setText(terminalReadingMode - ? terminalScreen.renderFocused() - : terminalScreen.render()); + if (terminalReadingMode) { + terminalText.setMovementMethod(LinkMovementMethod.getInstance()); + terminalText.setHighlightColor(Color.TRANSPARENT); + terminalText.setText(terminalScreen.renderFocused(terminalExpandedBlocks, key -> { + if (!terminalExpandedBlocks.add(key)) { + terminalExpandedBlocks.remove(key); + } + renderTerminalNow(); + })); + } else { + terminalText.setMovementMethod(null); + terminalText.setText(terminalScreen.render()); + } if (terminalFollowOutput) { terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); } diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/TerminalScreenBuffer.java b/app/src/main/java/com/neatstudio/tmuxandroid/TerminalScreenBuffer.java index 38f9afa..f832ac7 100644 --- a/app/src/main/java/com/neatstudio/tmuxandroid/TerminalScreenBuffer.java +++ b/app/src/main/java/com/neatstudio/tmuxandroid/TerminalScreenBuffer.java @@ -4,12 +4,17 @@ import android.graphics.Color; import android.graphics.Typeface; import android.text.SpannableStringBuilder; import android.text.Spanned; +import android.text.TextPaint; import android.text.style.BackgroundColorSpan; +import android.text.style.ClickableSpan; import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; +import android.view.View; import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.Set; final class TerminalScreenBuffer { private static final int DEFAULT_FG = 0xffe6ebf2; @@ -30,6 +35,10 @@ final class TerminalScreenBuffer { private boolean bold; private boolean dim; + interface FocusToggle { + void toggle(String key); + } + TerminalScreenBuffer(int cols, int rows) { resize(cols, rows); } @@ -121,42 +130,106 @@ final class TerminalScreenBuffer { return output; } - CharSequence renderFocused() { - List visible = new ArrayList<>(); - int hiddenRows = 0; + CharSequence renderFocused(Set expandedBlocks, FocusToggle toggle) { + SpannableStringBuilder output = new SpannableStringBuilder(); + List hiddenRows = new ArrayList<>(); + int hiddenStart = -1; for (int row = 0; row < rows; row++) { String text = rowText(row).trim(); if (containsHan(text)) { - if (hiddenRows > 0) { - visible.add("... 已折叠 " + hiddenRows + " 行终端内容 ..."); - hiddenRows = 0; + if (!hiddenRows.isEmpty()) { + appendFocusBlock(output, hiddenRows, hiddenStart, row - 1, expandedBlocks, toggle); + hiddenRows.clear(); + hiddenStart = -1; } - visible.add(text); + appendLine(output, text); } else if (!text.isEmpty()) { - hiddenRows++; - } - } - if (hiddenRows > 0) { - visible.add("... 已折叠 " + hiddenRows + " 行终端内容 ..."); - } - if (visible.isEmpty()) { - for (int row = Math.max(0, rows - 4); row < rows; row++) { - String text = rowText(row).trim(); - if (!text.isEmpty()) { - visible.add(text); + if (hiddenStart < 0) { + hiddenStart = row; } + hiddenRows.add(text); } } - StringBuilder output = new StringBuilder(); - for (int index = 0; index < visible.size(); index++) { - if (index > 0) { - output.append('\n'); - } - output.append(visible.get(index)); + if (!hiddenRows.isEmpty()) { + appendFocusBlock(output, hiddenRows, hiddenStart, rows - 1, expandedBlocks, toggle); + } + if (output.length() == 0) { + output.append("暂无可读内容"); } return output; } + private void appendFocusBlock( + SpannableStringBuilder output, + List lines, + int startRow, + int endRow, + Set expandedBlocks, + FocusToggle toggle + ) { + String key = startRow + ":" + endRow; + boolean expanded = expandedBlocks.contains(key); + String summary = focusSummary(lines); + int actionStart = output.length(); + appendLine(output, (expanded ? "▼ " : "▶ ") + summary); + int actionEnd = output.length(); + output.setSpan(new ClickableSpan() { + @Override + public void onClick(View widget) { + toggle.toggle(key); + } + + @Override + public void updateDrawState(TextPaint paint) { + paint.setColor(0xff67da91); + paint.setUnderlineText(false); + paint.setFakeBoldText(true); + } + }, actionStart, actionEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + if (expanded) { + for (String line : lines) { + appendLine(output, " " + line); + } + } + } + + private String focusSummary(List lines) { + String joined = String.join(" ", lines).toLowerCase(Locale.ROOT); + String type; + if (joined.contains("error") || joined.contains("failed") || joined.contains("exception")) { + type = "错误输出"; + } else if (joined.contains("working") || joined.contains("running") + || joined.contains("waiting") || joined.contains("interrupt")) { + type = "运行状态"; + } else if (joined.contains("test") || joined.contains("build") || joined.contains("compile") + || joined.contains("gradle") || joined.contains("webpack") || joined.contains("npm")) { + type = "构建/测试"; + } else if (joined.contains("git ") || joined.contains("commit") || joined.contains("push")) { + type = "Git 操作"; + } else if (lines.get(0).startsWith(">") || lines.get(0).startsWith("$") + || lines.get(0).startsWith("!")) { + type = "命令与输出"; + } else { + type = "终端细节"; + } + return type + " · " + lines.size() + " 行 · " + compactSummary(lines.get(0), 42); + } + + private String compactSummary(String text, int maxChars) { + String compact = text.replaceAll("\\s+", " ").trim(); + if (compact.length() <= maxChars) { + return compact; + } + return compact.substring(0, maxChars - 1) + "…"; + } + + private void appendLine(SpannableStringBuilder output, String text) { + if (output.length() > 0) { + output.append('\n'); + } + output.append(text); + } + private int handleEscape(String text, int index) { if (index + 1 >= text.length()) { return -1;