diff --git a/.gitignore b/.gitignore index 67c3cf0..2be5544 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ __pycache__/ *.py[cod] *$py.class - +.DS_Store # C extensions *.so diff --git a/examples/dictionary.py b/examples/dictionary.py new file mode 100644 index 0000000..609f984 --- /dev/null +++ b/examples/dictionary.py @@ -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() diff --git a/examples/dictionary.tcss b/examples/dictionary.tcss new file mode 100644 index 0000000..79d7851 --- /dev/null +++ b/examples/dictionary.tcss @@ -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; +} diff --git a/pyproject.toml b/pyproject.toml index 4321456..3c1a2bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/requirements-dev.lock b/requirements-dev.lock index c3af3cf..9b14ec3 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -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 diff --git a/requirements.lock b/requirements.lock index c3af3cf..9b14ec3 100644 --- a/requirements.lock +++ b/requirements.lock @@ -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 diff --git a/src/textual_serve/server.py b/src/textual_serve/server.py new file mode 100644 index 0000000..4d33eaf --- /dev/null +++ b/src/textual_serve/server.py @@ -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() diff --git a/src/textual_serve/static/.DS_Store b/src/textual_serve/static/.DS_Store new file mode 100644 index 0000000..490e2a8 Binary files /dev/null and b/src/textual_serve/static/.DS_Store differ diff --git a/src/textual_serve/static/css/xterm.css b/src/textual_serve/static/css/xterm.css new file mode 100644 index 0000000..a28d433 --- /dev/null +++ b/src/textual_serve/static/css/xterm.css @@ -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; +} diff --git a/src/textual_serve/static/fonts/RobotoMono-Italic-VariableFont_wght.ttf b/src/textual_serve/static/fonts/RobotoMono-Italic-VariableFont_wght.ttf new file mode 100644 index 0000000..1a4d694 Binary files /dev/null and b/src/textual_serve/static/fonts/RobotoMono-Italic-VariableFont_wght.ttf differ diff --git a/src/textual_serve/static/fonts/RobotoMono-VariableFont_wght.ttf b/src/textual_serve/static/fonts/RobotoMono-VariableFont_wght.ttf new file mode 100644 index 0000000..fc02de4 Binary files /dev/null and b/src/textual_serve/static/fonts/RobotoMono-VariableFont_wght.ttf differ diff --git a/src/textual_serve/static/images/background.png b/src/textual_serve/static/images/background.png new file mode 100644 index 0000000..eb815b7 Binary files /dev/null and b/src/textual_serve/static/images/background.png differ diff --git a/src/textual_serve/static/index.html b/src/textual_serve/static/index.html new file mode 100644 index 0000000..65dee23 --- /dev/null +++ b/src/textual_serve/static/index.html @@ -0,0 +1 @@ +