Improve terminal rendering and keyboard insets
Some checks failed
Gitea Android APK / build (push) Failing after 1s
Gitea Smoke / smoke (push) Successful in 0s

This commit is contained in:
Codex 2026-07-06 08:48:42 +00:00
parent 73cc8c8181
commit 19837d05bc
2 changed files with 135 additions and 14 deletions

View File

@ -99,10 +99,11 @@ incompatible package.
The terminal screen connects to `/ws/terminal` and sends the upstream protocol The terminal screen connects to `/ws/terminal` and sends the upstream protocol
messages unchanged: `attach`, `input`, `resize`, `scroll`, and `clear-history`. messages unchanged: `attach`, `input`, `resize`, `scroll`, and `clear-history`.
The first Android UI renders terminal output as basic monospace text with ANSI The first Android UI renders terminal output as monospace text with basic ANSI
escape filtering. It is enough for shell-oriented remote testing, but it is not SGR color support. The terminal view stays bottom-aligned when output is short,
yet a complete xterm-compatible renderer for full-screen TUIs such as `vim` or auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard
`top`. opens. It is enough for shell-oriented remote testing, but it is not yet a
complete xterm-compatible renderer for full-screen TUIs such as `vim` or `top`.
The terminal toolbar and shortcut row include tmux prefix helpers. The app sends The terminal toolbar and shortcut row include tmux prefix helpers. The app sends
the same control bytes a keyboard would send, for example `Ctrl+B`, `Ctrl+B d`, the same control bytes a keyboard would send, for example `Ctrl+B`, `Ctrl+B d`,

View File

