diff --git a/deadsands/pyproject.toml b/deadsands/pyproject.toml index 79e90b0..698bc94 100644 --- a/deadsands/pyproject.toml +++ b/deadsands/pyproject.toml @@ -30,6 +30,7 @@ dnd-npcs = { git = "https://github.com/evilchili/dnd-npcs", branch = 'main' elethis-cipher= { git = "https://github.com/evilchili/elethis-cipher", branch = 'main' } #dnd-rolltable = { git = "https://github.com/evilchili/dnd-rolltable", branch = 'main' } dnd-rolltable = { file = "../../dnd-rolltable/dist/dnd_rolltable-1.1.9-py3-none-any.whl" } +prompt-toolkit = "^3.0.38" [tool.poetry.scripts] site = "site_tools.cli:app" diff --git a/deadsands/site_tools/cli.py b/deadsands/site_tools/cli.py index dad4618..1a29621 100644 --- a/deadsands/site_tools/cli.py +++ b/deadsands/site_tools/cli.py @@ -1,3 +1,4 @@ +import asyncio import click import os import shutil @@ -15,8 +16,11 @@ from livereload.watcher import INotifyWatcher from pathlib import Path from pelican import main as pelican_main from time import sleep +from typing_extensions import Annotated +from collections import defaultdict from site_tools.content_manager import create +from site_tools.shell.interactive_shell import InteractiveShell from rolltable.tables import RollTable @@ -39,6 +43,8 @@ CONFIG = { 'import_path': 'imports', # where new asseets will be made available 'production_host': 'deadsands.froghat.club', + # where to find roll table sources + 'table_sources_path': 'sources', } app = typer.Typer() @@ -53,6 +59,15 @@ class ContentType(str, Enum): page = 'page' +class Die(str, Enum): + d100 = '100' + d20 = '20' + d12 = '12' + d10 = '10' + d6 = '6' + d4 = '4' + + def pelican_run(cmd: list = [], publish=False) -> None: settings = CONFIG['settings_publish' if publish else 'settings_base'] pelican_main(['-s', settings] + cmd) @@ -165,16 +180,24 @@ def restock(source: str = typer.Argument( ..., help='The source file for the store.' ), - frequency: str = typer.Option( - 'default', - help='use the specified frequency from the source file'), - die: int = typer.Option( + frequency: str = Annotated[ + str, + typer.Option( + 'default', + help='use the specified frequency from the source file' + ) + ], + die: Die = typer.Option( 20, - help='The size of the die for which to create a table'), - template_dir: str = typer.Argument( - CONFIG['templates_path'], - help="Override the default location for markdown templates.", - ) + help='The size of the die for which to create a table' + ), + template_dir: str = Annotated[ + str, + typer.Argument( + CONFIG['templates_path'], + help="Override the default location for markdown templates.", + ) + ], ) -> None: rt = RollTable( @@ -240,5 +263,25 @@ def new( category, template or content_type.value)) +@app.command() +def dmsh(): + import termios, sys + + session = defaultdict(dict) + prompt = InteractiveShell( + [ + "[title]DM's Shell.[/title]", + 'dmsh' + ], config=CONFIG, session=session + ) + + # ensure the terminal is restored on exit. + old_attrs = termios.tcgetattr(sys.stdin) + try: + asyncio.run(prompt.start()) + finally: + termios.tcsetattr(sys.stdin, termios.TCSANOW, old_attrs) + + if __name__ == '__main__': app() diff --git a/deadsands/site_tools/console.py b/deadsands/site_tools/console.py new file mode 100644 index 0000000..4771c55 --- /dev/null +++ b/deadsands/site_tools/console.py @@ -0,0 +1,170 @@ +import os + +from configparser import ConfigParser +from pathlib import Path +from textwrap import dedent +from typing import Union, List + +import rich.repr + +from rich.console import Console as _Console +from rich.markdown import Markdown +from rich.theme import Theme +from rich.table import Table, Column + +from prompt_toolkit import PromptSession +from prompt_toolkit.patch_stdout import patch_stdout +from prompt_toolkit.formatted_text import ANSI +from prompt_toolkit.styles import Style +from prompt_toolkit.output import ColorDepth + + +BASE_STYLE = { + 'help': 'cyan', + 'bright': 'white', + 'repr.str': 'dim', + 'repr.brace': 'dim', + 'repr.url': 'blue', + 'table.header': 'white', + 'toolbar.fg': '#888888', + 'toolbar.bg': '#111111', + 'toolbar.bold': '#FFFFFF', +} + + +def console_theme(theme_name: Union[str, None] = None) -> dict: + """ + Return a console theme as a dictionary. + + Args: + theme_name (str): + """ + cfg = ConfigParser() + cfg.read_dict({'styles': BASE_STYLE}) + + if theme_name: + theme_path = theme_name if theme_name else os.environ.get('DEFAULT_THEME', 'blue_train') + cfg.read(Theme( + Path(theme_path) / Path('console.cfg') + )) + return cfg['styles'] + + +@rich.repr.auto +class Console(_Console): + """ + SYNOPSIS + + Subclasses a rich.console.Console to provide an instance with a + reconfigured themes, and convenience methods and attributes. + + USAGE + + Console([ARGS]) + + ARGS + + theme The name of a theme to load. Defaults to DEFAULT_THEME. + + EXAMPLES + + Console().print("Can I kick it?") + >>> Can I kick it? + + INSTANCE ATTRIBUTES + + theme The current theme + + """ + + def __init__(self, *args, **kwargs): + self._console_theme = console_theme(kwargs.get('theme', None)) + self._overflow = 'ellipsis' + kwargs['theme'] = Theme(self._console_theme, inherit=False) + super().__init__(*args, **kwargs) + + self._session = PromptSession() + + @property + def theme(self) -> Theme: + return self._console_theme + + def prompt(self, lines: List, **kwargs) -> str: + """ + Print a list of lines, using the final line as a prompt. + + Example: + + Console().prompt(["Can I kick it?", "[Y/n] ") + >>> Can I kick it? + [Y/n]> + + """ + + prompt_style = Style.from_dict({ + # 'bottom-toolbar': f"{self.theme['toolbar.fg']} bg:{self.theme['toolbar.bg']}", + # 'toolbar-bold': f"{self.theme['toolbar.bold']}" + }) + + for line in lines[:-1]: + super().print(line) + with self.capture() as capture: + super().print(f"[prompt bold]{lines[-1]}>[/] ", end='') + text = ANSI(capture.get()) + + # This is required to intercept key bindings and not mess up the + # prompt. Both the prompt and bottom_toolbar params must be functions + # for this to correctly regenerate the prompt after the interrupt. + with patch_stdout(raw=True): + return self._session.prompt( + lambda: text, + style=prompt_style, + color_depth=ColorDepth.TRUE_COLOR, + **kwargs) + + def mdprint(self, txt: str, **kwargs) -> None: + """ + Like print(), but support markdown. Text will be dedented. + """ + self.print(Markdown(dedent(txt), justify='left'), **kwargs) + + def print(self, txt: str, **kwargs) -> None: + """ + Print text to the console, possibly truncated with an ellipsis. + """ + super().print(txt, overflow=self._overflow, **kwargs) + + def debug(self, txt: str, **kwargs) -> None: + """ + Print text to the console with the current theme's debug style applied, if debugging is enabled. + """ + if os.environ.get('DEBUG', None): + self.print(dedent(txt), style='debug') + + def error(self, txt: str, **kwargs) -> None: + """ + Print text to the console with the current theme's error style applied. + """ + self.print(dedent(txt), style='error') + + def table(self, *cols: List[Column], **params) -> None: + """ + Print a rich table to the console with theme elements and styles applied. + parameters and keyword arguments are passed to rich.table.Table. + """ + background_style = f"on {self.theme['background']}" + params.update( + header_style=background_style, + title_style=background_style, + border_style=background_style, + row_styles=[background_style], + caption_style=background_style, + style=background_style, + ) + params['min_width'] = 80 + width = os.environ.get('CONSOLE_WIDTH', 'auto') + if width == 'expand': + params['expand'] = True + elif width != 'auto': + params['width'] = int(width) + return Table(*cols, **params) diff --git a/deadsands/site_tools/shell.py b/deadsands/site_tools/shell.py new file mode 100644 index 0000000..663e00d --- /dev/null +++ b/deadsands/site_tools/shell.py @@ -0,0 +1,35 @@ +from prompt_toolkit import PromptSession +from prompt_toolkit.completion import NestedCompleter +from prompt_toolkit.completion import FuzzyWordCompleter +from site_tools.cli import app +from enum import EnumMeta +from inspect import signature +from rich import print + + +def dmsh(): + + session = PromptSession() + + def cmd2dict(cmd): + sig = signature(cmd) + if not sig.parameters: + return None + cmds = {} + for (k, v) in list(sig.parameters.items()): + print(v, dir(v)) + if v.annotation.__class__ == EnumMeta: + cmds[k] = FuzzyWordCompleter([e.value for e in v.annotation]) + else: + cmds[k] = None + return cmds + + commands = dict( + site=dict((c.callback.__name__, cmd2dict(c.callback)) for c in app.registered_commands) + ) + print(commands) + completer = NestedCompleter.from_nested_dict(commands) + text = session.prompt("DM> ", completer=completer, complete_while_typing=True, enable_history_search=False) + words = text.split() + + print(f"You said {words}") diff --git a/deadsands/site_tools/shell/__init__.py b/deadsands/site_tools/shell/__init__.py new file mode 100644 index 0000000..dc414bd --- /dev/null +++ b/deadsands/site_tools/shell/__init__.py @@ -0,0 +1 @@ +from .base import BasePrompt diff --git a/deadsands/site_tools/shell/base.py b/deadsands/site_tools/shell/base.py new file mode 100644 index 0000000..009208f --- /dev/null +++ b/deadsands/site_tools/shell/base.py @@ -0,0 +1,125 @@ +import functools +from collections import namedtuple, defaultdict + +from prompt_toolkit.completion import NestedCompleter + + +from site_tools.console import Console +from textwrap import dedent + +COMMANDS = defaultdict(dict) + +Command = namedtuple('Commmand', 'prompt,handler,usage,completer') + + +def register_command(handler, usage, completer=None): + prompt = handler.__qualname__.split('.', -1)[0] + cmd = handler.__name__ + if cmd not in COMMANDS[prompt]: + COMMANDS[prompt][cmd] = Command( + prompt=prompt, + handler=handler, + usage=usage, + completer=completer, + ) + + +def command(usage, completer=None, binding=None): + def decorator(func): + register_command(func, usage, completer) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper + return decorator + + +class BasePrompt(NestedCompleter): + + def __init__(self, console=None): + super(BasePrompt, self).__init__(self._nested_completer_map()) + + self._prompt = '' + self._autocomplete_values = [] + self._console = None + self._theme = None + + def _nested_completer_map(self): + return dict( + (cmd_name, cmd.completer) for (cmd_name, cmd) in COMMANDS[self.__class__.__name__].items() + ) + + def _get_help(self, cmd=None): + try: + return dedent(COMMANDS[self.__class__.__name__][cmd].usage) + except KeyError: + return self.usage + + def default_completer(self, document, complete_event): + raise NotImplementedError(f"Implement the 'default_completer' method of {self.__class__.__name__}") + + @property + def usage(self): + text = dedent(""" + [title]dmsh[/title] + + Available commands are listed below. Try 'help COMMAND' for detailed help. + + [title]COMMANDS[/title] + + """) + for (name, cmd) in sorted(self.commands.items()): + text += f" [b]{name:10s}[/b] {cmd.handler.__doc__.strip()}\n" + return text + + @property + def commands(self): + return COMMANDS[self.__class__.__name__] + + @property + def console(self): + if not self._console: + self._console = Console(color_system='truecolor') + return self._console + + @property + def prompt(self): + return self._prompt + + @property + def autocomplete_values(self): + return self._autocomplete_values + + @property + def toolbar(self): + return None + + @property + def key_bindings(self): + return None + + def help(self, parts): + attr = None + if parts: + attr = parts[0] + self.console.print(self._get_help(attr)) + return True + + def process(self, cmd, *parts): + if cmd in self.commands: + return self.commands[cmd].handler(self, parts) + self.console.error(f"Command {cmd} not understood.") + + def start(self, cmd=None): + while True: + if not cmd: + cmd = self.console.prompt( + self.prompt, + completer=self, + bottom_toolbar=self.toolbar, + key_bindings=self.key_bindings) + if cmd: + cmd, *parts = cmd.split() + self.process(cmd, *parts) + cmd = None diff --git a/deadsands/site_tools/shell/interactive_shell.py b/deadsands/site_tools/shell/interactive_shell.py new file mode 100644 index 0000000..a37a66d --- /dev/null +++ b/deadsands/site_tools/shell/interactive_shell.py @@ -0,0 +1,191 @@ +from site_tools.shell.base import BasePrompt, command +from rolltable.tables import RollTable +from rich.table import Table +from pathlib import Path +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.application import get_app + + +bindings = KeyBindings() + + +class InteractiveShell(BasePrompt): + + def __init__(self, prompt=[], config={}, session={}): + super().__init__() + self._prompt = prompt + self._config = config + self._wmt = "" + self._subshells = {} + self._register_subshells() + self._register_keybindings() + self._session = session + + def _register_keybindings(self): + + @bindings.add('c-q') + @bindings.add('c-d') + def quit(event): + self.quit() + + @bindings.add('c-h') + def help(event): + self.help() + + @bindings.add('c-w') + def wmt(event): + self.wmt() + + def _register_subshells(self): + for subclass in BasePrompt.__subclasses__(): + if subclass.__name__ == self.__class__.__name__: + continue + self._subshells[subclass.__name__] = subclass(parent=self) + + @property + def key_bindings(self): + return bindings + + @property + def toolbar(self): + return [ + ('class:bold', ' DMSH '), + ('', " [H]elp "), + ('', " [W]mt "), + ('', " [Q]uit "), + ] + + @property + def session(self): + return self._session + + @property + def autocomplete_values(self): + return list(self.commands.keys()) + + def default_completer(self, document, complete_event): # pragma: no cover + word = document.current_line_before_cursor + raise Exception(word) + + def process(self, cmd, *parts): + if cmd in self.commands: + return self.commands[cmd].handler(self, parts) + return "Unknown Command; try help." + + @command(usage=""" + [title]QUIT[/title] + + The [b]quit[/b] command exits dmsh. + + [title]USAGE[/title] + + [link]> quit|^D|[/link] + """) + def quit(self, *parts): + """ + Quit dmsh. + """ + get_app().exit() + raise SystemExit("Okay BYEEEE") + + @command(usage=""" + [title]HELP FOR THE HELP LORD[/title] + + The [b]help[/b] command will print usage information for whatever you're currently + doing. You can also ask for help on any command currently available. + + [title]USAGE[/title] + + [link]> help [COMMAND][/link] + """) + def help(self, *parts): + """ + Display the help message. + """ + super().help(parts) + return True + + @command(usage=""" + [title]INCREMENT DATE[/title] + + [b]id[/b] Increments the calendar date by one day. + + [title]USAGE[/title] + + [link]id[/link] + """) + def id(self, *parts): + """ + Increment the date by one day. + """ + raise NotImplementedError() + + @command(usage=""" + [title]LOCATION[/title] + + [b]loc[/b] sets the party's location to the specified region of the Sahwat Desert. + + [title]USAGE[/title] + + [link]loc LOCATION[/link] + """, + completer=WordCompleter([ + "The Blooming Wastes", + "Dust River Canyon", + "Gopher Gulch", + "Calamity Ridge" + ])) + def loc(self, *parts): + """ + Move the party to a new region of the Sahwat Desert. + """ + if parts: + self.session['location'] = (' '.join(parts)) + self.console.print(f"The party is in {self.session['location']}.") + + @command(usage=""" + [title]OVERLAND TRAVEL[/title] + + [b]ot[/b] + + [title]USAGE[/title] + + [link]ot in[/link] + """) + def ot(self, *parts): + """ + Increment the date by one day and record + """ + raise NotImplementedError() + + @command(usage=""" + [title]WILD MAGIC TABLE[/title] + + [b]wmt[/b] Generates a d20 wild magic surge roll table. The table will be cached for the session. + + [title]USAGE[/title] + + [link]> wmt[/link] + + [title]CLI[/title] + + [link]roll-table \\ + sources/sahwat_magic_table.yaml \\ + --frequency default --die 20[/link] + """) + def wmt(self, *parts, source='sahwat_magic_table.yaml'): + """ + Generate a Wild Magic Table for resolving spell effects. + """ + if not self._wmt: + rt = RollTable( + [Path(f"{self._config['table_sources_path']}/{source}").read_text()], + frequency='default', + die=20, + ) + table = Table(*rt.expanded_rows[0]) + for row in rt.expanded_rows[1:]: + table.add_row(*row) + self._wmt = table + self.console.print(self._wmt)