Compare commits

...
2 Commits
Author SHA1 Message Date
Codex db306679d7 Polish native controls
Gitea Smoke / smoke (push) Successful in 1s
Gitea Android APK / build (push) Successful in 11m43s
2026-07-06 09:34:39 +00:00
Codex 19837d05bc Improve terminal rendering and keyboard insets
Gitea Android APK / build (push) Failing after 1s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-06 08:48:42 +00:00
2 changed files with 179 additions and 19 deletions
+5 -4
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`,
@@ -14,15 +14,23 @@ import android.graphics.Bitmap;
import android.graphics.BitmapFactory; import android.graphics.BitmapFactory;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.Typeface; import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.StateListDrawable;
import android.net.Uri; 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 +107,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() {
@@ -131,8 +140,8 @@ public final class MainActivity extends Activity {
statusText.setTextColor(Color.rgb(210, 215, 224)); statusText.setTextColor(Color.rgb(210, 215, 224));
statusText.setTextSize(12); statusText.setTextSize(12);
statusText.setGravity(Gravity.CENTER_VERTICAL); statusText.setGravity(Gravity.CENTER_VERTICAL);
statusText.setPadding(dp(8), 0, dp(8), 0); statusText.setPadding(dp(10), 0, dp(10), 0);
statusText.setBackgroundColor(Color.rgb(29, 34, 41)); statusText.setBackground(rounded(Color.rgb(22, 27, 34), 0, Color.TRANSPARENT, 0));
statusText.setSingleLine(true); statusText.setSingleLine(true);
setStatus("Ready"); setStatus("Ready");
applySystemBarInsets(root); applySystemBarInsets(root);
@@ -141,11 +150,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;
}); });
@@ -203,6 +216,7 @@ public final class MainActivity extends Activity {
urlField.setImeOptions(EditorInfo.IME_ACTION_GO); urlField.setImeOptions(EditorInfo.IME_ACTION_GO);
urlField.setText(getServerUrl()); urlField.setText(getServerUrl());
urlField.setSelectAllOnFocus(true); urlField.setSelectAllOnFocus(true);
styleInput(urlField);
urlField.setOnEditorActionListener((view, actionId, event) -> { urlField.setOnEditorActionListener((view, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_GO) { if (actionId == EditorInfo.IME_ACTION_GO) {
saveServerAndRefresh(); saveServerAndRefresh();
@@ -303,8 +317,11 @@ public final class MainActivity extends Activity {
private View sessionRow(SessionSummary session) { private View sessionRow(SessionSummary session) {
LinearLayout row = new LinearLayout(this); LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.VERTICAL); row.setOrientation(LinearLayout.VERTICAL);
row.setPadding(dp(10), dp(10), dp(10), dp(10)); row.setPadding(dp(12), dp(11), dp(12), dp(11));
row.setBackgroundColor(Color.rgb(29, 34, 41)); row.setBackground(rounded(Color.rgb(27, 33, 40), 8, Color.rgb(45, 54, 64), 1));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
row.setElevation(dp(1));
}
TextView title = new TextView(this); TextView title = new TextView(this);
title.setText(session.name); title.setText(session.name);
@@ -394,13 +411,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
@@ -700,6 +722,7 @@ public final class MainActivity extends Activity {
inputField.setHint("type command or text"); inputField.setHint("type command or text");
inputField.setImeOptions(EditorInfo.IME_ACTION_SEND); inputField.setImeOptions(EditorInfo.IME_ACTION_SEND);
inputField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); inputField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
styleInput(inputField);
inputField.setOnEditorActionListener((view, actionId, event) -> { inputField.setOnEditorActionListener((view, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_SEND) { if (actionId == EditorInfo.IME_ACTION_SEND) {
sendLine(); sendLine();
@@ -1196,6 +1219,7 @@ public final class MainActivity extends Activity {
input.setSingleLine(true); input.setSingleLine(true);
input.setText(value); input.setText(value);
input.setSelectAllOnFocus(true); input.setSelectAllOnFocus(true);
styleInput(input);
form.addView(title); form.addView(title);
form.addView(input, new LinearLayout.LayoutParams( form.addView(input, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
@@ -1330,14 +1354,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() {
@@ -1385,11 +1513,15 @@ public final class MainActivity extends Activity {
private Button toolbarButton(String label, View.OnClickListener listener) { private Button toolbarButton(String label, View.OnClickListener listener) {
Button button = new Button(this); Button button = new Button(this);
button.setText(label); button.setText(label);
button.setTextColor(Color.rgb(235, 241, 248));
button.setTextSize(12); button.setTextSize(12);
button.setAllCaps(false); button.setAllCaps(false);
button.setTypeface(Typeface.DEFAULT_BOLD);
button.setMinWidth(0); button.setMinWidth(0);
button.setMinimumWidth(0); button.setMinimumWidth(0);
button.setPadding(dp(8), 0, dp(8), 0); button.setPadding(dp(10), 0, dp(10), 0);
button.setBackground(buttonBackground());
button.setGravity(Gravity.CENTER);
button.setOnClickListener(listener); button.setOnClickListener(listener);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT,
@@ -1400,6 +1532,33 @@ public final class MainActivity extends Activity {
return button; return button;
} }
private void styleInput(EditText input) {
input.setTextColor(Color.rgb(240, 246, 252));
input.setHintTextColor(Color.rgb(139, 148, 158));
input.setTextSize(14);
input.setSingleLine(true);
input.setPadding(dp(10), 0, dp(10), 0);
input.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(48, 58, 70), 1));
}
private StateListDrawable buttonBackground() {
StateListDrawable states = new StateListDrawable();
states.addState(new int[]{android.R.attr.state_pressed}, rounded(Color.rgb(64, 78, 94), 8, Color.rgb(91, 108, 128), 1));
states.addState(new int[]{android.R.attr.state_focused}, rounded(Color.rgb(48, 61, 76), 8, Color.rgb(98, 128, 164), 1));
states.addState(new int[]{}, rounded(Color.rgb(34, 43, 53), 8, Color.rgb(55, 66, 80), 1));
return states;
}
private GradientDrawable rounded(int color, int radiusDp, int strokeColor, int strokeDp) {
GradientDrawable drawable = new GradientDrawable();
drawable.setColor(color);
drawable.setCornerRadius(dp(radiusDp));
if (strokeDp > 0) {
drawable.setStroke(dp(strokeDp), strokeColor);
}
return drawable;
}
private void addSoftKey(LinearLayout row, String label, String sequence) { private void addSoftKey(LinearLayout row, String label, String sequence) {
addSoftButton(row, label, view -> sendTerminalInput(sequence)); addSoftButton(row, label, view -> sendTerminalInput(sequence));
} }