resources

This commit is contained in:
Will McGugan
2024-06-17 12:53:20 +01:00
parent 0fe15908e9
commit 5aecb6a072
15 changed files with 844 additions and 1 deletions

2
.gitignore vendored
View File

@@ -3,7 +3,7 @@
__pycache__/
*.py[cod]
*$py.class
.DS_Store
# C extensions
*.so

79
examples/dictionary.py Normal file
View 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
View 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;
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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
View 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

Binary file not shown.

View 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.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -0,0 +1 @@
<h1>Hello from Static</h1>

File diff suppressed because one or more lines are too long

View 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>