feat(maestro): add Android terminal pane composable
TerminalWebView wraps a WebView around the bundled xterm.js page, with a JS bridge (maestroKey/maestroToggleMod/maestroFocus) the native key bar drives; onModifierConsumed reports back when the page's own JS folds an armed Ctrl/Alt into a soft-keyboard keystroke, so the native toggle stays in sync. TerminalScreen composes the WebView with the key bar, showing the bar only while WindowInsets.isImeVisible.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.isImeVisible
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import pt.naps.maestro.data.Uuid
|
||||
|
||||
/**
|
||||
* Full-screen terminal pane for one session (issue #266): a [TerminalWebView]
|
||||
* showing xterm.js bound to `WS /api/attach/:id`, plus the native
|
||||
* [TerminalKeyBar] overlaid above the soft keyboard while it's open.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun TerminalScreen(
|
||||
daemonBaseUrl: String,
|
||||
sessionId: Uuid,
|
||||
sessionTitle: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pageUrl = remember(daemonBaseUrl, sessionId) { TerminalPageUrl.build(daemonBaseUrl, sessionId) }
|
||||
var controller by remember { mutableStateOf<TerminalWebViewController?>(null) }
|
||||
var armed by remember { mutableStateOf(emptySet<String>()) }
|
||||
val keyboardOpen = WindowInsets.isImeVisible
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(sessionTitle) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Text(text = "←", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
TerminalWebView(
|
||||
pageUrl = pageUrl,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxSize(),
|
||||
onReady = { controller = it },
|
||||
onModifierConsumed = { name -> armed = armed - name },
|
||||
)
|
||||
if (keyboardOpen) {
|
||||
TerminalKeyBar(
|
||||
armed = armed,
|
||||
onSend = { key ->
|
||||
controller?.sendKey(key)
|
||||
armed = emptySet()
|
||||
},
|
||||
onToggleModifier = { name ->
|
||||
armed = if (name in armed) armed - name else armed + name
|
||||
controller?.toggleModifier(name)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Matches `#121419` in `assets/terminal/index.html` and the web SPA's own
|
||||
* xterm theme, so the WebView doesn't flash white while the page loads. */
|
||||
private const val TERMINAL_BACKGROUND_ARGB = 0xFF121419.toInt()
|
||||
|
||||
/**
|
||||
* Drives the terminal page loaded into a [TerminalWebView] from native code
|
||||
* — the extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles. The page
|
||||
* itself owns the `WS /api/attach/:id` socket and the xterm.js instance;
|
||||
* this controller only calls the `maestro*` bridge functions it exposes.
|
||||
*/
|
||||
class TerminalWebViewController internal constructor(private val webView: WebView) {
|
||||
fun sendKey(key: String) {
|
||||
val arg = JSONObject.quote(key)
|
||||
webView.evaluateJavascript("window.maestroKey && window.maestroKey($arg)", null)
|
||||
}
|
||||
|
||||
fun toggleModifier(name: String) {
|
||||
val arg = JSONObject.quote(name)
|
||||
webView.evaluateJavascript("window.maestroToggleMod && window.maestroToggleMod($arg)", null)
|
||||
}
|
||||
|
||||
fun focus() {
|
||||
webView.requestFocus()
|
||||
webView.evaluateJavascript("window.maestroFocus && window.maestroFocus()", null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal pane composable for issue #266: a [WebView] loading the bundled
|
||||
* xterm.js page (`assets/terminal/index.html`), which connects straight to
|
||||
* the daemon's `WS /api/attach/:id` for this session. [onModifierConsumed]
|
||||
* fires when the page's own JS folds an armed Ctrl/Alt into a key typed on
|
||||
* the soft keyboard, so the native key bar can clear its toggle to match.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun TerminalWebView(
|
||||
pageUrl: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onReady: (TerminalWebViewController) -> Unit = {},
|
||||
onModifierConsumed: (String) -> Unit = {},
|
||||
) {
|
||||
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
setBackgroundColor(TERMINAL_BACKGROUND_ARGB)
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@JavascriptInterface
|
||||
fun onModConsumed(name: String) {
|
||||
mainHandler.post { onModifierConsumed(name) }
|
||||
}
|
||||
},
|
||||
"AndroidBridge",
|
||||
)
|
||||
onReady(TerminalWebViewController(this))
|
||||
loadUrl(pageUrl)
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
if (webView.url != pageUrl) webView.loadUrl(pageUrl)
|
||||
},
|
||||
onRelease = { it.destroy() },
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user