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]
|
[tool.poetry]
|
||||||
name = "textual-webterm"
|
name = "textual-webterm"
|
||||||
version = "0.5.2"
|
version = "0.5.3"
|
||||||
description = "Serve terminal sessions over the web"
|
description = "Serve terminal sessions over the web"
|
||||||
authors = ["Will McGugan <will@textualize.io>"]
|
authors = ["Will McGugan <will@textualize.io>"]
|
||||||
license = "MIT"
|
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),
|
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.',
|
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(
|
def app(
|
||||||
command: str | None,
|
command: str | None,
|
||||||
port: int,
|
port: int,
|
||||||
@@ -123,6 +142,9 @@ def app(
|
|||||||
app_path: str | None,
|
app_path: str | None,
|
||||||
landing_manifest: Path | None,
|
landing_manifest: Path | None,
|
||||||
compose_manifest: Path | None,
|
compose_manifest: Path | None,
|
||||||
|
theme: str,
|
||||||
|
font_family: str | None,
|
||||||
|
font_size: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Serve a terminal or Textual app over HTTP/WebSocket.
|
"""Serve a terminal or Textual app over HTTP/WebSocket.
|
||||||
|
|
||||||
@@ -165,6 +187,9 @@ def app(
|
|||||||
landing_apps=landing_apps,
|
landing_apps=landing_apps,
|
||||||
compose_mode=is_compose_mode,
|
compose_mode=is_compose_mode,
|
||||||
compose_project=compose_project,
|
compose_project=compose_project,
|
||||||
|
theme=theme,
|
||||||
|
font_family=font_family,
|
||||||
|
font_size=font_size,
|
||||||
)
|
)
|
||||||
for app_entry in landing_apps:
|
for app_entry in landing_apps:
|
||||||
server.add_terminal(app_entry.name, app_entry.command, slug=app_entry.slug)
|
server.add_terminal(app_entry.name, app_entry.command, slug=app_entry.slug)
|
||||||
|
|||||||
@@ -124,9 +124,15 @@ class LocalServer:
|
|||||||
landing_apps: list | None = None,
|
landing_apps: list | None = None,
|
||||||
compose_mode: bool = False,
|
compose_mode: bool = False,
|
||||||
compose_project: str | None = None,
|
compose_project: str | None = None,
|
||||||
|
theme: str = "monokai",
|
||||||
|
font_family: str | None = None,
|
||||||
|
font_size: int = 16,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
|
self.theme = theme
|
||||||
|
self.font_family = font_family
|
||||||
|
self.font_size = font_size
|
||||||
|
|
||||||
abs_path = Path(config_path).absolute()
|
abs_path = Path(config_path).absolute()
|
||||||
path = abs_path if abs_path.is_dir() else abs_path.parent
|
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)
|
ws_url = self._get_ws_url_from_request(request, route_key)
|
||||||
page_title = available_app.name if available_app else "Textual Web Terminal"
|
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_content = f"""<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -854,7 +867,7 @@ class LocalServer:
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<script type=\"module\" src=\"/static/js/terminal.js\"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -292,6 +292,7 @@ class WebTerminal {
|
|||||||
private reconnectDelay = 1000;
|
private reconnectDelay = 1000;
|
||||||
private messageQueue: [string, unknown][] = [];
|
private messageQueue: [string, unknown][] = [];
|
||||||
private lastValidSize: { cols: number; rows: number } | null = null;
|
private lastValidSize: { cols: number; rows: number } | null = null;
|
||||||
|
private mobileInput: HTMLTextAreaElement | null = null;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
@@ -365,10 +366,108 @@ class WebTerminal {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Setup mobile keyboard support
|
||||||
|
this.setupMobileKeyboard();
|
||||||
|
|
||||||
// Connect WebSocket
|
// Connect WebSocket
|
||||||
this.connect();
|
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 */
|
/** Wait for fonts to be loaded */
|
||||||
private async waitForFonts(): Promise<void> {
|
private async waitForFonts(): Promise<void> {
|
||||||
if (!("fonts" in document)) {
|
if (!("fonts" in document)) {
|
||||||
@@ -507,6 +606,10 @@ class WebTerminal {
|
|||||||
/** Clean up resources */
|
/** Clean up resources */
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.socket?.close();
|
this.socket?.close();
|
||||||
|
if (this.mobileInput) {
|
||||||
|
this.mobileInput.remove();
|
||||||
|
this.mobileInput = null;
|
||||||
|
}
|
||||||
this.fitAddon.dispose();
|
this.fitAddon.dispose();
|
||||||
this.terminal.dispose();
|
this.terminal.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user