mirror of
https://github.com/Textualize/textual-serve.git
synced 2025-10-17 02:50:37 +03:00
resources
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,7 +3,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
.DS_Store
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
|
||||
79
examples/dictionary.py
Normal file
79
examples/dictionary.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
raise ImportError("Please install httpx with 'pip install httpx' ")
|
||||
|
||||
|
||||
from textual import work
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Input, Markdown
|
||||
|
||||
|
||||
class DictionaryApp(App):
|
||||
"""Searches a dictionary API as-you-type."""
|
||||
|
||||
CSS_PATH = "dictionary.tcss"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Input(placeholder="Search for a word")
|
||||
with VerticalScroll(id="results-container"):
|
||||
yield Markdown(id="results")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Called when app starts."""
|
||||
# Give the input focus, so we can start typing straight away
|
||||
self.query_one(Input).focus()
|
||||
|
||||
async def on_input_changed(self, message: Input.Changed) -> None:
|
||||
"""A coroutine to handle a text changed message."""
|
||||
if message.value:
|
||||
self.lookup_word(message.value)
|
||||
else:
|
||||
# Clear the results
|
||||
await self.query_one("#results", Markdown).update("")
|
||||
|
||||
@work(exclusive=True)
|
||||
async def lookup_word(self, word: str) -> None:
|
||||
"""Looks up a word."""
|
||||
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
try:
|
||||
results = response.json()
|
||||
except Exception:
|
||||
self.query_one("#results", Markdown).update(response.text)
|
||||
return
|
||||
|
||||
if word == self.query_one(Input).value:
|
||||
markdown = self.make_word_markdown(results)
|
||||
self.query_one("#results", Markdown).update(markdown)
|
||||
|
||||
def make_word_markdown(self, results: object) -> str:
|
||||
"""Convert the results in to markdown."""
|
||||
lines = []
|
||||
if isinstance(results, dict):
|
||||
lines.append(f"# {results['title']}")
|
||||
lines.append(results["message"])
|
||||
elif isinstance(results, list):
|
||||
for result in results:
|
||||
lines.append(f"# {result['word']}")
|
||||
lines.append("")
|
||||
for meaning in result.get("meanings", []):
|
||||
lines.append(f"_{meaning['partOfSpeech']}_")
|
||||
lines.append("")
|
||||
for definition in meaning.get("definitions", []):
|
||||
lines.append(f" - {definition['definition']}")
|
||||
lines.append("---")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from textual_serve.server import Server
|
||||
|
||||
server = Server(DictionaryApp)
|
||||
server.serve()
|
||||
25
examples/dictionary.tcss
Normal file
25
examples/dictionary.tcss
Normal file
@@ -0,0 +1,25 @@
|
||||
Screen {
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
Input {
|
||||
dock: top;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
#results {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#results-container {
|
||||
background: $background 50%;
|
||||
margin: 0 0 1 0;
|
||||
height: 100%;
|
||||
overflow: hidden auto;
|
||||
border: tall $background;
|
||||
}
|
||||
|
||||
#results-container:focus {
|
||||
border: tall $accent;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ dependencies = [
|
||||
"textual>=0.66.0",
|
||||
"aiohttp>=3.9.5",
|
||||
"aiohttp-jinja2>=1.6",
|
||||
"jinja2>=3.1.4",
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">= 3.8"
|
||||
|
||||
@@ -25,6 +25,7 @@ idna==3.7
|
||||
# via yarl
|
||||
jinja2==3.1.4
|
||||
# via aiohttp-jinja2
|
||||
# via textual-serve
|
||||
linkify-it-py==2.0.3
|
||||
# via markdown-it-py
|
||||
markdown-it-py==3.0.0
|
||||
|
||||
@@ -25,6 +25,7 @@ idna==3.7
|
||||
# via yarl
|
||||
jinja2==3.1.4
|
||||
# via aiohttp-jinja2
|
||||
# via textual-serve
|
||||
linkify-it-py==2.0.3
|
||||
# via markdown-it-py
|
||||
markdown-it-py==3.0.0
|
||||
|
||||
121
src/textual_serve/server.py
Normal file
121
src/textual_serve/server.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
import aiohttp_jinja2
|
||||
from aiohttp import web
|
||||
from aiohttp.web_runner import GracefulExit
|
||||
import jinja2
|
||||
|
||||
from textual.app import App
|
||||
|
||||
log = logging.getLogger("textual")
|
||||
|
||||
|
||||
class Server:
|
||||
"""Serve a Textual app."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_factory: Callable[[], App],
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8000,
|
||||
name: str = "Textual App",
|
||||
public_url: str | None = None,
|
||||
statics_path: str | os.PathLike = "./static",
|
||||
templates_path: str | os.PathLike = "./templates",
|
||||
):
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
app_factory: A callable that returns a new App instance.
|
||||
host: Host of web application.
|
||||
port: Port for server.
|
||||
statics_path: Path to statics folder. May be absolute or relative to server.py.
|
||||
templates_path" Path to templates folder. May be absolute or relative to server.py.
|
||||
"""
|
||||
self.app_factory = app_factory
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.name = name
|
||||
|
||||
if public_url is None:
|
||||
if self.port == 80:
|
||||
self.public_url = f"http://{self.host}"
|
||||
else:
|
||||
self.public_url = f"http://{self.host}:{self.port}"
|
||||
else:
|
||||
self.public_url = public_url
|
||||
|
||||
base_path = (Path(__file__) / "../").resolve().absolute()
|
||||
self.statics_path = base_path / statics_path
|
||||
self.templates_path = base_path / templates_path
|
||||
|
||||
def request_exit(self, reason: str | None = None) -> None:
|
||||
"""Gracefully exit the application, optionally supplying a reason.
|
||||
|
||||
Args:
|
||||
reason: The reason for exiting which will be included in the Ganglion server log.
|
||||
"""
|
||||
log.info(f"Exiting - {reason if reason else ''}")
|
||||
raise GracefulExit()
|
||||
|
||||
async def _make_app(self) -> web.Application:
|
||||
"""Make the aiohttp web.Application.
|
||||
|
||||
Returns:
|
||||
New aiohttp application.
|
||||
"""
|
||||
app = web.Application()
|
||||
|
||||
aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(self.templates_path))
|
||||
|
||||
ROUTES = [
|
||||
web.get("/", self.handle_index, name="index"),
|
||||
web.get("/ws", self.handle_websocket, name="websocket"),
|
||||
web.static("/static", self.statics_path, show_index=True, name="static"),
|
||||
]
|
||||
app.add_routes(ROUTES)
|
||||
return app
|
||||
|
||||
async def on_shutdown(self, app: web.Application) -> None:
|
||||
pass
|
||||
|
||||
def serve(self) -> None:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.add_signal_handler(signal.SIGINT, self.request_exit)
|
||||
loop.add_signal_handler(signal.SIGTERM, self.request_exit)
|
||||
web.run_app(self._make_app(), port=self.port, handle_signals=False, loop=loop)
|
||||
|
||||
@aiohttp_jinja2.template("app_index.html")
|
||||
async def handle_index(self, request: web.Request) -> dict[str, Any]:
|
||||
router = request.app.router
|
||||
|
||||
def get_url(route: str, **args) -> str:
|
||||
path = router[route].url_for(**args)
|
||||
return f"{self.public_url}{path}"
|
||||
|
||||
context = {
|
||||
"font_size": 14,
|
||||
"app_websocket_url": get_url("websocket"),
|
||||
}
|
||||
context["config"] = {
|
||||
"static": {
|
||||
"url": get_url("static", filename="/") + "/",
|
||||
},
|
||||
}
|
||||
context["application"] = {
|
||||
"name": self.name,
|
||||
}
|
||||
|
||||
print(context)
|
||||
return context
|
||||
|
||||
async def handle_websocket(self, request: web.Request) -> web.Response:
|
||||
return web.Response()
|
||||
BIN
src/textual_serve/static/.DS_Store
vendored
Normal file
BIN
src/textual_serve/static/.DS_Store
vendored
Normal file
Binary file not shown.
191
src/textual_serve/static/css/xterm.css
Normal file
191
src/textual_serve/static/css/xterm.css
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 7;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
Binary file not shown.
BIN
src/textual_serve/static/fonts/RobotoMono-VariableFont_wght.ttf
Normal file
BIN
src/textual_serve/static/fonts/RobotoMono-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
src/textual_serve/static/images/background.png
Normal file
BIN
src/textual_serve/static/images/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
1
src/textual_serve/static/index.html
Normal file
1
src/textual_serve/static/index.html
Normal file
@@ -0,0 +1 @@
|
||||
<h1>Hello from Static</h1>
|
||||
263
src/textual_serve/static/js/textual.js
Normal file
263
src/textual_serve/static/js/textual.js
Normal file
File diff suppressed because one or more lines are too long
160
src/textual_serve/templates/app_index.html
Normal file
160
src/textual_serve/templates/app_index.html
Normal file
@@ -0,0 +1,160 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="{{ config.static.url }}css/xterm.css" />
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto%20Mono"
|
||||
/>
|
||||
<script src="{{ config.static.url }}js/textual.js"></script>
|
||||
<style>
|
||||
body {
|
||||
background: #0c181f;
|
||||
}
|
||||
|
||||
.dialog-container {
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
box-shadow: var(--shadow-elevation-high);
|
||||
}
|
||||
.shade {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0c181f;
|
||||
background-image: url("{{ config.static.url }}images/background.png");
|
||||
}
|
||||
.intro {
|
||||
width: 640px;
|
||||
height: 240px;
|
||||
font-size: 16px;
|
||||
z-index: 20;
|
||||
font-family: "Roboto Mono", menlo, monospace;
|
||||
text-align: center;
|
||||
opacity: 1;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
background-color: #12232d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 32px;
|
||||
}
|
||||
|
||||
body.-first-byte .intro-dialog,
|
||||
body.-first-byte .intro-dialog .shade {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-out;
|
||||
display: none;
|
||||
}
|
||||
|
||||
body .textual-terminal {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
body.-first-byte .textual-terminal {
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
.intro svg {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
body Button {
|
||||
padding: 16px 32px;
|
||||
background-color: #5e0ba7;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
border: none;
|
||||
font-family: "Roboto Mono", menlo, monospace;
|
||||
margin: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
Button:hover {
|
||||
background: #ac5af4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.closed-dialog {
|
||||
opacity: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.-closed .closed-dialog {
|
||||
opacity: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#start {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#start.-delay {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
function getStartUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
const params = new URLSearchParams(url.search);
|
||||
params.delete("delay");
|
||||
return url.pathname + "?" + params.toString();
|
||||
}
|
||||
async function refresh() {
|
||||
const ping_url = document.body.dataset.pingurl;
|
||||
if (ping_url) {
|
||||
await fetch(ping_url, {
|
||||
method: "GET",
|
||||
mode: "no-cors",
|
||||
});
|
||||
}
|
||||
window.location.href = getStartUrl();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body data-pingurl="{{ ping_url }}">
|
||||
<div class="dialog-container intro-dialog">
|
||||
<div class="shade"></div>
|
||||
<div class="intro">
|
||||
<svg
|
||||
width="32px"
|
||||
viewBox="0 0 2933 2261"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2927.81 0H1677.1L1312.35 205.173H306.689L0.359375 434.921L204.029 893.177H735.972L644.784 2261H1060.36L1441.72 1974.97L2073.92 688.003H2334.4L2717.9 472.286L2927.81 0ZM1245.82 410.347L1276.32 277.656H330.85L153.929 410.347H1245.82ZM1229.16 482.83H100.972L251.134 820.694H813.449L722.26 2188.52H837.043L1229.16 482.83ZM1350.7 277.656L911.417 2188.52H1023.87L1662.19 615.52H2301.36L2451.52 277.656H1350.7ZM1460.19 205.173H2497.79L2733.69 72.4829H1696.09L1460.19 205.173Z"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div>{{ application.name or 'Textual Application' }}</div>
|
||||
<button type="button" onClick="refresh()" id="start">Start</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-container closed-dialog">
|
||||
<div class="shade"></div>
|
||||
<div class="intro">
|
||||
<div class="message">Session ended.</div>
|
||||
<button type="button" onClick="refresh()">Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="terminal"
|
||||
class="textual-terminal"
|
||||
data-session-websocket-url="{{ app_websocket_url }}"
|
||||
data-font-size="{{ font_size }}"
|
||||
></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user