Add theme/font CLI options and mobile Safari keyboard support
- Add --theme, --font-family, --font-size CLI options - Pass theme/font config via HTML data attributes to frontend - Add hidden textarea for mobile keyboard input capture - Handle special keys (Enter, Backspace, arrows, Tab) on mobile - Focus textarea on touch/click to trigger mobile keyboard Bump version to 0.5.3
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "textual-webterm"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
description = "Serve terminal sessions over the web"
|
||||
authors = ["Will McGugan <will@textualize.io>"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -116,6 +116,25 @@ def load_app_class(app_path: str):
|
||||
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path),
|
||||
help='Docker compose YAML; services with label "webterm-command" become landing tiles.',
|
||||
)
|
||||
@click.option(
|
||||
"--theme",
|
||||
"-t",
|
||||
help="Terminal color theme (monokai, dark, light, dracula, catppuccin, nord, gruvbox, solarized, tokyo).",
|
||||
default="monokai",
|
||||
)
|
||||
@click.option(
|
||||
"--font-family",
|
||||
"-f",
|
||||
help="Terminal font family (CSS font stack).",
|
||||
default=None,
|
||||
)
|
||||
@click.option(
|
||||
"--font-size",
|
||||
"-s",
|
||||
type=int,
|
||||
help="Terminal font size in pixels.",
|
||||
default=16,
|
||||
)
|
||||
def app(
|
||||
command: str | None,
|
||||
port: int,
|
||||
@@ -123,6 +142,9 @@ def app(
|
||||
app_path: str | None,
|
||||
landing_manifest: Path | None,
|
||||
compose_manifest: Path | None,
|
||||
theme: str,
|
||||
font_family: str | None,
|
||||
font_size: int,
|
||||
) -> None:
|
||||
"""Serve a terminal or Textual app over HTTP/WebSocket.
|
||||
|
||||
@@ -165,6 +187,9 @@ def app(
|
||||
landing_apps=landing_apps,
|
||||
compose_mode=is_compose_mode,
|
||||
compose_project=compose_project,
|
||||
theme=theme,
|
||||
font_family=font_family,
|
||||
font_size=font_size,
|
||||
)
|
||||
for app_entry in landing_apps:
|
||||
server.add_terminal(app_entry.name, app_entry.command, slug=app_entry.slug)
|
||||
|
||||
@@ -124,9 +124,15 @@ class LocalServer:
|
||||
landing_apps: list | None = None,
|
||||
compose_mode: bool = False,
|
||||
compose_project: str | None = None,
|
||||
theme: str = "monokai",
|
||||
font_family: str | None = None,
|
||||
font_size: int = 16,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.theme = theme
|
||||
self.font_family = font_family
|
||||
self.font_size = font_size
|
||||
|
||||
abs_path = Path(config_path).absolute()
|
||||
path = abs_path if abs_path.is_dir() else abs_path.parent
|
||||
@@ -842,6 +848,13 @@ class LocalServer:
|
||||
ws_url = self._get_ws_url_from_request(request, route_key)
|
||||
page_title = available_app.name if available_app else "Textual Web Terminal"
|
||||
|
||||
# Build data attributes for terminal configuration
|
||||
data_attrs = f'data-session-websocket-url="{ws_url}" data-font-size="{self.font_size}" data-scrollback="1000" data-theme="{self.theme}"'
|
||||
if self.font_family:
|
||||
# Escape quotes for HTML attribute
|
||||
escaped_font = self.font_family.replace('"', """)
|
||||
data_attrs += f' data-font-family="{escaped_font}"'
|
||||
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -854,7 +867,7 @@ class LocalServer:
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id=\"terminal\" class=\"textual-terminal\" data-session-websocket-url=\"{ws_url}\" data-font-size=\"16\" data-scrollback=\"1000\"></div>
|
||||
<div id=\"terminal\" class=\"textual-terminal\" {data_attrs}></div>
|
||||
<script type=\"module\" src=\"/static/js/terminal.js\"></script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -292,6 +292,7 @@ class WebTerminal {
|
||||
private reconnectDelay = 1000;
|
||||
private messageQueue: [string, unknown][] = [];
|
||||
private lastValidSize: { cols: number; rows: number } | null = null;
|
||||
private mobileInput: HTMLTextAreaElement | null = null;
|
||||
|
||||
private constructor(
|
||||
container: HTMLElement,
|
||||
@@ -365,10 +366,108 @@ class WebTerminal {
|
||||
}
|
||||
});
|
||||
|
||||
// Setup mobile keyboard support
|
||||
this.setupMobileKeyboard();
|
||||
|
||||
// Connect WebSocket
|
||||
this.connect();
|
||||
}
|
||||
|
||||
/** Setup mobile keyboard input via hidden textarea */
|
||||
private setupMobileKeyboard(): void {
|
||||
// Create hidden textarea for mobile keyboard input
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.setAttribute("autocapitalize", "off");
|
||||
textarea.setAttribute("autocomplete", "off");
|
||||
textarea.setAttribute("autocorrect", "off");
|
||||
textarea.setAttribute("spellcheck", "false");
|
||||
textarea.setAttribute("inputmode", "text");
|
||||
textarea.setAttribute("enterkeyhint", "send");
|
||||
// Style to be invisible but still focusable (not display:none)
|
||||
textarea.style.cssText = `
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
font-size: 16px;
|
||||
`;
|
||||
// Font size 16px prevents iOS auto-zoom on focus
|
||||
|
||||
this.element.style.position = "relative";
|
||||
this.element.appendChild(textarea);
|
||||
this.mobileInput = textarea;
|
||||
|
||||
// Handle input from mobile keyboard
|
||||
textarea.addEventListener("input", () => {
|
||||
const value = textarea.value;
|
||||
if (value) {
|
||||
this.send(["stdin", value]);
|
||||
textarea.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
// Handle special keys via keydown
|
||||
textarea.addEventListener("keydown", (e) => {
|
||||
let seq: string | null = null;
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
seq = "\r";
|
||||
break;
|
||||
case "Backspace":
|
||||
seq = "\x7f";
|
||||
break;
|
||||
case "Escape":
|
||||
seq = "\x1b";
|
||||
break;
|
||||
case "ArrowUp":
|
||||
seq = "\x1b[A";
|
||||
break;
|
||||
case "ArrowDown":
|
||||
seq = "\x1b[B";
|
||||
break;
|
||||
case "ArrowRight":
|
||||
seq = "\x1b[C";
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
seq = "\x1b[D";
|
||||
break;
|
||||
case "Tab":
|
||||
seq = "\t";
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
if (seq) {
|
||||
e.preventDefault();
|
||||
this.send(["stdin", seq]);
|
||||
}
|
||||
});
|
||||
|
||||
// Focus textarea on touch/click to show mobile keyboard
|
||||
this.element.addEventListener("touchstart", () => {
|
||||
this.focusMobileInput();
|
||||
}, { passive: true });
|
||||
|
||||
this.element.addEventListener("click", () => {
|
||||
this.focusMobileInput();
|
||||
});
|
||||
}
|
||||
|
||||
/** Focus the mobile input to show keyboard */
|
||||
private focusMobileInput(): void {
|
||||
if (this.mobileInput) {
|
||||
// Small delay helps with iOS keyboard activation
|
||||
setTimeout(() => {
|
||||
this.mobileInput?.focus({ preventScroll: true });
|
||||
}, 10);
|
||||
}
|
||||
// Also focus the terminal for desktop
|
||||
this.terminal.focus();
|
||||
}
|
||||
|
||||
/** Wait for fonts to be loaded */
|
||||
private async waitForFonts(): Promise<void> {
|
||||
if (!("fonts" in document)) {
|
||||
@@ -507,6 +606,10 @@ class WebTerminal {
|
||||
/** Clean up resources */
|
||||
dispose(): void {
|
||||
this.socket?.close();
|
||||
if (this.mobileInput) {
|
||||
this.mobileInput.remove();
|
||||
this.mobileInput = null;
|
||||
}
|
||||
this.fitAddon.dispose();
|
||||
this.terminal.dispose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user