From a962a0aa782ee505bb6715e5be3d4af5ed4bdba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20P=2E=20Santos?= Date: Sat, 1 Jan 2022 16:05:05 -0300 Subject: [PATCH] First commit --- .github/workflows/python-publish.yml | 36 ++++ .gitignore | 127 ++++++++++++ LICENSE | 21 ++ README.md | 51 +++++ makefile | 50 +++++ pyproject.toml | 3 + requirements.txt | 10 + setup.cfg | 39 ++++ setup.py | 4 + xradios/__init__.py | 1 + xradios/__main__.py | 31 +++ xradios/cli.py | 11 ++ xradios/core/__init__.py | 0 xradios/core/metadata.py | 81 ++++++++ xradios/core/player.py | 70 +++++++ xradios/core/server.py | 182 ++++++++++++++++++ xradios/plugins/__init__.py | 0 .../plug_1_fm_amsterdam_trance_radio.py | 15 ++ xradios/plugins/plug_bbc_radio_1.py | 20 ++ xradios/plugins/stream.py | 9 + xradios/tui/__init__.py | 31 +++ xradios/tui/buffers/__init__.py | 0 xradios/tui/buffers/command_line.py | 42 ++++ xradios/tui/buffers/display.py | 55 ++++++ xradios/tui/buffers/listview.py | 53 +++++ xradios/tui/buffers/popup.py | 20 ++ xradios/tui/client.py | 14 ++ xradios/tui/commands.py | 168 ++++++++++++++++ xradios/tui/constants.py | 54 ++++++ xradios/tui/keybindings.py | 79 ++++++++ xradios/tui/layout.py | 50 +++++ xradios/tui/messages.py | 26 +++ xradios/tui/styles.py | 14 ++ xradios/tui/utils.py | 87 +++++++++ xradios/tui/widget/__init__.py | 0 xradios/tui/widget/command_line.py | 35 ++++ xradios/tui/widget/display.py | 25 +++ xradios/tui/widget/listview.py | 57 ++++++ xradios/tui/widget/popup.py | 30 +++ xradios/tui/widget/topbar.py | 14 ++ 40 files changed, 1615 insertions(+) create mode 100644 .github/workflows/python-publish.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 makefile create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 xradios/__init__.py create mode 100644 xradios/__main__.py create mode 100644 xradios/cli.py create mode 100644 xradios/core/__init__.py create mode 100644 xradios/core/metadata.py create mode 100644 xradios/core/player.py create mode 100644 xradios/core/server.py create mode 100644 xradios/plugins/__init__.py create mode 100644 xradios/plugins/plug_1_fm_amsterdam_trance_radio.py create mode 100644 xradios/plugins/plug_bbc_radio_1.py create mode 100644 xradios/plugins/stream.py create mode 100644 xradios/tui/__init__.py create mode 100644 xradios/tui/buffers/__init__.py create mode 100644 xradios/tui/buffers/command_line.py create mode 100644 xradios/tui/buffers/display.py create mode 100644 xradios/tui/buffers/listview.py create mode 100644 xradios/tui/buffers/popup.py create mode 100644 xradios/tui/client.py create mode 100644 xradios/tui/commands.py create mode 100644 xradios/tui/constants.py create mode 100644 xradios/tui/keybindings.py create mode 100644 xradios/tui/layout.py create mode 100644 xradios/tui/messages.py create mode 100644 xradios/tui/styles.py create mode 100644 xradios/tui/utils.py create mode 100644 xradios/tui/widget/__init__.py create mode 100644 xradios/tui/widget/command_line.py create mode 100644 xradios/tui/widget/display.py create mode 100644 xradios/tui/widget/listview.py create mode 100644 xradios/tui/widget/popup.py create mode 100644 xradios/tui/widget/topbar.py diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..3bfabfc --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,36 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..015669b --- /dev/null +++ b/.gitignore @@ -0,0 +1,127 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +.vscode/ +.idea/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e8ba138 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 André P. Santos + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a59490 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# xradios + +[![Upload Python Package](https://github.com/andreztz/xradios/actions/workflows/python-publish.yml/badge.svg)](https://github.com/andreztz/xradios/actions/workflows/python-publish.yml) + +> Search and play your favorite Internet radio station. + + + +## Installation + +```bash +$ pip install xradios +``` + +## Usage example + +Open the terminal, and run the following command + +```bash + +$ xradios + +``` + + +## Development setup + +```bash +$ git clone git@github.com:andreztz/xradios.git +$ pip install -e . +``` + +## Release History + + - Work in progress + +## Meta + +André P. Santos – [@ztzandre](https://twitter.com/ztzandre) – andreztz@gmail.com + +Distributed under the XYZ license. See `LICENSE` for more information. + +[https://github.com/andreztz/xradios](https://github.com/andreztz/) + +## Contributing + +1. Fork it () +2. Create your feature branch (`git checkout -b feature/fooBar`) +3. Commit your changes (`git commit -am 'Add some fooBar'`) +4. Push to the branch (`git push origin feature/fooBar`) +5. Create a new Pull Request diff --git a/makefile b/makefile new file mode 100644 index 0000000..53af0a9 --- /dev/null +++ b/makefile @@ -0,0 +1,50 @@ +SHELL := /bin/bash +PYTHON = python3 +TEST_PATH = ./tests/ +FLAKE8_EXCLUDE = .venv,.eggs,,tox,.git,__pycache__,*.pyc + + +all: + clean install test + +check: + ${PYTHON} -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude ${FLAKE8_EXCLUDE} + ${PYTHON} -m flake8 . --count --exit-zero --max-complexity=10 --max-line-length=79 --statistics --exclude ${FLAKE8_EXCLUDE} + + +clean: + @find . -name '*.pyc' -exec rm --force {} + + @find . -name '*.pyo' -exec rm --force {} + + @find . -name '*~' -exec rm --force {} + + rm -rf build + rm -rf dist + rm -rf *.egg-info + rm -f *.sqlite + rm -rf .cache + +build: clean + @python -m build + +deploy: dist + @echo "-------------------- sending to pypi server ------------------------" + @twine upload dist/* + +help: + @echo "---------------------------- help --------------------------------------" + @echo " clean" + @echo " Remove python artifacts and build artifacts." + @echo " build" + @echo " Generate the distribution." + @echo " deploy" + @echo " Deploy on pypi.org." + @echo " check" + @echo " Check style with flake8." + @echo " black" + @echo " Run black" + +install: + pip install --upgrade pip + pip install -e . + +test: + ${PYTHON} -m pytest ${TEST_PATH} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9787c3b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..43dc3ce --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +prompt-toolkit>=3.0.8 +notify-send>=0.0.20 +Pygments>=2.7.3 +streamscrobbler3>=0.0.4 +python-mpv>=0.5.2 +pyradios>=0.0.22 +pluginbase>=1.0.1 +python-vlc>=3.0.12118 +psutil>=5.8.0 +tinydb>=4.5.2 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b67cb86 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,39 @@ +[metadata] +name = xradios +version = attr: xradios.__version__ +description = Search and play your favorite Internet radio station. +author = André P. Santos +author_email = andreztz@gmail.com +long_description_content_type = text/markdown +long_description = file: README.md +license = MIT +url = https://github.com/andreztz/xradios +keywords = player, radio, stations, radio-stations, terminal +classifiers = + Development Status :: 1 - Planning + Intended Audience :: End Users/Desktop + Programming Language :: Python :: 3 :: Only + Topic :: Multimedia :: Sound/Audio :: Players + Topic :: Utilities +project_urls = + Source = https://github.com/andreztz/xradios/ + +[options] +python_requires = >=3.8 +include_package_data = true +packages = find: +install_requires = + prompt-toolkit >= 3.0.8 + notify-send >= 0.0.20 + Pygments >= 2.7.3 + streamscrobbler3 >= 0.0.4 + python-mpv >= 0.5.2 + pyradios >= 0.0.22 + python-vlc >= 3.0.12118 + pluginbase >= 1.0.1 + + +[options.entry_points] +console_scripts = + xradios = xradios.__main__:main + xradiosd = xradios.core.server:main diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b024da8 --- /dev/null +++ b/setup.py @@ -0,0 +1,4 @@ +from setuptools import setup + + +setup() diff --git a/xradios/__init__.py b/xradios/__init__.py new file mode 100644 index 0000000..e9531b4 --- /dev/null +++ b/xradios/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.dev5" diff --git a/xradios/__main__.py b/xradios/__main__.py new file mode 100644 index 0000000..46f9ac4 --- /dev/null +++ b/xradios/__main__.py @@ -0,0 +1,31 @@ +import time +import logging +import subprocess + +from xradios.tui import TUI +from xradios.cli import parser +from xradios.tui.client import proxy + + +def main(): + cli = parser.parse_args() + query = {} + if cli.stations_by_tag: + query['command'] = "bytag" + query["term"] = cli.stations_by_tag + else: + query['command'] = "bytag" + query["term"] = "trance" + proc = subprocess.Popen( + ['xradiosd'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + time.sleep(2) + response = proxy.bookmarks() + # response = proxy.search(**query) + tui = TUI() + tui.initialize(response) + tui.run() + proc.terminate() + proc.wait(timeout=2) diff --git a/xradios/cli.py b/xradios/cli.py new file mode 100644 index 0000000..694fb30 --- /dev/null +++ b/xradios/cli.py @@ -0,0 +1,11 @@ +import argparse + + +def create_parser(): + parser = argparse.ArgumentParser() + parser.add_argument('--stations-by-tag', type=str) + # parser.add_argument('--station-by-tag-list', type=str) + return parser + + +parser = create_parser() diff --git a/xradios/core/__init__.py b/xradios/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xradios/core/metadata.py b/xradios/core/metadata.py new file mode 100644 index 0000000..695e152 --- /dev/null +++ b/xradios/core/metadata.py @@ -0,0 +1,81 @@ +import logging +import os +import re +from dataclasses import asdict +from dataclasses import dataclass +from dataclasses import field +from functools import partial + +from pluginbase import PluginBase + + +log = logging.getLogger('xradios') +here = os.path.abspath(os.path.dirname(__file__)) +get_path = partial(os.path.join, here) +plugin_base = PluginBase(package="xradios.plugins") +plugin_source = plugin_base.make_plugin_source( + searchpath=[get_path("../plugins")] +) + + +def normalize_plugin_name(name): + name = re.sub(r"(\s|\-|\.|,|\"|\'|\`)+", "_", name) + return name.lower() + + +def plugin_name(name): + return "plug_{}".format(normalize_plugin_name(name)) + + +@dataclass +class MetadataState: + index: str = field(init=False, default="") + name: str = field(init=False, default="") + song: str = field(init=False, default="") + homepage: str = field(init=False, default="") + + def serialize(self): + return asdict(self) + + +class MetadataManager: + def __init__(self): + self.station = None + self.s = MetadataState() + + def get(self): + """ + Try to find the id of the song that is playing. + """ + song = None + # BUG: clear song state + self.s.song = '' + + name = plugin_name(self.station["name"]) + + if name in plugin_source.list_plugins(): + # Try to get the id of song with user plugin. + plugin = plugin_source.load_plugin(name) + song = plugin.run() + else: + # Try to get the id of song with streamscrobler plugin. + plugin = plugin_source.load_plugin("stream") + song = plugin.run(self.station["url"]) + + if song and isinstance(song, str): + self.s.song = song + else: + log.debug(f'{song}= Must be string, not {type(song)=}') + + def state(self): + return self.s.serialize() + + def __call__(self, station): + if station: + self.station = station + self.s.index = station["index"] + self.s.homepage = station["homepage"] + self.s.name = station["name"] + + +metadata_manager = MetadataManager() diff --git a/xradios/core/player.py b/xradios/core/player.py new file mode 100644 index 0000000..2fe3064 --- /dev/null +++ b/xradios/core/player.py @@ -0,0 +1,70 @@ +import logging + +from abc import ABC +from abc import abstractmethod +from mpv import MPV +from pyradios import RadioBrowser + + +rb = RadioBrowser() +log = logging.getLogger('xradios') + + +class PlayerBase(ABC): + @abstractmethod + def play(self): + pass + + @abstractmethod + def stop(self): + pass + + def _click_counter(self, stationuuid): + try: + station = rb.click_counter(stationuuid) + except Exception: + log.exception("click counter error:") + else: + return station["url"] + + +class MPVPlayer(PlayerBase): + + player = MPV() + # player.loop_playlist = "inf" + player.set_loglevel = "no" + + def play(self, stationuuid): + url = self._click_counter(stationuuid) + self.player.play(url) + + def stop(self): + self.player.play("") + + def pause(self): + if self.player.pause: + self.player.pause = False + else: + self.player.pause = True + + def terminate(self): + self.player.terminate() + + +class VLCPlayer(PlayerBase): + import vlc + instance = vlc.Instance("--input-repeat=-1") # --verbose 0 + player = instance.media_player_new() + + def play(self, stationuuid): + url = self._click_counter(stationuuid) + media = self.instance.media_new(url) + self.player.set_media(media) + self.player.play() + + def stop(self): + self.player.stop() + + +# player = MPVPlayer() +player = VLCPlayer() diff --git a/xradios/core/server.py b/xradios/core/server.py new file mode 100644 index 0000000..0ef28f1 --- /dev/null +++ b/xradios/core/server.py @@ -0,0 +1,182 @@ +import logging +import os +import sys +import signal + +from pathlib import Path + +from xmlrpc.server import SimpleXMLRPCServer +from xmlrpc.server import SimpleXMLRPCRequestHandler + +from appdirs import user_data_dir +from appdirs import user_log_dir +from appdirs import user_config_dir + +from pyradios import RadioBrowser + +from tinydb import Query +from tinydb import TinyDB + +from xradios.core.metadata import metadata_manager +from xradios.core.player import player + + +app_name = 'xradios' +server_name = 'xradiosd' + +xradios_config_dir = Path(user_config_dir(appname=app_name)) +xradios_config_dir.mkdir(parents=True, exist_ok=True) + +xradios_data_dir = Path(user_data_dir(appname=app_name)) +xradios_data_dir.mkdir(parents=True, exist_ok=True) + +xradios_log_dir = Path(user_log_dir(appname=app_name)) +xradios_log_dir.mkdir(parents=True, exist_ok=True) + +log_level = getattr(logging, os.environ.get('XRADIOS_LOG_LEVEL', 'INFO')) +log_format = '%(levelname)s - %(name)s - %(message)s' +log_file = 'xradios.log' + +logging.basicConfig( + filename=xradios_log_dir / log_file, + level=log_level, + format=log_format + ) + +log = logging.getLogger('xradios') +effective_log_level = logging.getLevelName(log.getEffectiveLevel()) +log.info(f'Log level {effective_log_level=}') + + +rb = RadioBrowser() +db = TinyDB(xradios_data_dir / 'bookmarks.json') + + +command_handlers = {} + + +def cmd(name): + def decorator(func): + command_handlers[name] = func + return decorator + + +@cmd("play") +def play(**station): + metadata_manager(station) + player.play(station.get("stationuuid")) + + +@cmd("stop") +def stop(): + player.stop() + + +@cmd("pause") +def pause(): + player.pause() + + +@cmd("search") +def search(**kwargs): + command = kwargs.get("command") + term = kwargs.get("term") + response = rb.search(**{command: term}) + return response + + +@cmd("tags") +def tags(): + response = rb.tags() + return response + + +@cmd("now_playing") +def now_playing(): + metadata_manager.get() + return metadata_manager.state() + + +@cmd('bookmarks') +def bookmarks(): + return [dict(s) for s in db.all()] + + +@cmd('add_bookmark') +def add_bookmark(**station): + # Check if the station has already been added to bookmarks + if not db.search(Query().stationuuid == station['stationuuid']): + db.insert(station) + log.debug(f'Adding a new station to bookmarks {station=}') + + +@cmd('remove_bookmark') +def remove_bookmark(**station): + db.remove(Query().stationuuid == station['stationuuid']) + log.debug(f'Removing a station from bookmarks {station=}') + + +class RequestHandler(SimpleXMLRPCRequestHandler): + rpc_paths = ("/", "/RPC2") + # def log_request(self, code="-", size="-"): + # from xradios.logger import log + # log.info(f'RPC status code {code}') + # BaseHTTPRequestHandler.log_message = lambda *args: print(*args) + # BaseHTTPRequestHandler.log_request(self, code, size) + + +class RPCServer: + def __init__(self, address): + self._serv = SimpleXMLRPCServer( + address, + allow_none=True, + requestHandler=RequestHandler, + use_builtin_types=True, + ) + self._serv.rpc_paths = ("/", "/RPC2") # default + self._serv.register_introspection_functions() + + for key, value in command_handlers.items(): + setattr(self, key, value) + self.register_function(getattr(self, key)) + + def register_function(self, function, name=None): + # https://stackoverflow.com/questions/119802/using-kwargs-with-simplexmlrpcserver-in-python + def _function(args, kwargs): + return function(*args, **kwargs) + + _function.__name__ = function.__name__ + self._serv.register_function(_function, name) + + def serve_forever(self): + self._serv.serve_forever() + + def stop(self): + self._serv.shutdown() + + +def sigterm_handler(signo, frame, server): + server.stop() + log.info('Shutdown server...') + raise SystemExit(0) + + +def run(host="", port=10000): + server = RPCServer((host, port)) + signal.signal( + signal.SIGTERM, + lambda signo, frame: sigterm_handler(signo, frame, server) + ) + log.info(f"Serving XML-RPC port: {port}") + server.serve_forever() + + +def main(): + try: + run() + except KeyboardInterrupt: + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/xradios/plugins/__init__.py b/xradios/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xradios/plugins/plug_1_fm_amsterdam_trance_radio.py b/xradios/plugins/plug_1_fm_amsterdam_trance_radio.py new file mode 100644 index 0000000..46f531d --- /dev/null +++ b/xradios/plugins/plug_1_fm_amsterdam_trance_radio.py @@ -0,0 +1,15 @@ +import requests + + +url = "https://www.1.fm/stplaylist/atr" + + +def run(*args, **kwargs): + try: + resp = requests.get(url) + obj = resp.json() + artist = obj["nowplaying"][0]["artist"] + title = obj["nowplaying"][0]["title"] + return "{} - {}".format(artist, title) + except Exception: + return diff --git a/xradios/plugins/plug_bbc_radio_1.py b/xradios/plugins/plug_bbc_radio_1.py new file mode 100644 index 0000000..16821e9 --- /dev/null +++ b/xradios/plugins/plug_bbc_radio_1.py @@ -0,0 +1,20 @@ +import requests + + +URL = "https://rms.api.bbc.co.uk/v2/services/bbc_radio_one/segments/latest" + + +def run(*args, **kwargs): + resp = requests.get(URL) + data = resp.json() + try: + artist = data["data"][0]["titles"]["primary"] + title = data["data"][0]["titles"]["secondary"] + except Exception: + return + else: + return "{} - {}".format(artist, title) + + +if __name__ == "__main__": + print(run()) diff --git a/xradios/plugins/stream.py b/xradios/plugins/stream.py new file mode 100644 index 0000000..ce79d7a --- /dev/null +++ b/xradios/plugins/stream.py @@ -0,0 +1,9 @@ +from streamscrobbler import streamscrobbler + + +def run(url, *args, **kwargs): + data = streamscrobbler.get_server_info(url) + metadata = data["metadata"] + if not metadata: + return + return metadata.get("song").strip() diff --git a/xradios/tui/__init__.py b/xradios/tui/__init__.py new file mode 100644 index 0000000..b6ed025 --- /dev/null +++ b/xradios/tui/__init__.py @@ -0,0 +1,31 @@ +from prompt_toolkit.application import Application + +from xradios.tui.buffers.listview import LISTVIEW_BUFFER +from xradios.tui.buffers.display import DISPLAY_BUFFER +from xradios.tui.keybindings import kbindings +from xradios.tui.layout import layout +from xradios.tui.utils import stations + + +class TUI: + def __init__(self, *args, **kwargs): + self.app = Application( + layout=layout, + key_bindings=kbindings(), + full_screen=True, + mouse_support=True, + enable_page_navigation_bindings=True, + ) + + def initialize(self, response): + list_buffer = self.app.layout.get_buffer_by_name(LISTVIEW_BUFFER) + stations.new(*response) + list_buffer.update(str(stations)) + + display_buffer = self.app.layout.get_buffer_by_name(DISPLAY_BUFFER) + from asyncio import get_event_loop + loop = get_event_loop() + loop.create_task(display_buffer.run()) + + def run(self): + self.app.run() diff --git a/xradios/tui/buffers/__init__.py b/xradios/tui/buffers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xradios/tui/buffers/command_line.py b/xradios/tui/buffers/command_line.py new file mode 100644 index 0000000..c701328 --- /dev/null +++ b/xradios/tui/buffers/command_line.py @@ -0,0 +1,42 @@ +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.document import Document +from prompt_toolkit.completion import WordCompleter + +from xradios.tui.constants import COMMAND_LINE_BUFFER + + +COMMANDS = [ + "play", + "pause", + "stop", + "rec", + "info", + "help", + "list", + "bytag", + "bycodec", + "bycountry", + "bylanguage", + "byname", + "bystate", + "byuuid", + "tags", + "exit", + "search", + "nowplaying", +] + + +completer = WordCompleter(COMMANDS, ignore_case=True) + + +class CommandLineBuffer(Buffer): + def __init__(self, *args, **kwargs): + super().__init__( + completer=completer, + complete_while_typing=True, + name=COMMAND_LINE_BUFFER + ) + + +buffer = CommandLineBuffer() diff --git a/xradios/tui/buffers/display.py b/xradios/tui/buffers/display.py new file mode 100644 index 0000000..f574b1f --- /dev/null +++ b/xradios/tui/buffers/display.py @@ -0,0 +1,55 @@ +import logging +import asyncio + +from notify import notification + +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.document import Document + +from xradios.tui.constants import DISPLAY_BUFFER +from xradios.tui.client import proxy + + +log = logging.getLogger('xradios') + + +class DisplayBuffer(Buffer): + def __init__(self, *args, **kwargs): + content = kwargs.get("content", "") + super().__init__( + document=Document(content, 0), + read_only=True, + name=DISPLAY_BUFFER + ) + + def clear(self): + self.set_document(Document("", 0), bypass_readonly=True) + + async def run(self): + while True: + try: + metadata = proxy.now_playing() + except: + pass + else: + if all(metadata.values()): + notification( + metadata.get('name'), + message=metadata.get('song'), + app_name='xradios' + ) + + self.update(metadata) + await asyncio.sleep(120) + + def update(self, metadata): + log.info(metadata) + if metadata.get('song'): + content = '\n{name:<30} {homepage}\n\n{song}'.format(**metadata) + else: + content = "\n{name:<30}\n\n{homepage}".format(**metadata) + + self.set_document(Document(content, 0), bypass_readonly=True) + + +buffer = DisplayBuffer() diff --git a/xradios/tui/buffers/listview.py b/xradios/tui/buffers/listview.py new file mode 100644 index 0000000..31a55bd --- /dev/null +++ b/xradios/tui/buffers/listview.py @@ -0,0 +1,53 @@ +import logging + +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.document import Document + +from xradios.tui.constants import LISTVIEW_BUFFER + + +log = logging.getLogger('xradios') + + +class ListViewBuffer(Buffer): + def __init__(self, *args, **kwargs): + content = kwargs.get("content", "") + super().__init__( + document=Document(content, 0), + multiline=False, + read_only=True, + name=LISTVIEW_BUFFER, + ) + + def line(self, index=None): + if index and index.isnumeric(): + index = int(index) - 1 + line = self._get_line(index) + return index, line + # i (index) is -> (int) line number + # text is -> (str) line content + index = self.document.cursor_position_row + text = self.document.current_line + return index, text + + def _get_line(self, i): + try: + line = self.document.text.split("\n")[i] + except IndexError: + log.exception("ListViewBuffer._get_line(i)") + else: + return line + + def get_index(self, **kwargs): + variables = kwargs.get("variables", None) + index = variables.get("term") if variables else None + + if index and index.isnumeric(): + return self.line(index)[0] + return self.line()[0] + + def update(self, content): + self.reset(Document(content, 0)) + + +buffer = ListViewBuffer() diff --git a/xradios/tui/buffers/popup.py b/xradios/tui/buffers/popup.py new file mode 100644 index 0000000..75b3863 --- /dev/null +++ b/xradios/tui/buffers/popup.py @@ -0,0 +1,20 @@ +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.document import Document + +from xradios.tui.constants import POPUP_BUFFER + + +class PopupBuffer(Buffer): + def __init__(self, *args, **kwargs): + content = kwargs.get("content", "") + super().__init__( + document=Document(content, 0), + read_only=True, + name=POPUP_BUFFER + ) + + def update(self, content): + self.reset(Document(content, 0)) + + +buffer = PopupBuffer() diff --git a/xradios/tui/client.py b/xradios/tui/client.py new file mode 100644 index 0000000..f4c1d7a --- /dev/null +++ b/xradios/tui/client.py @@ -0,0 +1,14 @@ +from xmlrpc.client import ServerProxy + + +class Proxy: + def __init__(self, url): + self._xmlrpc_server_proxy = ServerProxy(url) + def __getattr__(self, name): + call_proxy = getattr(self._xmlrpc_server_proxy, name) + def _call(*args, **kwargs): + return call_proxy(args, kwargs) + return _call + + +proxy = Proxy('http://localhost:10000') diff --git a/xradios/tui/commands.py b/xradios/tui/commands.py new file mode 100644 index 0000000..f7a4a96 --- /dev/null +++ b/xradios/tui/commands.py @@ -0,0 +1,168 @@ +import logging +from prompt_toolkit.contrib.regular_languages import compile +from xradios.tui.constants import DISPLAY_BUFFER +from xradios.tui.constants import LISTVIEW_BUFFER +from xradios.tui.constants import POPUP_BUFFER +from xradios.tui.constants import HELP_TEXT +from xradios.tui.client import proxy +from xradios.tui.utils import stations +from xradios.tui.utils import tags as _tags + + +log = logging.getLogger('xradios') + + +COMMAND_GRAMMAR = compile( + r"""( + (?P[^\s]+) \s+ (?P[^\s]+) \s+ (?P[^\s].+) | + (?P[^\s]+) \s+ (?P[^\s]+) | + (?P[^\s!]+) + )""" +) + + +COMMAND_TO_HANDLER = {} + + +def get_commands(): + return COMMAND_TO_HANDLER.keys() + + +def get_command_help(command): + return COMMAND_TO_HANDLER[command].__doc__ + + +def has_command_handler(command): + return command in COMMAND_TO_HANDLER + + +def call_command_handler(command, *args, **kwargs): + COMMAND_TO_HANDLER[command](*args, **kwargs) + + +def command_line_handler(event): + try: + variables = COMMAND_GRAMMAR.match( + event.current_buffer.text + ).variables() + except Exception: + return + else: + command = variables.get("command") + if has_command_handler(command): + call_command_handler(command, event, variables=variables) + + +def grabe_from_buffer(buffer, stations, **kwargs): + """ + Retrieves an object, via the line number of a given buffer. + """ + index = int(buffer.get_index(**kwargs)) + station = stations[index] + return index, station + + +def cmd(name): + """ + Decorator to register commands in this namespace + """ + def decorator(func): + COMMAND_TO_HANDLER[name] = func + + return decorator + + +@cmd("exit") +def exit(event, **kwargs): + """ exit Ctrl + Q""" + proxy.stop() + event.app.exit() + + +@cmd("play") +def play(event, **kwargs): + list_view_buffer = event.app.layout.get_buffer_by_name(LISTVIEW_BUFFER) + index, station = grabe_from_buffer( + list_view_buffer, + stations, + **kwargs + ) + proxy.play(**station.serialize()) + display_buffer = event.app.layout.get_buffer_by_name(DISPLAY_BUFFER) + metadata = proxy.now_playing() + display_buffer.update(metadata) + + # from asyncio import get_event_loop + # loop = get_event_loop() + # loop.create_task(display_buffer.run()) + + +@cmd("stop") +def stop(event, **kwargs): + display_buffer = event.app.layout.get_buffer_by_name(DISPLAY_BUFFER) + display_buffer.clear() + proxy.stop() + + +@cmd("pause") +def pause(event, **kwargs): + proxy.pause() + + +@cmd("search") +def search(event, **kwargs): + query = {} + list_buffer = event.app.layout.get_buffer_by_name(LISTVIEW_BUFFER) + query["command"] = kwargs["variables"].get("subcommand")[2:] + query["term"] = kwargs["variables"].get("term") + stations.new(*proxy.search(**query)) + list_buffer.update(str(stations)) + +@cmd('tags') +def tags(event, **kwargs): + list_buffer = event.app.layout.get_buffer_by_name(LISTVIEW_BUFFER) + _tags.new(*proxy.tags()) + list_buffer.update(str(_tags)) + + +@cmd("help") +def help(event, **kwargs): + """Show help""" + popup_buffer = event.app.layout.get_buffer_by_name(POPUP_BUFFER) + popup_buffer.update(HELP_TEXT) + event.app.layout.focus(popup_buffer) + + +@cmd('bookmark') +def bookmark(event, **kwargs): + list_view_buffer = event.app.layout.get_buffer_by_name( + LISTVIEW_BUFFER + ) + subcommand = kwargs['variables'].get('subcommand') + log.debug(f'{subcommand=}') + match subcommand: + case 'add': + index, station = grabe_from_buffer(list_view_buffer, stations, **kwargs) + station = station.serialize() + # Removes `index` key before storing + del station['index'] + proxy.add_bookmark(**station) + case 'remove': + index, station = grabe_from_buffer(list_view_buffer, stations, **kwargs) + station = station.serialize() + proxy.remove_bookmark(**station) + case _: + log.debug(f'{subcommand!r} not yet implemented') + + stations.new(*proxy.bookmarks()) + list_view_buffer.update(str(stations)) + + +@cmd("bookmarks") +def home(event, **kwargs): + """ + Go to bookmarks page + """ + list_view_buffer = event.app.layout.get_buffer_by_name(LISTVIEW_BUFFER) + stations.new(*proxy.bookmarks()) + list_view_buffer.update(str(stations)) diff --git a/xradios/tui/constants.py b/xradios/tui/constants.py new file mode 100644 index 0000000..a5fb414 --- /dev/null +++ b/xradios/tui/constants.py @@ -0,0 +1,54 @@ +DISPLAY_BUFFER = "display_buffer" +LISTVIEW_BUFFER = "listview_buffer" +COMMAND_LINE_BUFFER = "command_line_buffer" +POPUP_BUFFER = "popup_buffer" + +# https://stackoverflow.com/questions/21503865/how-to-denote-that-a-command-line-argument-is-optional-when-printing-usage +HELP_TEXT = """ + +xradios +------- + + +- Press `:` to show the commandline. +- Press `Ctrl + Up` or `Ctrl + Down` to move the focus. +- Press `UP` or `Down` to navigate between radio stations +- To close xradios press `Ctrl + q` or `:quit` or `:exit`. +- To close this window, press F1, ESC, or change the focus. + + +Player commands +--------------- + +play +stop + + +Search commands +--------------- + +search bycodec +search bycountry +search byid +search bylanguage +search byname +search bystate +search bytag +search byuuid +search tags + + +Bookmark commands +------------------ + +bookmark add +bookmark rm +bookmarks + + +Help commands +-------------- + +help +help # TODO +""" diff --git a/xradios/tui/keybindings.py b/xradios/tui/keybindings.py new file mode 100644 index 0000000..8de5ef3 --- /dev/null +++ b/xradios/tui/keybindings.py @@ -0,0 +1,79 @@ +from prompt_toolkit.keys import Keys +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.bindings.focus import focus_next +from prompt_toolkit.key_binding.bindings.focus import focus_previous + +from prompt_toolkit.application import get_app +from prompt_toolkit.filters import Condition +from prompt_toolkit.filters import has_focus + +from xradios.tui.constants import HELP_TEXT +from xradios.tui.constants import POPUP_BUFFER +from xradios.tui.constants import COMMAND_LINE_BUFFER + + +def kbindings(): + + def enter_command_mode(app): + command_buffer = app.layout.get_buffer_by_name(COMMAND_LINE_BUFFER) + app.layout.focus(command_buffer) + + def leave_command_mode(app): + app.layout.focus_last() + + def launch_popup(app): + if not app.layout.has_focus(POPUP_BUFFER): + popup_buffer = app.layout.get_buffer_by_name(POPUP_BUFFER) + popup_buffer.update(HELP_TEXT) + app.layout.focus(POPUP_BUFFER) + else: + app.layout.focus(COMMAND_LINE_BUFFER) + + @Condition + def check_enter_command_mode(): + app = get_app() + buffer = app.layout.get_buffer_by_name(COMMAND_LINE_BUFFER) + if app.layout.has_focus(buffer): + return False + return True + + @Condition + def check_leave_command_mode(): + app = get_app() + buffer = app.layout.get_buffer_by_name(COMMAND_LINE_BUFFER) + if not app.layout.has_focus(buffer): + return False + return buffer.text == '' + + kb = KeyBindings() + + @kb.add(":") + def _(event): + """ + Show the commandline + """ + enter_command_mode(get_app()) + + @kb.add(Keys.Escape, eager=True) + @kb.add(Keys.Backspace, filter=check_leave_command_mode) + def _(event): + leave_command_mode(get_app()) + + @kb.add(Keys.ControlDown) + def _(event): + enter_command_mode(get_app()) + + @kb.add(Keys.ControlUp) + def _(event): + focus_previous(event) + + @kb.add(Keys.F1) + def _(event): + """Launch help pop up.""" + launch_popup(get_app()) + + @kb.add(Keys.ControlQ) + def _(event): + event.app.exit() + + return kb diff --git a/xradios/tui/layout.py b/xradios/tui/layout.py new file mode 100644 index 0000000..4249f5a --- /dev/null +++ b/xradios/tui/layout.py @@ -0,0 +1,50 @@ +from prompt_toolkit.layout import Float +from prompt_toolkit.layout import FloatContainer +from prompt_toolkit.layout import Layout +from prompt_toolkit.layout import HSplit +from prompt_toolkit.layout import ConditionalContainer + +from prompt_toolkit.filters import has_focus + +from xradios.tui.widget.display import Display +from xradios.tui.widget.popup import PopupWindow +from xradios.tui.widget.listview import ListView +from xradios.tui.widget.command_line import CommandLine +from xradios.tui.widget.topbar import TopBar + +from xradios.tui.buffers.display import buffer as display_buffer +from xradios.tui.buffers.popup import buffer as popup_buffer +from xradios.tui.buffers.command_line import buffer as command_line_buffer +from xradios.tui.buffers.listview import buffer as list_view_buffer + + +layout = Layout( + FloatContainer( + content=HSplit( + [ + TopBar(message="Need help! Press `F1`."), + Display(display_buffer), + ListView(list_view_buffer), + CommandLine(command_line_buffer), + ] + ), + modal=True, + floats=[ + # Help text as a float. + Float( + top=3, + bottom=2, + left=2, + right=2, + content=ConditionalContainer( + content=PopupWindow(popup_buffer, title="Help"), + filter=has_focus(popup_buffer), + ), + + ) + ], + ) +) + + +layout.focus(command_line_buffer) diff --git a/xradios/tui/messages.py b/xradios/tui/messages.py new file mode 100644 index 0000000..394e848 --- /dev/null +++ b/xradios/tui/messages.py @@ -0,0 +1,26 @@ +class EventEmitter: + def __init__(self): + self.listeners = dict() + + def _add_listener(self, event, func): + self.listeners.setdefault(event, set()).add(func) + + def on(self, event, func): + self._add_listener(event, func) + + def remove_listener(self, event, func): + if event in self.listeners and func in self.listeners[event]: + self.listeners[event].remove(func) + + def remove_all_listeners(self, event): + if event in self.listeners: + del self.listeners[event] + + def emit(self, event, *args, **kwargs): + if event in self.listeners: + listener_copy = self.listeners[event] # .copy() + for func in listener_copy: + return func(*args, **kwargs) + + +emitter = EventEmitter() diff --git a/xradios/tui/styles.py b/xradios/tui/styles.py new file mode 100644 index 0000000..eb6574f --- /dev/null +++ b/xradios/tui/styles.py @@ -0,0 +1,14 @@ +# Styling. +from prompt_toolkit.styles import Style + +style = Style( + [ + ("left-pane", "bg:#888800 #000000"), + ("right-pane", "bg:#00aa00 #000000"), + ("button", "#000000"), + ("button-arrow", "#000000"), + ("button focused", "bg:#ff0000"), + ("text-area focused", "bg:#ff0000"), + ("danger", "#ff3300"), + ] +) diff --git a/xradios/tui/utils.py b/xradios/tui/utils.py new file mode 100644 index 0000000..5c35c62 --- /dev/null +++ b/xradios/tui/utils.py @@ -0,0 +1,87 @@ +from dataclasses import asdict +from dataclasses import dataclass +from collections import UserList + + +@dataclass(frozen=True) +class Station: + index: str + stationuuid: str + name: str + url: str + homepage: str + tags: str + + @classmethod + def fields(cls): + return cls.__annotations__.keys() + + def serialize(self): + return asdict(self) + + def __str__(self): + return "{:>4} | {:<40.40} | tags: {} \n".format( + self.index, self.name, self.tags + ) + + +@dataclass(frozen=True) +class Tag: + index: str + name: str + stationcount: str + + @classmethod + def fields(cls): + return cls.__annotations__.keys() + + def serialize(self): + return asdict(self) + + def __str__(self): + return "{:>4} | {:<40}\n".format( + self.stationcount, self.name + ) + + +class TagList(UserList): + def __init__(self, *tags): + self.data = [] + if tags: + for i, t in enumerate(tags, 1): + kwargs = dict( + filter( + lambda elem: elem[0] in Tag.fields(), t.items() + ) + ) + self.data.append(Tag(index=i, **kwargs)) + self.sort(key=lambda t: t.stationcount, reverse=True) + + def new(self, *args): + return self.__init__(*args) + + def __str__(self): + return "".join(str(t) for t in self.data) + + +class StationList(UserList): + def __init__(self, *stations): + self.data = [] + if stations: + for i, s in enumerate(stations, 1): + kwargs = dict( + filter( + lambda elem: elem[0] in Station.fields(), s.items() + ) + ) + self.data.append(Station(index=i, **kwargs)) + + def new(self, *args): + return self.__init__(*args) + + def __str__(self): + return "".join(str(s) for s in self.data) + + +tags = TagList() +stations = StationList() diff --git a/xradios/tui/widget/__init__.py b/xradios/tui/widget/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xradios/tui/widget/command_line.py b/xradios/tui/widget/command_line.py new file mode 100644 index 0000000..fe634b5 --- /dev/null +++ b/xradios/tui/widget/command_line.py @@ -0,0 +1,35 @@ +from prompt_toolkit.layout import BufferControl +from prompt_toolkit.layout.processors import BeforeInput +from prompt_toolkit.layout.containers import Window +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import ConditionalContainer +from prompt_toolkit.filters import has_focus + +from xradios.tui.commands import command_line_handler + + +class CommandLine(ConditionalContainer): + + def __init__(self, buffer, filter=False): + self.buffer = buffer + self._buffer_control = BufferControl( + buffer=self.buffer, + input_processors=[BeforeInput(":")], + key_bindings=self.kbindings(), + ) + + super().__init__( + Window(self._buffer_control, height=1), filter=has_focus(buffer) + ) + + def kbindings(self): + + kb = KeyBindings() + + @kb.add("enter") + def _(event): + command_line_handler(event) + self.buffer.text = "" + + return kb + diff --git a/xradios/tui/widget/display.py b/xradios/tui/widget/display.py new file mode 100644 index 0000000..2633681 --- /dev/null +++ b/xradios/tui/widget/display.py @@ -0,0 +1,25 @@ +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.document import Document +from prompt_toolkit.completion import WordCompleter + +from prompt_toolkit.layout import BufferControl +from prompt_toolkit.layout.containers import Window +from prompt_toolkit.widgets import Frame +from prompt_toolkit.widgets import Box + + +class Display: + def __init__(self, buffer): + self.buffer_control = BufferControl( + buffer=buffer, + focusable=False, + focus_on_click=False + ) + self.window = Window(content=self.buffer_control) + self.window = Frame( + body=Box(self.window, padding_left=2, padding_right=0), + height=7 + ) + + def __pt_container__(self): + return self.window diff --git a/xradios/tui/widget/listview.py b/xradios/tui/widget/listview.py new file mode 100644 index 0000000..1949cce --- /dev/null +++ b/xradios/tui/widget/listview.py @@ -0,0 +1,57 @@ +from prompt_toolkit.keys import Keys +from prompt_toolkit.layout import BufferControl +from prompt_toolkit.layout.containers import Window +from prompt_toolkit.layout.margins import ScrollbarMargin +from prompt_toolkit.widgets import Box +from prompt_toolkit.widgets import Frame +from prompt_toolkit.key_binding import KeyBindings + +from xradios.tui.commands import call_command_handler + + +class ListView: + def __init__(self, buffer): + + self.buffer_control = BufferControl( + buffer=buffer, + focusable=True, + key_bindings=self._get_key_bindings(), + focus_on_click=True, + ) + + self.window = Window( + content=self.buffer_control, + right_margins=[ScrollbarMargin(display_arrows=True)], + ) + self.window = Frame(self.window) + self.container = self.window + + def _get_key_bindings(self): + "Key bindings for the List." + kb = KeyBindings() + + @kb.add("p") + @kb.add("enter") + def _(event): + call_command_handler('play', event) + + @kb.add("s") + def _(event): + call_command_handler('stop', event) + + @kb.add(Keys.ControlD) + def _(event): + call_command_handler( + 'bookmark', event, variables={'subcommand': 'add'} + ) + + @kb.add(Keys.ControlA) + def _(event): + call_command_handler( + 'bookmark', event, variables={'subcommand': 'remove'} + ) + + return kb + + def __pt_container__(self): + return self.container diff --git a/xradios/tui/widget/popup.py b/xradios/tui/widget/popup.py new file mode 100644 index 0000000..1551dc3 --- /dev/null +++ b/xradios/tui/widget/popup.py @@ -0,0 +1,30 @@ +from prompt_toolkit.layout import BufferControl +from prompt_toolkit.widgets import Frame +from prompt_toolkit.layout.containers import Window +from prompt_toolkit.layout.margins import ScrollbarMargin +from prompt_toolkit.layout.containers import ScrollOffsets +from prompt_toolkit.lexers import PygmentsLexer +from pygments.lexers import MarkdownLexer + + +class PopupWindow: + def __init__(self, buffer, title): + self._title = title + self._buffer = buffer + + self.buffer_control = BufferControl( + buffer=self._buffer, + lexer=PygmentsLexer(MarkdownLexer), + ) + + self.window = Frame( + body=Window( + content=self.buffer_control, + right_margins=[ScrollbarMargin(display_arrows=True)], + scroll_offsets=ScrollOffsets(top=2, bottom=2), + ), + title=self._title, + ) + + def __pt_container__(self): + return self.window diff --git a/xradios/tui/widget/topbar.py b/xradios/tui/widget/topbar.py new file mode 100644 index 0000000..9ba8f47 --- /dev/null +++ b/xradios/tui/widget/topbar.py @@ -0,0 +1,14 @@ +from prompt_toolkit.widgets import Box +from prompt_toolkit.widgets import Label + + +class TopBar: + def __init__(self, message): + self.window = Box( + Label(text=message), + padding_left=2, + height=1 + ) + + def __pt_container__(self): + return self.window