@ -18,11 +18,17 @@ import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.provider.Settings; import android.provider.Settings;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.InputType; import android.text.InputType;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.view.Gravity; import android.view.Gravity;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.WindowInsets; import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo; import android.view.inputmethod.EditorInfo;
import android.widget.Button; import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
@ -99,6 +105,7 @@ public final class MainActivity extends Activity {
getWindow().setStatusBarColor(Color.rgb(17, 20, 24)); getWindow().setStatusBarColor(Color.rgb(17, 20, 24));
getWindow().setNavigationBarColor(Color.rgb(17, 20, 24)); getWindow().setNavigationBarColor(Color.rgb(17, 20, 24));
} }
getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
api = new SessionApiClient(getServerUrl()); api = new SessionApiClient(getServerUrl());
setContentView(createRoot()); setContentView(createRoot());
updateManager = new UpdateManager(this, prefs, new UpdateManager.Callback() { updateManager = new UpdateManager(this, prefs, new UpdateManager.Callback() {
@ -141,11 +148,15 @@ public final class MainActivity extends Activity {
private void applySystemBarInsets(View view) { private void applySystemBarInsets(View view) {
view.setOnApplyWindowInsetsListener((target, insets) -> { view.setOnApplyWindowInsetsListener((target, insets) -> {
int bottom = insets.getSystemWindowInsetBottom();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
bottom = Math.max(bottom, insets.getInsets(WindowInsets.Type.ime()).bottom);
}
target.setPadding( target.setPadding(
0, 0,
insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetTop(),
0, 0,
insets.getSystemWindowInsetBottom() bottom
); );
return insets; return insets;
}); });
@ -394,13 +405,18 @@ public final class MainActivity extends Activity {
root.addView(createTerminalTopBar(sessionName), matchWrap()); root.addView(createTerminalTopBar(sessionName), matchWrap());
terminalScroll = new ScrollView(this); terminalScroll = new ScrollView(this);
terminalScroll.setFillViewport(true);
terminalScroll.setBackgroundColor(Color.rgb(4, 7, 10));
terminalText = new TextView(this); terminalText = new TextView(this);
terminalText.setTextColor(Color.rgb(230, 235, 242)); terminalText.setTextColor(Color.rgb(230, 235, 242));
terminalText.setTextSize(12); terminalText.setTextSize(13);
terminalText.setTypeface(Typeface.MONOSPACE); terminalText.setTypeface(Typeface.MONOSPACE);
terminalText.setTextIsSelectable(true); terminalText.setIncludeFontPadding(false);
terminalText.setPadding(dp(8), dp(8), dp(8), dp(8)); terminalText.setLineSpacing(0, 1.05f);
terminalText.setBackgroundColor(Color.BLACK); terminalText.setGravity(Gravity.BOTTOM | Gravity.START);
terminalText.setTextIsSelectable(false);
terminalText.setPadding(dp(10), dp(10), dp(10), dp(10));
terminalText.setBackgroundColor(Color.rgb(4, 7, 10));
terminalScroll.addView(terminalText, new ScrollView.LayoutParams( terminalScroll.addView(terminalText, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
@ -1330,14 +1346,118 @@ public final class MainActivity extends Activity {
if (terminalBuffer.length() > MAX_TERMINAL_CHARS) { if (terminalBuffer.length() > MAX_TERMINAL_CHARS) {
terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS); terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS);
} }
terminalText.setText(stripAnsiForBasicView(terminalBuffer.toString())); terminalText.setText(renderAnsiForTerminal(terminalBuffer.toString()));
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
} }
private String stripAnsiForBasicView(String text) { private CharSequence renderAnsiForTerminal(String text) {
return text SpannableStringBuilder output = new SpannableStringBuilder();
.replaceAll("\u001B\\[[0-?]*[ -/]*[@-~]", "") int fg = Color.rgb(230, 235, 242);
.replace("\r", ""); int bg = Color.TRANSPARENT;
boolean bold = false;
int index = 0;
while (index < text.length()) {
char item = text.charAt(index);
if (item == '\r') {
index++;
continue;
}
if (item == '\u001b' && index + 1 < text.length() && text.charAt(index + 1) == '[') {
int end = findAnsiEnd(text, index + 2);
if (end == -1) {
break;
}
char command = text.charAt(end);
if (command == 'm') {
int[] state = applySgr(text.substring(index + 2, end), fg, bg, bold);
fg = state[0];
bg = state[1];
bold = state[2] == 1;
}
index = end + 1;
continue;
}
int start = output.length();
output.append(item);
int finish = output.length();
output.setSpan(new ForegroundColorSpan(fg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (bg != Color.TRANSPARENT) {
output.setSpan(new BackgroundColorSpan(bg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (bold) {
output.setSpan(new StyleSpan(Typeface.BOLD), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
index++;
}
return output;
}
private int findAnsiEnd(String text, int start) {
for (int index = start; index < text.length(); index++) {
char item = text.charAt(index);
if (item >= '@' && item <= '~') {
return index;
}
}
return -1;
}
private int[] applySgr(String params, int fg, int bg, boolean bold) {
if (params.isEmpty()) {
params = "0";
}
String[] parts = params.split(";");
for (String part : parts) {
int value;
try {
value = part.isEmpty() ? 0 : Integer.parseInt(part);
} catch (NumberFormatException ignored) {
continue;
}
if (value == 0) {
fg = Color.rgb(230, 235, 242);
bg = Color.TRANSPARENT;
bold = false;
} else if (value == 1) {
bold = true;
} else if (value == 22) {
bold = false;
} else if (value == 39) {
fg = Color.rgb(230, 235, 242);
} else if (value == 49) {
bg = Color.TRANSPARENT;
} else if ((value >= 30 && value <= 37) || (value >= 90 && value <= 97)) {
fg = ansiColor(value, false);
} else if ((value >= 40 && value <= 47) || (value >= 100 && value <= 107)) {
bg = ansiColor(value, true);
}
}
return new int[]{fg, bg, bold ? 1 : 0};
}
private int ansiColor(int code, boolean background) {
int base = background ? (code >= 100 ? code - 100 : code - 40) : (code >= 90 ? code - 90 : code - 30);
boolean bright = code >= 90;
switch (base) {
case 0:
return bright ? Color.rgb(80, 88, 100) : Color.rgb(33, 38, 45);
case 1:
return bright ? Color.rgb(255, 123, 114) : Color.rgb(248, 81, 73);
case 2:
return bright ? Color.rgb(86, 211, 100) : Color.rgb(63, 185, 80);
case 3:
return bright ? Color.rgb(234, 179, 8) : Color.rgb(210, 153, 34);
case 4:
return bright ? Color.rgb(121, 192, 255) : Color.rgb(88, 166, 255);
case 5:
return bright ? Color.rgb(210, 168, 255) : Color.rgb(188, 140, 255);
case 6:
return bright ? Color.rgb(86, 211, 219) : Color.rgb(57, 197, 187);
case 7:
default:
return bright ? Color.rgb(240, 246, 252) : Color.rgb(201, 209, 217);
}
} }
private void saveServerAndRefresh() { private void saveServerAndRefresh() {