├── CONTRIBUTING.md ├── tests ├── __init__.py ├── test_contrib.py ├── test_tutorial.py ├── utils.py ├── test_file_loader.py ├── test_parse.py ├── test_table.py ├── test_cli.py ├── test_schemas.py └── test_markdown.py ├── lookatme ├── render │ ├── __init__.py │ ├── asciinema.py │ ├── pygments.py │ └── markdown_inline.py ├── widgets │ ├── __init__.py │ ├── clickable_text.py │ └── table.py ├── __init__.py ├── themes │ ├── dark.py │ ├── __init__.py │ └── light.py ├── exceptions.py ├── prompt.py ├── log.py ├── slide.py ├── ascii_art.py ├── config.py ├── contrib │ ├── file_loader.py │ ├── terminal.py │ └── __init__.py ├── __main__.py ├── pres.py ├── utils.py ├── tutorial.py ├── parser.py └── schemas.py ├── examples ├── nasa_orion.jpg ├── file_loader_ext │ ├── 1x1.png │ ├── hello_world.py │ └── example.md ├── progressive.md ├── calendar_contrib │ ├── example.md │ ├── setup.py │ ├── lookatme │ │ └── contrib │ │ │ └── calendar.py │ └── README.md ├── terminal_ext │ └── example.md └── tour.md ├── docs ├── source │ ├── _static │ │ ├── lookatme_dark1.png │ │ ├── lookatme_dark2.png │ │ ├── lookatme_dark3.png │ │ ├── lookatme_light1.png │ │ ├── lookatme_light2.png │ │ ├── lookatme_light3.png │ │ ├── lookatme_tour.gif │ │ ├── ext_terminal_example.gif │ │ ├── lookatme_live_updates.gif │ │ ├── ext_file_loader_example.gif │ │ └── lookatme_smart_splitting.gif │ ├── dark_theme.rst │ ├── light_theme.rst │ ├── smart_splitting.rst │ ├── builtin_extensions │ │ ├── index.rst │ │ ├── file_loader.rst │ │ └── terminal.rst │ ├── index.rst │ ├── slides.rst │ ├── contrib_extensions.rst │ ├── style_precedence.rst │ ├── getting_started.rst │ └── conf.py ├── Makefile └── make.bat ├── requirements.txt ├── presentations └── san_diego_python_meetup │ ├── examples │ ├── lookatme_qrcode.md │ ├── mdp.md │ └── patat.md │ ├── source │ └── minimal_flask.py │ └── 2019-12-20.md ├── requirements.test.txt ├── MANIFEST.in ├── .gitignore ├── .github ├── release.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── pull_requests.yml │ ├── testing.yml │ ├── grapevine.yml │ ├── new_release.yml │ └── preview.yml ├── tox.ini ├── LICENSE ├── bin ├── _utils.sh ├── fill_placeholders └── ci ├── setup.py ├── CODE_OF_CONDUCT.md └── README.md /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lookatme/render/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lookatme/render/asciinema.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lookatme/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lookatme/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = __version__ = "{{VERSION}}" 2 | -------------------------------------------------------------------------------- /examples/nasa_orion.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d0c-s4vage/lookatme/HEAD/examples/nasa_orion.jpg -------------------------------------------------------------------------------- /examples/file_loader_ext/1x1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d0c-s4vage/lookatme/HEAD/examples/file_loader_ext/1x1.png -------------------------------------------------------------------------------- /docs/source/_static/lookatme_dark1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d0c-s4vage/lookatme/HEAD/docs/source/_static/lookatme_dark1.png -------------------------------------------------------------------------------- /docs/source/_static/lookatme_dark2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d0c-s4vage/lookatme/HEAD/docs/source/_static/lookatme_dark2.png -------------------------------------------------------------------------------- /docs/source/_static/lookatme_dark3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d0c-s4vage/lookatme/HEAD/docs/source/_static/lookatme_dark3.png -------------------------------------------------------------------------------- /docs/source/_static/lookatme_light1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d0c-s4vage/lookatme/HEAD/docs/source/_static/lookatme_light1.png -------------------------------------------------------------------------------- /docs/source/_static/lookatme_light2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d0c-s4vage/lookatme/HEAD/docs/source/_static/lookatme_light2.png -------------------------------------------------------------------------------- /docs/source/_static/lookatme_light3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d0c-s4vage/lookatme/HEAD/docs/source/_static/lookatme_light3.png -------------------------------------------------------------------------------- /docs/source/_static/lookatme_tour.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d0c-s4vage/lookatme/HEAD/docs/source/_static/lookatme_tour.gif -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | marshmallow>=3.17.0,<4 2 | Click>=7,<9 3 | PyYAML>=5,<6 4 | mistune>=0.8,<1 5 | urwid>=2,<3 6 | Pygments>=2,<3 7 | -------------------------------------------------------------------------------- /docs/source/_static/ext_terminal_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d0c-s4vage/lookatme/HEAD/docs/source/_static/ext_terminal_example.gif -------------------------------------------------------------------------------- /docs/source/_static/lookatme_live_updates.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d0c-s4vage/lookatme/HEAD/docs/source/_static/lookatme_live_updates.gif -------------------------------------------------------------------------------- /presentations/san_diego_python_meetup/examples/lookatme_qrcode.md: -------------------------------------------------------------------------------- 1 | --- 2 | extensions: 3 | - qrcode 4 | --- 5 | 6 | ```qrcode 7 | a 8 | ``` 9 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-xdist 3 | pytest-mock 4 | pytest-cov 5 | pyright 6 | isort 7 | flake8 8 | autopep8 9 | six 10 | -------------------------------------------------------------------------------- /docs/source/_static/ext_file_loader_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d0c-s4vage/lookatme/HEAD/docs/source/_static/ext_file_loader_example.gif -------------------------------------------------------------------------------- /docs/source/_static/lookatme_smart_splitting.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d0c-s4vage/lookatme/HEAD/docs/source/_static/lookatme_smart_splitting.gif -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include tests *.py 2 | include README.* 3 | include LICENSE 4 | include LICENSE* 5 | include setup.* 6 | include requirements.txt 7 | -------------------------------------------------------------------------------- /examples/file_loader_ext/hello_world.py: -------------------------------------------------------------------------------- 1 | def hello_world(arg1): 2 | print(f"Hello World, {arg1}") 3 | 4 | hello_world(input("What is your name? ")) 5 | -------------------------------------------------------------------------------- /presentations/san_diego_python_meetup/examples/mdp.md: -------------------------------------------------------------------------------- 1 | %title: MDP Example 2 | %author: James Johnson 3 | 4 | -> # Test Title <- 5 | 6 | ```python 7 | print("hello") 8 | ``` 9 | -------------------------------------------------------------------------------- /lookatme/themes/dark.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines styles that should look good on dark backgrounds 3 | """ 4 | 5 | 6 | # the default settings for lookatme are dark settings 7 | theme = { 8 | } 9 | -------------------------------------------------------------------------------- /presentations/san_diego_python_meetup/examples/patat.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Patat Example 3 | author: James Johnson 4 | --- 5 | 6 | # Test Title 7 | 8 | ```python 9 | print("hello") 10 | ``` 11 | -------------------------------------------------------------------------------- /presentations/san_diego_python_meetup/source/minimal_flask.py: -------------------------------------------------------------------------------- 1 | import flask 2 | app = flask.Flask(__name__) 3 | 4 | @app.route("/", methods=["GET"]) 5 | def index(): 6 | return "Hello, World" 7 | 8 | -------------------------------------------------------------------------------- /lookatme/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exceptions used within lookatme 3 | """ 4 | 5 | 6 | class IgnoredByContrib(Exception): 7 | """Raised when a contrib module's function chooses to ignore the function 8 | call. 9 | """ 10 | pass 11 | -------------------------------------------------------------------------------- /lookatme/prompt.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic user-prompting helper functions 3 | """ 4 | 5 | 6 | def yes(msg): 7 | """Prompt the user for a yes/no answer. Returns bool 8 | """ 9 | answer = input("{} (Y/N) ".format(msg)) 10 | return answer.strip().lower() in ["y", "yes"] 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # editors 2 | .*.sw[hijklmnop] 3 | 4 | # python 5 | *.pyc 6 | __pycache__ 7 | .mypy_cache 8 | .tox 9 | venv* 10 | .coverage 11 | *.egg-info 12 | 13 | # docs 14 | docs/build 15 | docs/source/autodoc 16 | 17 | # autogenerated documentation 18 | docs/source/contrib_extensions_auto.rst 19 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | categories: 6 | - title: New Features 7 | labels: 8 | - enhancement 9 | - title: Bug fixes 10 | labels: 11 | - bug 12 | - title: Other Changes 13 | labels: 14 | - "*" 15 | -------------------------------------------------------------------------------- /examples/progressive.md: -------------------------------------------------------------------------------- 1 | # This is a normal slide 2 | 3 | With some content. 4 | 5 | --- 6 | 7 | # This is a multi-part slide 8 | 9 | - This is the first part. 10 | 11 | 12 | 13 | - And this is the second part. 14 | 15 | 16 | 17 | - Parts are separated by a `` paragraph. 18 | -------------------------------------------------------------------------------- /examples/calendar_contrib/example.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: An Awesome Presentation 3 | author: James Johnson 4 | date: 2019-11-19 5 | extensions: 6 | - calendar 7 | --- 8 | 9 | # Calendar example 10 | 11 | Below should be a calendar of the current month! 12 | 13 | ```calendar 14 | 15 | ``` 16 | 17 | There is a quirk that the code-block must have at least one new-line in it. 18 | -------------------------------------------------------------------------------- /examples/terminal_ext/example.md: -------------------------------------------------------------------------------- 1 | # A Plain Terminal 2 | 3 | A plain bash terminal: 4 | 5 | ~~~md 6 | ```terminal8 7 | bash -il 8 | ``` 9 | ~~~ 10 | 11 | ```terminal8 12 | bash -il 13 | ``` 14 | 15 | # A While Loop 16 | 17 | ~~~md 18 | ```terminal8 19 | bash -c "while true ; do sleep 1 ; date ; done" 20 | ``` 21 | ~~~ 22 | 23 | ```terminal8 24 | bash -c "while true ; do sleep 1 ; date ; done" 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/source/dark_theme.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _dark_theme: 3 | 4 | Dark Theme 5 | ========== 6 | 7 | The dark theme is intended to appear well on terminals with dark backgrounds 8 | 9 | .. image:: _static/lookatme_dark1.png 10 | :width: 600 11 | :alt: Dark Theme 1 12 | 13 | .. image:: _static/lookatme_dark2.png 14 | :width: 600 15 | :alt: Dark Theme 2 16 | 17 | .. image:: _static/lookatme_dark3.png 18 | :width: 600 19 | :alt: Dark Theme 2 20 | -------------------------------------------------------------------------------- /docs/source/light_theme.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _light_theme: 3 | 4 | Light Theme 5 | =========== 6 | 7 | The light theme is intended to appear well on terminals with light backgrounds 8 | 9 | .. image:: _static/lookatme_light1.png 10 | :width: 600 11 | :alt: Light Theme 1 12 | 13 | .. image:: _static/lookatme_light2.png 14 | :width: 600 15 | :alt: Light Theme 2 16 | 17 | .. image:: _static/lookatme_light3.png 18 | :width: 600 19 | :alt: Light Theme 2 20 | -------------------------------------------------------------------------------- /examples/calendar_contrib/setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup for lookatme.contrib.calender example 3 | """ 4 | 5 | 6 | from setuptools import setup, find_namespace_packages 7 | import os 8 | 9 | 10 | setup( 11 | name="lookatme.contrib.calendar", 12 | version="0.0.0", 13 | description="Adds a calendar code block type", 14 | author="James Johnson", 15 | author_email="d0c.s4vage@gmail.com", 16 | python_requires=">=3.5", 17 | packages=find_namespace_packages(include=["lookatme.*"]), 18 | ) 19 | -------------------------------------------------------------------------------- /lookatme/log.py: -------------------------------------------------------------------------------- 1 | """ 2 | Logging module 3 | """ 4 | 5 | 6 | import logging 7 | 8 | 9 | def create_log(log_path): 10 | """Create a new log that writes to log_path 11 | """ 12 | logging.basicConfig(filename=log_path, level=logging.DEBUG) 13 | return logging.getLogger("lookatme") 14 | 15 | 16 | def create_null_log(): 17 | """Create a logging object that does nothing 18 | """ 19 | logging.basicConfig(handlers=[logging.NullHandler()]) 20 | return logging.getLogger("lookatme") 21 | -------------------------------------------------------------------------------- /lookatme/slide.py: -------------------------------------------------------------------------------- 1 | """ 2 | Slide info holder 3 | """ 4 | 5 | 6 | class Slide(object): 7 | """This class defines a single slide. It operates on mistune's lexed 8 | tokens from the input markdown 9 | """ 10 | 11 | def __init__(self, tokens, number=0): 12 | """Create a new Slide instance with the provided tokens 13 | 14 | :param list tokens: A list of mistune tokens 15 | :param int number: The slide number 16 | """ 17 | self.tokens = tokens 18 | self.number = number 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Create a feature proposal 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the Feature Request** 10 | A clear and concise description of what the proposed feature is. 11 | 12 | **Example Markdown** 13 | If applicable, include sample Markdown that demonstrates the new feature 14 | 15 | **Screenshot/Wireframe** 16 | If applicable, add screenshots to help explain your proposed feature. 17 | 18 | **Additional notes** 19 | Add any other notes about the feature here. 20 | -------------------------------------------------------------------------------- /lookatme/themes/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines the built-in styles for lookatme 3 | """ 4 | 5 | 6 | from typing import Any, Dict 7 | 8 | from lookatme.schemas import StyleSchema 9 | from lookatme.utils import dict_deep_update 10 | 11 | 12 | def ensure_defaults(mod) -> Dict[str, Any]: 13 | """Ensure that all required attributes exist within the provided module 14 | """ 15 | defaults = StyleSchema().dump(None) 16 | dict_deep_update(defaults, mod.theme) 17 | 18 | if not isinstance(defaults, dict): 19 | raise ValueError("Schemas didn't return a dict") 20 | 21 | return defaults 22 | -------------------------------------------------------------------------------- /lookatme/ascii_art.py: -------------------------------------------------------------------------------- 1 | """ 2 | Misc ASCII art 3 | """ 4 | 5 | WARNING = r""" 6 | _mBma 7 | sQf "QL 8 | jW( -$g. 9 | jW' -$m, 10 | .y@' _aa. 4m, 11 | .mD` ]QQWQ. 4Q, 12 | _mP` ]QQQQ ?Q/ 13 | _QF )WQQ@ ?Qc 14 | NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 James Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/calendar_contrib/README.md: -------------------------------------------------------------------------------- 1 | # Sample Calendar Contrib 2 | 3 | This is a sample lookatme.contrib module that overrides code blocks when the 4 | language is `calendar` to display a text calendar of the current month. 5 | 6 | ## Example Usage 7 | 8 | Install this python package into your virtual environment: 9 | 10 | ``` 11 | pip install ./examples/calendar_contrib 12 | ``` 13 | 14 | List the `calendar` extension in the `extensions` section of your slide deck's 15 | YAML header: 16 | 17 | ~~~markdown 18 | --- 19 | title: An Awesome Presentation 20 | author: James Johnson 21 | date: 2019-11-19 22 | extensions: 23 | - calendar 24 | --- 25 | ~~~ 26 | 27 | With the extension available and declared, you can now use `calendar` code 28 | blocks to display a calendar of the current month on a slide: 29 | 30 | ~~~markdown 31 | ```calendar 32 | 33 | ``` 34 | ~~~ 35 | 36 | ## Future Options 37 | 38 | If one desired to build more on top of this toy calendar extension, additional 39 | options could be entered as yaml within the code block: 40 | 41 | ```calendar 42 | from: 2019-05-01 43 | to: 2019-11-01 44 | ``` 45 | 46 | **NOTE** These are not implemented in this toy extension example. 47 | -------------------------------------------------------------------------------- /docs/source/builtin_extensions/file_loader.rst: -------------------------------------------------------------------------------- 1 | 2 | File Loader Extension 3 | ===================== 4 | 5 | The :any:`lookatme.contrib.file_loader` builtin extension allows external 6 | files to be sourced into the code block, optionally being transformed and 7 | optionally restricting the range of lines to display. 8 | 9 | Format 10 | ------ 11 | 12 | The file loader extension modifies the code block markdown rendering by intercepting 13 | code blocks whose language equals ``file``. The contents of the code block must 14 | be YAML that conforms to the :any:`FileSchema` schema. 15 | 16 | The default schema is shown below: 17 | 18 | .. code-block:: yaml 19 | 20 | path: path/to/the/file # required 21 | relative: true # relative to the slide source directory 22 | lang: text # pygments language to render in the code block 23 | transform: null # optional shell command to transform the file data 24 | lines: 25 | start: 0 26 | end: null 27 | 28 | .. note:: 29 | 30 | The line range is only applied **AFTER** transformations are performed on 31 | the file data. 32 | 33 | Usage 34 | ----- 35 | 36 | E.g. 37 | 38 | .. code-block:: md 39 | 40 | ```file 41 | path: ../source/main.c 42 | lang: c 43 | ``` 44 | -------------------------------------------------------------------------------- /lookatme/themes/light.py: -------------------------------------------------------------------------------- 1 | 2 | theme = { 3 | "style": "pastie", 4 | "title": { 5 | "fg": "#f20,bold,italics", 6 | "bg": "", 7 | }, 8 | "author": { 9 | "fg": "#f20,bold", 10 | "bg": "", 11 | }, 12 | "date": { 13 | "fg": "#222", 14 | "bg": "", 15 | }, 16 | "slides": { 17 | "fg": "#f20,bold", 18 | "bg": "", 19 | }, 20 | "quote": { 21 | "style": { 22 | "fg": "italics,#444", 23 | "bg": "default", 24 | }, 25 | }, 26 | "hrule": { 27 | "style": { 28 | "fg": "#555", 29 | "bg": "default", 30 | } 31 | }, 32 | "link": { 33 | "fg": "#22f,underline,bold", 34 | "bg": "default", 35 | }, 36 | "headings": { 37 | '1': { 38 | "fg": "#12c,bold", 39 | "bg": "default", 40 | }, 41 | '2': { 42 | "fg": "#31c,bold", 43 | "bg": "default", 44 | }, 45 | '3': { 46 | "fg": "#71c,bold", 47 | "bg": "default", 48 | }, 49 | "4": { 50 | "fg": "#91c,bold", 51 | "bg": "default", 52 | }, 53 | "default": { 54 | "fg": "#91c,bold", 55 | "bg": "default", 56 | }, 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/pull_requests.yml: -------------------------------------------------------------------------------- 1 | name: New Pull Request Automation 2 | on: 3 | pull_request: 4 | types: [ opened, reopened ] 5 | 6 | jobs: 7 | add_labels: 8 | continue-on-error: true 9 | runs-on: ubuntu-latest 10 | if: startsWith(github.head_ref, 'feature-') || startsWith(github.head_ref, 'hotfix-') 11 | permissions: 12 | issues: write 13 | pull-requests: write 14 | steps: 15 | - uses: actions/github-script@v6 16 | with: 17 | script: | 18 | let refName = "${{ github.head_ref }}"; 19 | core.info("Ref name: " + refName); 20 | let labels = []; 21 | if (refName.startsWith("feature-")) { 22 | core.info("Is enhancement (feature)"); 23 | labels.push("enhancement"); 24 | } else if (refName.startsWith("hotfix-")) { 25 | core.info("Is bug (hotfix)"); 26 | labels.push("bug"); 27 | } 28 | 29 | core.info("labels: >> " + labels.join(",") + " <<"); 30 | if (labels.length == 0) { 31 | return; 32 | } 33 | 34 | github.rest.issues.addLabels({ 35 | issue_number: context.issue.number, 36 | owner: context.repo.owner, 37 | repo: context.repo.repo, 38 | labels: labels, 39 | }) 40 | -------------------------------------------------------------------------------- /tests/test_contrib.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module tests contrib-specific functionality 3 | """ 4 | 5 | import urwid 6 | from six.moves import StringIO, reload_module # type: ignore 7 | 8 | import lookatme.config 9 | import lookatme.contrib 10 | import lookatme.contrib.file_loader 11 | import lookatme.contrib.terminal 12 | import lookatme.pres 13 | import lookatme.render.markdown_block 14 | import lookatme.render.markdown_inline 15 | import lookatme.tui 16 | 17 | 18 | def setup_contrib(fake_mod): 19 | lookatme.contrib.CONTRIB_MODULES = [ 20 | lookatme.contrib.terminal, 21 | lookatme.contrib.file_loader, 22 | fake_mod 23 | ] 24 | reload_module(lookatme.render.markdown_block) 25 | reload_module(lookatme.render.markdown_inline) 26 | reload_module(lookatme.tui) 27 | 28 | 29 | def test_overridable_root(mocker): 30 | """Ensure that the root urwid component is overridable 31 | """ 32 | lookatme.config.LOG = mocker.Mock() 33 | 34 | class Wrapper(urwid.WidgetWrap): 35 | pass 36 | 37 | class FakeMod: 38 | @staticmethod 39 | def root_urwid_widget(to_wrap): 40 | return Wrapper(to_wrap) 41 | 42 | setup_contrib(FakeMod) 43 | input_stream = StringIO("test") 44 | pres = lookatme.pres.Presentation(input_stream, "dark") 45 | tui = lookatme.tui.MarkdownTui(pres) 46 | 47 | assert isinstance(tui.loop.widget, Wrapper) 48 | -------------------------------------------------------------------------------- /bin/_utils.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | LOG_PREFIX="[>>>]" 4 | 5 | function check_deps { 6 | local log_missing=false 7 | if [ "$1" == "--log" ] ; then 8 | log_missing=true 9 | shift 10 | fi 11 | 12 | exit_code=0 13 | while [ $# -gt 0 ] ; do 14 | if ! command -v "$1" &> /dev/null ; then 15 | exit_code=1 16 | if [ "$log_missing" == true ] ; then 17 | log "Dependency '$1' is missing" 18 | fi 19 | fi 20 | shift 21 | done 22 | return $exit_code 23 | } 24 | 25 | function log { 26 | echo "${LOG_PREFIX} $@" 27 | } 28 | 29 | function log_indent { 30 | sed "s/^/ /g" 31 | } 32 | 33 | function log_box_indent { 34 | log ' ╭──────' 35 | sed "s/^/${LOG_PREFIX} │ /g" 36 | log ' ╰──────' 37 | } 38 | 39 | function indent { 40 | sed "s/^/ /g" 41 | } 42 | 43 | function box_indent { 44 | echo ' ╭──────' 45 | sed "s/^/ │ /g" 46 | echo ' ╰──────' 47 | } 48 | 49 | function run_boxed { 50 | log "Running command '$@'" 51 | "$@" 2>&1 | log_box_indent 52 | } 53 | 54 | function run_with_summary { 55 | exit_code=0 56 | results=() 57 | while [ "$#" -gt 0 ] ; do 58 | "$1" 59 | if [ $? -ne 0 ] ; then 60 | exit_code=$? 61 | results+=(" FAIL: $1") 62 | else 63 | results+=("SUCCESS: $1") 64 | fi 65 | shift 66 | done 67 | 68 | for result in "${results[@]}" ; do 69 | log "RESULT: ${result}" 70 | done 71 | } 72 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup for lookatme 3 | """ 4 | 5 | 6 | from setuptools import setup, find_packages 7 | import os 8 | 9 | 10 | req_path = os.path.join(os.path.dirname(__file__), "requirements.txt") 11 | with open(req_path, "r") as f: 12 | required = f.read().splitlines() 13 | 14 | 15 | readme_path = os.path.join(os.path.dirname(__file__), "README.md") 16 | with open(readme_path, "r") as f: 17 | readme = f.read() 18 | 19 | 20 | setup( 21 | name="lookatme", 22 | version="{{VERSION}}", 23 | description="An interactive, command-line presentation tool", 24 | author="James Johnson", 25 | author_email="d0c.s4vage@gmail.com", 26 | url="https://github.com/d0c-s4vage/lookatme", 27 | long_description=readme, 28 | long_description_content_type="text/markdown", 29 | python_requires=">=3.6", 30 | packages=find_packages(exclude=["docs", ".gitignore", "README.md", "tests"]), 31 | install_requires=required, 32 | classifiers=[ 33 | "Environment :: Console", 34 | "Environment :: Plugins", 35 | "Intended Audience :: Developers", 36 | "License :: OSI Approved :: MIT License", 37 | "Operating System :: OS Independent", 38 | "Programming Language :: Python :: 3", 39 | "Programming Language :: Python :: 3 :: Only", 40 | "Topic :: Multimedia :: Graphics :: Presentation", 41 | "Topic :: Software Development :: Documentation", 42 | ], 43 | entry_points={ 44 | "console_scripts": [ 45 | "lookatme = lookatme.__main__:main", 46 | "lam = lookatme.__main__:main", 47 | "witnessme = lookatme.__main__:main", 48 | ] 49 | }, 50 | ) 51 | -------------------------------------------------------------------------------- /lookatme/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Config module for lookatme 3 | """ 4 | 5 | 6 | import logging 7 | import os 8 | from types import ModuleType 9 | from typing import Any, Dict 10 | 11 | import lookatme.themes 12 | from lookatme.utils import dict_deep_update 13 | 14 | LOG = None 15 | STYLE: Dict[str, Any] = {} 16 | 17 | 18 | def get_log() -> logging.Logger: 19 | if LOG is None: 20 | raise Exception("LOG was None") 21 | return LOG 22 | 23 | 24 | def get_style() -> Dict: 25 | if not STYLE: 26 | raise Exception("STYLE was empty!") 27 | return STYLE 28 | 29 | 30 | def get_style_with_precedence( 31 | theme_mod: ModuleType, 32 | direct_overrides: Dict, 33 | style_override: str 34 | ) -> Dict[str, Any]: 35 | """Return the resulting style dict from the provided override values. 36 | """ 37 | # style override order: 38 | # 1. theme settings 39 | styles = lookatme.themes.ensure_defaults(theme_mod) 40 | # 2. inline styles from the presentation 41 | dict_deep_update(styles, direct_overrides) 42 | # 3. CLI style overrides 43 | if style_override is not None: 44 | styles["style"] = style_override # type: ignore 45 | 46 | return styles 47 | 48 | 49 | def set_global_style_with_precedence( 50 | theme_mod, 51 | direct_overrides, 52 | style_override 53 | ) -> Dict[str, Any]: 54 | """Set the lookatme.config.STYLE value based on the provided override 55 | values 56 | """ 57 | global STYLE 58 | STYLE = get_style_with_precedence( 59 | theme_mod, direct_overrides, style_override) 60 | 61 | return STYLE 62 | 63 | 64 | # default to the current working directory - this will be set later by 65 | # pres:Presentation when reading the input stream 66 | SLIDE_SOURCE_DIR = os.getcwd() 67 | -------------------------------------------------------------------------------- /tests/test_tutorial.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test tutorial functionality 3 | """ 4 | 5 | 6 | import inspect 7 | 8 | import lookatme.tutorial as tutorial 9 | 10 | 11 | def test_real_tutorials_exist(): 12 | assert "general" in tutorial.GROUPED_TUTORIALS 13 | assert "markdown" in tutorial.GROUPED_TUTORIALS 14 | 15 | 16 | def test_tutorial_basic(mocker): 17 | """Test that tutorials work correctly 18 | """ 19 | mocker.patch("lookatme.tutorial.GROUPED_TUTORIALS", {}) 20 | mocker.patch("lookatme.tutorial.NAMED_TUTORIALS", {}) 21 | 22 | @tutorial.tutor("category", "name", "contents") 23 | def some_function(): 24 | pass 25 | 26 | assert "category" in tutorial.GROUPED_TUTORIALS 27 | assert "name" in tutorial.GROUPED_TUTORIALS["category"] 28 | 29 | category_md = tutorial.get_tutorial_md(["category"]) 30 | assert category_md is not None 31 | 32 | name_md = tutorial.get_tutorial_md(["name"]) 33 | assert name_md is not None 34 | 35 | assert category_md == name_md 36 | assert "# Category: Name" in category_md 37 | assert "contents" in category_md 38 | 39 | 40 | def test_tutor(mocker): 41 | mocker.patch("lookatme.config.STYLE", {"test": {"test": "hello"}}) 42 | tutor = tutorial.Tutor( 43 | "name", 44 | "group", 45 | "\n".join([ 46 | "contents", 47 | "test", 48 | ]), 49 | impl_fn=lambda _: 10, 50 | order=99999, 51 | ) 52 | 53 | md_text = tutor.get_md() 54 | 55 | md_example = "> ~~~markdown\n> contents\n> ~~~" 56 | assert md_example in md_text 57 | 58 | md_rendered = "\ncontents" 59 | assert md_rendered in md_text 60 | 61 | style_yaml = inspect.cleandoc(""" 62 | ```yaml 63 | --- 64 | styles: 65 | test: 66 | test: hello 67 | --- 68 | ``` 69 | """).strip() 70 | assert style_yaml in md_text 71 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines utilities for testing lookatme 3 | """ 4 | 5 | 6 | import urwid 7 | 8 | import lookatme.config 9 | import lookatme.tui 10 | from lookatme.parser import Parser 11 | 12 | 13 | def setup_lookatme(tmpdir, mocker, style=None): 14 | mocker.patch.object(lookatme.config, "LOG") 15 | mocker.patch("lookatme.config.SLIDE_SOURCE_DIR", new=str(tmpdir)) 16 | 17 | if style is not None: 18 | mocker.patch("lookatme.config.STYLE", new=style) 19 | 20 | 21 | def assert_render(correct_render, rendered, full_strip=False): 22 | for idx, row in enumerate(rendered): 23 | if full_strip: 24 | stripped = row_text(row).strip() 25 | else: 26 | stripped = row_text(row).rstrip() 27 | if idx >= len(correct_render): 28 | assert stripped == b"" 29 | else: 30 | assert correct_render[idx] == stripped 31 | 32 | 33 | def render_markdown(markdown, height=50, width=200, single_slide=False): 34 | """Returns the rendered canvas contents of the markdown 35 | """ 36 | loop = urwid.MainLoop(urwid.ListBox([])) 37 | renderer = lookatme.tui.SlideRenderer(loop) 38 | renderer.start() 39 | 40 | parser = Parser(single_slide=single_slide) 41 | _, slides = parser.parse_slides({"title": ""}, markdown) 42 | 43 | renderer.stop() 44 | contents = renderer.render_slide(slides[0], force=True) 45 | renderer.join() 46 | 47 | container = urwid.ListBox([urwid.Text("testing")]) 48 | container.body = contents 49 | return list(container.render((width, height)).content()) 50 | 51 | 52 | def spec_and_text(item): 53 | """``item`` should be an item from a rendered widget, a tuple of the form 54 | 55 | .. code-block:: python 56 | 57 | (spec, ?, text) 58 | """ 59 | return item[0], item[2] 60 | 61 | 62 | def row_text(rendered_row): 63 | """Return all text joined together from the rendered row 64 | """ 65 | return b"".join(x[-1] for x in rendered_row) 66 | -------------------------------------------------------------------------------- /docs/source/builtin_extensions/terminal.rst: -------------------------------------------------------------------------------- 1 | 2 | Terminal Extension 3 | ================== 4 | 5 | The :any:`lookatme.contrib.terminal` builtin extension allows terminals to be 6 | embedded within slides. 7 | 8 | Basic Format 9 | ------------ 10 | 11 | The terminal extension modifies the code block markdown rendering by intercepting 12 | code blocks whose language has the format ``terminal\d+``. The number following 13 | the ``terminal`` string indicates how many rows the terminal should use when 14 | rendered (the height). 15 | 16 | Usage 17 | ***** 18 | 19 | E.g. 20 | 21 | .. code-block:: md 22 | 23 | ```terminal8 24 | bash -il 25 | ``` 26 | 27 | The content of the code block is the command to be run in the terminal. Clicking 28 | inside of the terminal gives the terminal focus, which will allow you to 29 | interact with it, type in it, etc. 30 | 31 | To escape from the terminal, press ``ctrl+a``. 32 | 33 | Extended Format 34 | --------------- 35 | 36 | The terminal extension also has a `terminal-ex` mode that can be used as the 37 | language in a code block. When `terminal-ex` is used, the contents of the code 38 | block must be YAML that conforms to the :any:`TerminalExSchema` schema. 39 | 40 | The default schema is shown below: 41 | 42 | .. code-block:: yaml 43 | 44 | command: "the command to run" # required 45 | rows: 10 # number of rows for the terminal (height) 46 | init_text: null # initial text to feed to the command. This is 47 | # useful to, e.g., pre-load text on a 48 | # bash prompt so that only "enter" must be 49 | # pressed. Uses the `expect` command. 50 | init_wait: null # the prompt (string) to wait for with `expect` 51 | # this is required if init_text is set. 52 | init_codeblock: true # show a codeblock with the init_text as its 53 | # content 54 | init_codeblock_lang: text # the language of the init codeblock 55 | 56 | Usage 57 | ***** 58 | 59 | E.g. 60 | 61 | .. code-block:: md 62 | 63 | ```terminal-ex 64 | command: bash -il 65 | rows: 20 66 | init_text: echo hello 67 | init_wait: '$> ' 68 | init_codeblock_lang: bash 69 | ``` 70 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. lookatme documentation master file, created by 2 | sphinx-quickstart on Mon Dec 2 06:35:10 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | lookatme 7 | ======== 8 | 9 | ``lookatme`` is an interactive, terminal-based markdown presentation tool that 10 | supports: 11 | 12 | * Themes 13 | * Syntax highlighting 14 | * Styling and settings embedded within the Markdown YAML header 15 | * Embedded terminals as part of a presentation 16 | * Live and manual source reloading 17 | * Contrib extensions 18 | * Smart Slide Splitting 19 | 20 | Tour 21 | ---- 22 | 23 | .. image:: _static/lookatme_tour.gif 24 | :width: 800 25 | :alt: Tour Gif 26 | 27 | TL;DR Getting Started 28 | --------------------- 29 | 30 | Install ``lookatme`` with: 31 | 32 | .. code-block:: bash 33 | 34 | pip install lookatme 35 | 36 | Run lookatme on slides written in Markdown: 37 | 38 | .. code-block:: bash 39 | 40 | lookatme slides.md 41 | 42 | Slides are separated with ``---`` hrules: 43 | 44 | .. code-block:: md 45 | 46 | # Slide 1 47 | 48 | Some text 49 | 50 | --- 51 | 52 | # Slide 2 53 | 54 | More text 55 | 56 | A basic, optional YAML header may be included at the top of the slides: 57 | 58 | .. code-block:: md 59 | 60 | --- 61 | title: Slides Presentation 62 | author: Me Not You 63 | date: 2019-12-02 64 | --- 65 | 66 | # Slide 1 67 | 68 | Some text 69 | 70 | Slides can be progressively rendered by adding ```` comments 71 | between block elements (paragraphs, tables, lists, etc.): 72 | 73 | .. code-block:: md 74 | 75 | # Progressive Slide 76 | 77 | 78 | 79 | Paragraph 1 80 | 81 | 82 | 83 | Paragraph 2 84 | 85 | 86 | 87 | Paragraph 3 88 | 89 | .. toctree:: 90 | :maxdepth: 2 91 | 92 | getting_started 93 | slides 94 | dark_theme 95 | light_theme 96 | style_precedence 97 | contrib_extensions_auto 98 | smart_splitting 99 | builtin_extensions/index 100 | 101 | 102 | .. toctree:: 103 | 104 | autodoc/modules.rst 105 | 106 | 107 | Indices and tables 108 | ================== 109 | 110 | * :ref:`genindex` 111 | * :ref:`modindex` 112 | * :ref:`search` 113 | -------------------------------------------------------------------------------- /bin/fill_placeholders: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 5 | THIS_PROG="$0" 6 | SUBPROGRAM="ls" 7 | 8 | . "$DIR"/_utils.sh 9 | 10 | 11 | function show_help { 12 | cat <<-EOF 13 | USAGE: $THIS_PROG VERSION_NUMBER 14 | 15 | This script will fill all placeholder values in the lookatme me project. 16 | Specifically: 17 | 18 | * {{VERSION}} will be replaced with the version number (no 'v' prefix!) 19 | * {{LOOKATME_HELP_OUTPUT}} will be replaced with output from "lookatme --help" 20 | * {{LOOKATME_HELP_OUTPUT_INDENTED}} will be replaced with the indented 21 | output of "lookatme --help" 22 | EOF 23 | } 24 | 25 | function replace_in_files { 26 | local placeholder="$1" 27 | local replacement="$2" 28 | 29 | log "==== Replacing $placeholder" 30 | ( 31 | cd "$DIR"/.. 32 | files_to_replace=$(grep -rl "$placeholder" $(git ls-files) | grep -v bin/fill_placeholders) 33 | python3 <(cat <<-EOF 34 | import sys 35 | 36 | placeholder = sys.argv[1] 37 | replacement = sys.argv[2] 38 | files_to_replace = sys.argv[3:] 39 | 40 | for file in files_to_replace: 41 | with open(file, "r") as f: 42 | data = f.read() 43 | 44 | new_data = data.replace(placeholder, replacement) 45 | if new_data != data: 46 | print(" replacing {}".format(file)) 47 | with open(file, "w") as f: 48 | f.write(new_data) 49 | EOF 50 | ) "$placeholder" "$replacement" $files_to_replace 51 | ) 52 | } 53 | 54 | function set_version { 55 | replace_in_files "{{VERSION}}" "$VERSION" 56 | } 57 | 58 | function set_help_output { 59 | local help_output=$(cd "$DIR"/.. ; python3 -m lookatme --help) 60 | replace_in_files "{{LOOKATME_HELP_OUTPUT}}" "$help_output" 61 | 62 | local help_output_indented=$(sed 's/^/ /' <<<$help_output) 63 | replace_in_files "{{LOOKATME_HELP_OUTPUT_INDENTED}}" "$help_output_indented" 64 | } 65 | 66 | 67 | function parse_args { 68 | VERSION="" 69 | while [ $# -ne 0 ] ; do 70 | param="$1" 71 | shift 72 | 73 | case "$param" in 74 | --help|-h) 75 | show_help 76 | exit 1 77 | ;; 78 | *) 79 | VERSION="$param" 80 | ;; 81 | esac 82 | done 83 | } 84 | 85 | parse_args "$@" 86 | # needs to come first, can contain {{VERSION}} placeholders 87 | set_help_output 88 | set_version 89 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: "testing" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | isort: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Setup Python 3.10 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.10" 18 | architecture: "x64" 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install -r requirements.test.txt 23 | - name: Check if imports are sorted 24 | run: | 25 | bin/ci isort --plain 26 | 27 | flake8: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v3 31 | - name: Setup Python 3.10 32 | uses: actions/setup-python@v4 33 | with: 34 | python-version: "3.10" 35 | architecture: "x64" 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install -r requirements.test.txt 40 | - name: Lint with flake8 41 | run: | 42 | bin/ci flake8 --plain 43 | 44 | pyright: 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v3 48 | - name: Setup Python 3.10 49 | uses: actions/setup-python@v4 50 | with: 51 | python-version: "3.10" 52 | architecture: "x64" 53 | - name: Install dependencies 54 | run: | 55 | python -m pip install --upgrade pip 56 | pip install -r requirements.txt -r requirements.test.txt 57 | - name: Analyze with pyright 58 | run: | 59 | bin/ci pyright --plain 60 | 61 | unit-test: 62 | runs-on: ubuntu-latest 63 | strategy: 64 | matrix: 65 | python-version: 66 | - "3.7" 67 | - "3.8" 68 | - "3.9" 69 | - "3.10" 70 | 71 | steps: 72 | - uses: actions/checkout@v3 73 | - name: Setup Python ${{ matrix.python-version }} 74 | uses: actions/setup-python@v4 75 | with: 76 | python-version: ${{ matrix.python-version }} 77 | - name: Install dependencies 78 | run: | 79 | python -m pip install --upgrade pip 80 | pip install -r requirements.txt -r requirements.test.txt 81 | - name: Test 82 | run: | 83 | bin/ci pytest --plain 84 | -------------------------------------------------------------------------------- /docs/source/slides.rst: -------------------------------------------------------------------------------- 1 | .. _slides: 2 | 3 | Slides 4 | ====== 5 | 6 | Slides in ``lookatme`` are: 7 | 8 | * Separated by hrule elements: ``---`` in Markdown 9 | * Resized to fit the current window 10 | 11 | Metadata 12 | -------- 13 | 14 | Slide metadata is contained within an optional YAML header: 15 | 16 | .. code-block:: md 17 | 18 | --- 19 | title: TITLE 20 | author: AUTHOR 21 | date: 2019-12-02 22 | extensions: [] 23 | styles: {} 24 | --- 25 | 26 | Additional, unknown metadata fields are allowed at the top level. However, the 27 | ``styles`` field and subfields are strictly validated. 28 | 29 | Extensions 30 | ^^^^^^^^^^ 31 | 32 | Extensions are lookatme contrib modules that redefine lookatme behavior. E.g., 33 | the ``lookatmecontrib.calendar`` example in the 34 | `examples folder `_ 35 | redefines the ``render_code`` function found in ``lookatme/render/markdown_block.py``. 36 | 37 | The original ``render_code`` function gives contrib extensions first-chance at 38 | handling any function calls. Contrib extensions are able to ignore function 39 | calls, and thus allow the default ``lookatme`` behavior, by raising the 40 | :any:`IgnoredByContrib` exception: 41 | 42 | .. code-block:: python 43 | 44 | import datetime 45 | import calendar 46 | import urwid 47 | 48 | 49 | from lookatme.exceptions import IgnoredByContrib 50 | 51 | 52 | def render_code(token, body, stack, loop): 53 | lang = token["lang"] or "" 54 | if lang != "calendar": 55 | raise IgnoredByContrib() 56 | 57 | today = datetime.datetime.utcnow() 58 | return urwid.Text(calendar.month(today.year, today.month)) 59 | 60 | Styles 61 | ^^^^^^ 62 | 63 | In addition to the ``--style`` and ``--theme`` CLI options for lookatme, the 64 | slide metadata may explicitly override styling behaviors within lookatme: 65 | 66 | .. code-block:: md 67 | 68 | --- 69 | title: TITLE 70 | author: AUTHOR 71 | date: 2019-12-02 72 | styles: 73 | style: monokai 74 | table: 75 | column_spacing: 3 76 | header_divider: "-" 77 | --- 78 | 79 | # Slide 1 80 | 81 | text 82 | 83 | The final, resolved styling settings that will be used when displaying a 84 | markdown source is viewable by adding the ``--dump-styles`` flag as a command-line 85 | argument. 86 | 87 | See the :ref:`default_style_settings` for a full list of available, overrideable 88 | styles. 89 | -------------------------------------------------------------------------------- /lookatme/widgets/clickable_text.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains code for ClickableText 3 | """ 4 | 5 | 6 | import urwid 7 | from urwid.util import is_mouse_press 8 | 9 | 10 | class LinkIndicatorSpec(urwid.AttrSpec): 11 | """Used to track a link within an urwid.Text instance 12 | """ 13 | 14 | def __init__(self, link_label, link_target, orig_spec): 15 | """Create a new LinkIndicator spec from an existing urwid.AttrSpec 16 | 17 | :param str link_label: The label for the link 18 | :param str link_target: The target url for the link 19 | """ 20 | self.link_label = link_label 21 | self.link_target = link_target 22 | 23 | urwid.AttrSpec.__init__( 24 | self, orig_spec.foreground, orig_spec.background) 25 | 26 | 27 | class ClickableText(urwid.Text): 28 | """Allows clickable/changing text to be part of the Text() contents 29 | """ 30 | 31 | signals = ["click", "change"] 32 | 33 | def mouse_event(self, size, event, button, x, y, focus): 34 | """Handle mouse events! 35 | """ 36 | if button != 1 or not is_mouse_press(event): 37 | return False 38 | 39 | total_offset = (y * size[0]) + x 40 | 41 | text, chunk_stylings = self.get_text() 42 | curr_offset = 0 43 | 44 | found_style = None 45 | found_text = None 46 | found_idx = 0 47 | found_length = 0 48 | 49 | for idx, info in enumerate(chunk_stylings): 50 | style, length = info 51 | if curr_offset < total_offset <= curr_offset + length: 52 | found_text = text[curr_offset:curr_offset + length] 53 | found_style = style 54 | found_idx = idx 55 | found_length = length 56 | break 57 | curr_offset += length 58 | 59 | if found_style is None or not isinstance(found_style, LinkIndicatorSpec): 60 | self._emit('click') 61 | return True 62 | 63 | # it's a link, so change the text and update the RLE! 64 | if found_text == found_style.link_label: 65 | new_text = found_style.link_target 66 | else: 67 | new_text = found_style.link_label 68 | text = text[:curr_offset] + new_text + text[curr_offset+found_length:] 69 | new_rle = len(new_text) 70 | 71 | chunk_stylings[found_idx] = (found_style, new_rle) 72 | 73 | self._text = text 74 | self._attrib = chunk_stylings 75 | self._invalidate() 76 | 77 | self._emit("change") 78 | 79 | return True 80 | -------------------------------------------------------------------------------- /.github/workflows/grapevine.yml: -------------------------------------------------------------------------------- 1 | name: Grapevine Tagging 2 | on: 3 | push: 4 | tags: 5 | - 'v*.*.*-grape' 6 | 7 | jobs: 8 | create-grape: 9 | name: Make a new grape 10 | runs-on: ubuntu-latest 11 | outputs: 12 | VERSION_NO_GRAPE: "${{ steps.vars.outputs.VERSION_NO_GRAPE }}" 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | token: ${{ github.token }} 17 | 18 | - name: Setup Python 3.10 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: "3.10" 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements.txt 27 | 28 | # make some environment variables available for all steps 29 | - name: Define variables 30 | id: vars 31 | run: | 32 | version_num="${{ github.ref_name }}" 33 | echo "VERSION_RAW=$version_num" >> "$GITHUB_ENV" 34 | # strip the trailing "-grape" 35 | version_no_grape=${version_num%-grape} 36 | echo "VERSION_NO_GRAPE=$version_no_grape" >> "$GITHUB_ENV" 37 | echo "VERSION_NO_GRAPE=$version_no_grape" >> "$GITHUB_OUTPUT" 38 | # strip the leading v 39 | version_plain=${version_no_grape#v} 40 | echo "VERSION_PLAIN=$version_plain" >> "$GITHUB_ENV" 41 | 42 | - name: Fill placeholders 43 | run: | 44 | bin/fill_placeholders "$VERSION_PLAIN" 45 | 46 | - name: Generate Changelog 47 | run: | 48 | echo "Generating changelog (TODO)" 49 | 50 | - name: Commit and tag grape 51 | run: | 52 | # get into a detached HEAD state by checking out the latest commit 53 | # directly 54 | git checkout $(git rev-parse HEAD) 55 | 56 | git config --global user.name "James Johnson" 57 | git config --global user.email "d0c-s4vage@users.noreply.github.com" 58 | 59 | git commit -am "Commit for $VERSION_NO_GRAPE" 60 | git tag "$VERSION_NO_GRAPE" 61 | git push --tags 62 | 63 | - name: Delete grape tag 64 | run: | 65 | git push --delete origin "$VERSION_RAW" 66 | 67 | - name: run other workflow 68 | run: | 69 | ls -la .github/workflows/ 70 | 71 | 72 | run_main_version_workflow: 73 | needs: create-grape 74 | # directly trigger the "new_release" workflow 75 | uses: ./.github/workflows/new_release.yml 76 | secrets: inherit 77 | with: 78 | ref: "${{ needs.create-grape.outputs.VERSION_NO_GRAPE }}" 79 | ref_name: "${{ needs.create-grape.outputs.VERSION_NO_GRAPE }}" 80 | -------------------------------------------------------------------------------- /tests/test_file_loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the file loader built-in extension 3 | """ 4 | 5 | 6 | import pytest 7 | 8 | import lookatme.config 9 | import lookatme.contrib.file_loader 10 | import lookatme.render.pygments 11 | from tests.utils import assert_render, render_markdown 12 | 13 | TEST_STYLE = { 14 | "style": "monokai", 15 | "headings": { 16 | "default": { 17 | "fg": "bold", 18 | "bg": "", 19 | "prefix": "|", 20 | "suffix": "|", 21 | }, 22 | }, 23 | } 24 | 25 | 26 | @pytest.fixture(autouse=True) 27 | def file_loader_setup(tmpdir, mocker): 28 | mocker.patch.object(lookatme.config, "LOG") 29 | mocker.patch("lookatme.config.SLIDE_SOURCE_DIR", new=str(tmpdir)) 30 | mocker.patch("lookatme.contrib.CONTRIB_MODULES", new=[ 31 | lookatme.contrib.file_loader 32 | ]) 33 | mocker.patch("lookatme.config.STYLE", new=TEST_STYLE) 34 | 35 | 36 | def test_file_loader(tmpdir, mocker): 37 | """Test the built-in file loader extension 38 | """ 39 | tmppath = tmpdir.join("test.py") 40 | tmppath.write("print('hello')") 41 | 42 | rendered = render_markdown(f""" 43 | ```file 44 | path: {tmppath} 45 | relative: false 46 | ``` 47 | """) 48 | 49 | stripped_rows = [ 50 | b'', 51 | b"print('hello')", 52 | b'', 53 | b'', 54 | b'', 55 | ] 56 | assert_render(stripped_rows, rendered) 57 | 58 | 59 | def test_file_loader_with_transform(tmpdir, mocker): 60 | """Test the built-in file loader extension 61 | """ 62 | tmppath = tmpdir.join("test.py") 63 | tmppath.write(""" 64 | Hello 65 | Apples2 66 | there 67 | Apples3 68 | there 69 | Apples1 70 | """) 71 | 72 | rendered = render_markdown(f""" 73 | ```file 74 | path: {tmppath} 75 | relative: false 76 | transform: "grep -i apples | sort" 77 | ``` 78 | """) 79 | 80 | stripped_rows = [ 81 | b'', 82 | b"Apples1", 83 | b'Apples2', 84 | b'Apples3', 85 | b'', 86 | b'', 87 | b'', 88 | ] 89 | assert_render(stripped_rows, rendered) 90 | 91 | 92 | def test_file_loader_relative(tmpdir, mocker): 93 | """Test the built-in file loader extension 94 | """ 95 | tmppath = tmpdir.join("test.py") 96 | tmppath.write("print('hello')") 97 | 98 | rendered = render_markdown(""" 99 | ```file 100 | path: test.py 101 | relative: true 102 | ``` 103 | """) 104 | 105 | stripped_rows = [ 106 | b'', 107 | b"print('hello')", 108 | b'', 109 | b'', 110 | b'', 111 | ] 112 | assert_render(stripped_rows, rendered) 113 | 114 | 115 | def test_file_loader_not_found(mocker): 116 | """Test the built-in file loader extension 117 | """ 118 | rendered = render_markdown(""" 119 | ```file 120 | path: does_not_exist.py 121 | ``` 122 | """) 123 | 124 | stripped_rows = [ 125 | b'', 126 | b"File not found", 127 | b'', 128 | b'', 129 | b'', 130 | ] 131 | assert_render(stripped_rows, rendered) 132 | -------------------------------------------------------------------------------- /tests/test_parse.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module tests that markdown presentations can be correctly parsed 3 | """ 4 | 5 | 6 | import datetime 7 | 8 | from lookatme.parser import Parser 9 | 10 | 11 | def test_parse_metadata(): 12 | """Test that metadata can be correctly parsed out of a markdown 13 | presentation 14 | """ 15 | title = "Presentation Title" 16 | date = "September 2, 2022" 17 | author = "The Author" 18 | 19 | input_data = f""" 20 | --- 21 | title: {title} 22 | date: {date} 23 | author: {author} 24 | --- 25 | remaining 26 | """ 27 | 28 | parser = Parser() 29 | input_data, meta = parser.parse_meta(input_data) 30 | assert input_data.strip() == "remaining" 31 | assert meta["title"] == title 32 | assert meta["date"] == "September 2, 2022" 33 | assert meta["author"] == author 34 | 35 | 36 | def test_parse_metadata_empty(): 37 | """Test that metadata can be correctly parsed out of a markdown 38 | presentation 39 | """ 40 | input_data = """ 41 | --- 42 | --- 43 | remaining 44 | """ 45 | 46 | parser = Parser() 47 | input_data, meta = parser.parse_meta(input_data) 48 | assert input_data.strip() == "remaining" 49 | now = datetime.datetime.now() 50 | assert meta["title"] == "" 51 | assert meta["date"] == now.strftime("%Y-%m-%d") 52 | assert meta["author"] == "" 53 | 54 | 55 | def test_parse_slides(): 56 | """Test that slide parsing works correctly 57 | """ 58 | input_data = r""" 59 | # Slide 1 60 | 61 | * list 62 | * item 63 | * item 64 | * item 65 | 66 | Hello there this is a paragraph 67 | 68 | ```python 69 | code block 70 | ``` 71 | 72 | --- 73 | 74 | # Slide 2 75 | 76 | More text 77 | """ 78 | parser = Parser() 79 | _, slides = parser.parse_slides({}, input_data) 80 | assert len(slides) == 2 81 | 82 | 83 | def test_parse_slides_with_progressive_stops_and_hrule_splits(): 84 | """Test that slide parsing works correctly 85 | """ 86 | input_data = r""" 87 | # Heading 88 | 89 | 90 | 91 | p1 92 | 93 | 94 | 95 | p2 96 | 97 | --- 98 | 99 | # Slide 2 100 | 101 | More text 102 | """ 103 | parser = Parser() 104 | _, slides = parser.parse_slides({}, input_data) 105 | assert len(slides) == 4 106 | 107 | 108 | def test_parse_smart_slides_one_h1(): 109 | """Test that slide smart splitting works correctly 110 | """ 111 | input_data = r""" 112 | # Heading Title 113 | 114 | ## Heading 2 115 | 116 | some text 117 | 118 | ## Heading 3 119 | 120 | more text 121 | 122 | ## Heading 4 123 | 124 | ### Sub heading 125 | 126 | #### Sub Heading 127 | """ 128 | parser = Parser() 129 | meta = {"title": ""} 130 | _, slides = parser.parse_slides(meta, input_data) 131 | assert len(slides) == 4 132 | assert meta["title"] == "Heading Title" 133 | 134 | 135 | def test_parse_smart_slides_multiple_h2(): 136 | """Test that slide smart splitting works correctly 137 | """ 138 | input_data = r""" 139 | ## Heading 2 140 | 141 | some text 142 | 143 | ## Heading 3 144 | 145 | more text 146 | 147 | ## Heading 4 148 | 149 | ### Sub heading 150 | 151 | #### Sub Heading 152 | """ 153 | parser = Parser() 154 | meta = {"title": ""} 155 | _, slides = parser.parse_slides(meta, input_data) 156 | assert len(slides) == 3 157 | assert meta["title"] == "" 158 | -------------------------------------------------------------------------------- /docs/source/contrib_extensions.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _contrib-extensions: 3 | 4 | Contrib Extensions 5 | ================== 6 | 7 | lookatme allows an extension to override and redefine how markdown is rendered. 8 | Extensions have first-chance opportunities to handle rendering function calls. 9 | Extensions also have the ability to ignore specific rendering function calls 10 | and allow original lookatme behavior (or other extensions) to handle the 11 | call to that rendering function. 12 | 13 | For example, an extension may provide its own implementation of the render 14 | function ``render_table`` to provide custom table rendering, such as sortable 15 | rows, alternating row background colors, etc. 16 | 17 | Using Extensions 18 | ---------------- 19 | 20 | Extensions are namespace packages within ``lookatme.contrib``. The are used by 21 | 22 | 1. Installing the extension with ``pip install lookatme.contrib.XXX`` 23 | 2. Adding the extension to the list of extensions required by your slides: 24 | 25 | .. code-block:: md 26 | 27 | --- 28 | title: TITLE 29 | author: AUTHOR 30 | date: 2019-11-01 31 | extensions: 32 | - XXX 33 | --- 34 | 35 | # Slide 1 36 | 37 | ... 38 | 39 | Extension Layout 40 | ---------------- 41 | 42 | It is highly recommended that you use the `lookatme.contrib-template `_ 43 | to create new extensions. 44 | 45 | Extensions *must* be a namespaced module within the ``lookatme.contrib`` 46 | submodule. The basic tree layout for such an extension is below: 47 | 48 | .. code-block:: text 49 | 50 | examples/calendar_contrib/ 51 | ├── lookatme 52 | │   └── contrib 53 | │   └── calendar.py 54 | └── setup.py 55 | 56 | Notice that there is not an ``__init__.py`` file in the contrib path. This is 57 | using the `implicit namespace package `_ 58 | format for creating namespace packages, where an ``__init__.py`` is not 59 | needed. 60 | 61 | Extension setup.py 62 | ------------------ 63 | 64 | Below is the ``setup.py`` from the ``examples/calendar_contrib`` extension: 65 | 66 | .. literalinclude:: ../../examples/calendar_contrib/setup.py 67 | :language: python 68 | 69 | Overriding Behavior 70 | ------------------- 71 | 72 | Any function within lookatme that is decorated with ``@contrib_first`` may be 73 | overridden by an extension by defining a function of the same name within the 74 | extension module. 75 | 76 | For example, to override the ``render_code`` function that is declared in 77 | lookatme in `lookatme/render/markdown_block.py `_, 78 | the example calender extension must declare its own function named 79 | ``render_code`` that accepts the same arguments and provides the same return 80 | values as the original function: 81 | 82 | .. literalinclude:: ../../examples/calendar_contrib/lookatme/contrib/calendar.py 83 | :language: python 84 | 85 | Notice how the extension code above raises the :any:`IgnoredByContrib` exception 86 | to allow the default lookatme behavior to occur. 87 | 88 | Overrideable Functions 89 | ---------------------- 90 | 91 | Below is an automatically generated list of all overrideable functions that 92 | are present in this release of lookatme. See the 93 | :any:`lookatme.tui.SlideRenderer.do_render` function for details on markdown_block 94 | render function arguments and return values. 95 | 96 | LOOKATME_OVERRIDES 97 | -------------------------------------------------------------------------------- /docs/source/style_precedence.rst: -------------------------------------------------------------------------------- 1 | .. _style_precedence: 2 | 3 | Style Precedence 4 | ================ 5 | 6 | Styling may be set in three locations in lookatme: 7 | 8 | 1. In a theme 9 | 2. In a slide's YAML header 10 | 3. On the command-line 11 | 12 | When constructing the final, resolved style set that will be used to render 13 | markdown, lookatme starts with the default style settings defined in 14 | :any:`lookatme.schemas`, and then applies overrides in the order specified 15 | above. 16 | 17 | Overrides are applied by performing a deep merge of nested dictionaries. For 18 | example, if the default styles defined in schemas.py were: 19 | 20 | .. code-block:: yaml 21 | 22 | headings: 23 | "1": 24 | fg: "#33c,bold" 25 | bg: "default" 26 | "2": 27 | fg: "#222,bold" 28 | bg: "default" 29 | 30 | ... and if the style overrides defined by a theme were: 31 | 32 | .. code-block:: yaml 33 | 34 | headings: 35 | "1": 36 | bg: "#f00" 37 | 38 | ... and if the style overrides defined in the slide YAML header were: 39 | 40 | 41 | .. code-block:: yaml 42 | 43 | headings: 44 | "2": 45 | fg: "#f00,bold,underline" 46 | 47 | The final, resolved style settings for rendering the markdown would be: 48 | 49 | .. code-block:: yaml 50 | 51 | headings: 52 | "1": 53 | fg: "#33c,bold" 54 | bg: "#f00" # from the theme 55 | "2": 56 | fg: "#f00,bold,underline" # from the slide YAML header 57 | bg: "default" 58 | 59 | 60 | .. _default_style_settings: 61 | 62 | Default Style Settings 63 | ---------------------- 64 | 65 | The default styles and formats are defined in the marshmallow schemas in 66 | :any:`lookatme.schemas`. The dark theme is an empty theme with no overrides 67 | (the defaults *are* the dark theme): 68 | 69 | .. code-block:: yaml 70 | 71 | author: 72 | bg: default 73 | fg: '#f30' 74 | bullets: 75 | '1': • 76 | '2': ⁃ 77 | '3': ◦ 78 | default: • 79 | date: 80 | bg: default 81 | fg: '#777' 82 | headings: 83 | '1': 84 | bg: default 85 | fg: '#9fc,bold' 86 | prefix: '██ ' 87 | suffix: '' 88 | '2': 89 | bg: default 90 | fg: '#1cc,bold' 91 | prefix: '▓▓▓ ' 92 | suffix: '' 93 | '3': 94 | bg: default 95 | fg: '#29c,bold' 96 | prefix: '▒▒▒▒ ' 97 | suffix: '' 98 | '4': 99 | bg: default 100 | fg: '#559,bold' 101 | prefix: '░░░░░ ' 102 | suffix: '' 103 | default: 104 | bg: default 105 | fg: '#346,bold' 106 | prefix: '░░░░░ ' 107 | suffix: '' 108 | hrule: 109 | char: ─ 110 | style: 111 | bg: default 112 | fg: '#777' 113 | link: 114 | bg: default 115 | fg: '#33c,underline' 116 | margin: 117 | bottom: 0 118 | left: 2 119 | right: 2 120 | top: 0 121 | numbering: 122 | '1': numeric 123 | '2': alpha 124 | '3': roman 125 | default: numeric 126 | padding: 127 | bottom: 0 128 | left: 10 129 | right: 10 130 | top: 0 131 | quote: 132 | bottom_corner: └ 133 | side: ╎ 134 | style: 135 | bg: default 136 | fg: italics,#aaa 137 | top_corner: ┌ 138 | slides: 139 | bg: default 140 | fg: '#f30' 141 | style: monokai 142 | table: 143 | column_spacing: 3 144 | header_divider: ─ 145 | title: 146 | bg: default 147 | fg: '#f30,bold,italics' 148 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at d0c.s4vage@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/workflows/new_release.yml: -------------------------------------------------------------------------------- 1 | name: Create new Release 2 | on: 3 | workflow_call: 4 | inputs: 5 | ref: 6 | type: string 7 | required: false 8 | default: "${{ github.ref }}" 9 | ref_name: 10 | type: string 11 | required: false 12 | default: "${{ github.ref_name }}" 13 | push: 14 | tags: 15 | - 'v*.*.*' 16 | - '!*-grape' 17 | 18 | jobs: 19 | publish_to_pypi: 20 | name: Build and publish to PyPI 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v3 24 | with: 25 | ref: "${{ inputs.ref_name }}" 26 | - name: Setup Python 3.10 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: "3.10" 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install -r requirements.txt 34 | - name: Install pypa/build 35 | run: python -m pip install build --user 36 | - name: Build binary wheel + tarball 37 | run: python -m build --sdist --wheel --outdir dist/ 38 | - name: Publish distribution to PyPI 39 | uses: pypa/gh-action-pypi-publish@release/v1 40 | with: 41 | password: ${{ secrets.PYPI_API_TOKEN }} 42 | 43 | create_github_release: 44 | name: Create new Release on GitHub 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/github-script@v6 48 | with: 49 | script: | 50 | function semVerSort(a, b) { 51 | const aParts = a.split(/\.|rc/).map(x => x.replace(/^[^\d]*/, '')); 52 | const bParts = b.split(/\.|rc/).map(x => x.replace(/^[^\d]*/, '')); 53 | 54 | let max = Math.max(aParts.length, bParts.length); 55 | let aPart = 0; 56 | let bPart = 0; 57 | for (let idx = 0; idx < max; idx++) { 58 | aPart = parseInt(aParts[idx]) || Number.MIN_VALUE; 59 | bPart = parseInt(bParts[idx]) || Number.MIN_VALUE; 60 | 61 | if (aPart != bPart) { 62 | break; 63 | } 64 | } 65 | 66 | return aPart - bPart; 67 | } 68 | 69 | let currTag = "${{ inputs.ref_name }}"; 70 | let currIsRc = (currTag.indexOf("rc") != -1); 71 | 72 | const {data: allTags} = await github.rest.repos.listTags({ 73 | owner: context.repo.owner, 74 | repo: context.repo.repo, 75 | }); 76 | const allTagNames = allTags.map(x => x.name); 77 | 78 | const sortedTags = allTagNames.sort(semVerSort).reverse(); 79 | const relevantTags = sortedTags.filter(x => (currIsRc ? true : x.indexOf("rc") == -1)); 80 | 81 | let releaseNoteOptions = { 82 | owner: context.repo.owner, 83 | repo: context.repo.repo, 84 | tag_name: "${{ inputs.ref_name }}", 85 | }; 86 | 87 | let currTagIdx = relevantTags.indexOf(currTag); 88 | if (currTagIdx != -1 && currTagIdx + 1 < relevantTags.length) { 89 | releaseNoteOptions.previous_tag_name = relevantTags[currTagIdx + 1]; 90 | } 91 | core.info(`releaseNoteOptions: ${JSON.stringify(releaseNoteOptions)}`); 92 | 93 | const {data: releaseNotes} = await github.rest.repos.generateReleaseNotes( 94 | releaseNoteOptions 95 | ); 96 | 97 | const {data: response} = await github.rest.repos.createRelease({ 98 | owner: context.repo.owner, 99 | repo: context.repo.repo, 100 | tag_name: "${{ inputs.ref_name }}", 101 | body: releaseNotes.body, 102 | prerelease: currIsRc, 103 | }); 104 | -------------------------------------------------------------------------------- /lookatme/contrib/file_loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines a built-in contrib module that enables external files to 3 | be included within the slide. This is extremely useful when having source 4 | code displayed in a code block, and then running/doing something with the 5 | source data in a terminal on the same slide. 6 | """ 7 | 8 | 9 | import os 10 | import subprocess 11 | from typing import Dict 12 | 13 | import yaml 14 | from marshmallow import Schema, fields 15 | 16 | import lookatme.config 17 | from lookatme.exceptions import IgnoredByContrib 18 | 19 | 20 | def user_warnings(): 21 | """Provide warnings to the user that loading this extension may cause 22 | shell commands specified in the markdown to be run. 23 | """ 24 | return [ 25 | "Code-blocks with a language starting with 'file' may cause shell", 26 | " commands from the source markdown to be run if the 'transform'", 27 | " field is set", 28 | "See https://lookatme.readthedocs.io/en/latest/builtin_extensions/file_loader.html", 29 | " for more details", 30 | ] 31 | 32 | 33 | class YamlRender: 34 | @staticmethod 35 | def loads(data): return yaml.safe_load(data) 36 | @staticmethod 37 | def dumps(data): return yaml.safe_dump(data) 38 | 39 | 40 | class LineRange(Schema): 41 | start = fields.Integer(dump_default=0, load_default=0) 42 | end = fields.Integer(dump_default=None, load_default=None) 43 | 44 | 45 | class FileSchema(Schema): 46 | path = fields.Str() 47 | relative = fields.Boolean(dump_default=True, load_default=True) 48 | lang = fields.Str(dump_default="auto", load_default="auto") 49 | transform = fields.Str(dump_default=None, load_default=None) 50 | lines = fields.Nested( 51 | LineRange, 52 | dump_default=LineRange().dump(None), 53 | load_default=LineRange().dump(None) 54 | ) 55 | 56 | class Meta: 57 | render_module = YamlRender 58 | 59 | def loads(self, *args, **kwargs) -> Dict: 60 | res = super(self.__class__, self).loads(*args, **kwargs) 61 | if res is None: 62 | raise ValueError("Could not loads") 63 | return res 64 | 65 | def load(self, *args, **kwargs) -> Dict: 66 | res = super(self.__class__, self).load(*args, **kwargs) 67 | if res is None: 68 | raise ValueError("Could not load") 69 | return res 70 | 71 | 72 | def transform_data(transform_shell_cmd, input_data): 73 | """Transform the ``input_data`` using the ``transform_shell_cmd`` 74 | shell command. 75 | """ 76 | proc = subprocess.Popen( 77 | transform_shell_cmd, 78 | shell=True, 79 | stdout=subprocess.PIPE, 80 | stderr=subprocess.STDOUT, 81 | stdin=subprocess.PIPE, 82 | ) 83 | stdout, _ = proc.communicate(input=input_data) 84 | return stdout 85 | 86 | 87 | def render_code(token, body, stack, loop): 88 | """Render the code, ignoring all code blocks except ones with the language 89 | set to ``file``. 90 | """ 91 | lang = token["lang"] or "" 92 | if lang != "file": 93 | raise IgnoredByContrib 94 | 95 | file_info_data = token["text"] 96 | file_info = FileSchema().loads(file_info_data) 97 | 98 | # relative to the slide source 99 | if file_info["relative"]: 100 | base_dir = lookatme.config.SLIDE_SOURCE_DIR 101 | else: 102 | base_dir = os.getcwd() 103 | 104 | full_path = os.path.join(base_dir, file_info["path"]) 105 | if not os.path.exists(full_path): 106 | token["text"] = "File not found" 107 | token["lang"] = "text" 108 | raise IgnoredByContrib 109 | 110 | with open(full_path, "rb") as f: 111 | file_data = f.read() 112 | 113 | if file_info["transform"] is not None: 114 | file_data = transform_data(file_info["transform"], file_data) 115 | 116 | lines = file_data.split(b"\n") 117 | lines = lines[file_info["lines"]["start"]:file_info["lines"]["end"]] 118 | file_data = b"\n".join(lines) 119 | token["text"] = file_data 120 | token["lang"] = file_info["lang"] 121 | raise IgnoredByContrib 122 | -------------------------------------------------------------------------------- /lookatme/contrib/terminal.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines a built-in contrib module that enables terminal embedding 3 | within a slide. 4 | """ 5 | 6 | 7 | import re 8 | import shlex 9 | from typing import Dict 10 | 11 | import urwid 12 | import yaml 13 | from marshmallow import Schema, fields 14 | 15 | import lookatme.config 16 | import lookatme.render.markdown_block 17 | from lookatme.exceptions import IgnoredByContrib 18 | 19 | 20 | def user_warnings(): 21 | """Provide warnings to the user that loading this extension may cause 22 | shell commands specified in the markdown to be run. 23 | """ 24 | return [ 25 | "Code-blocks with a language starting with 'terminal' will cause shell", 26 | " commands from the source markdown to be run", 27 | "See https://lookatme.readthedocs.io/en/latest/builtin_extensions/terminal.html", 28 | " for more details", 29 | ] 30 | 31 | 32 | class YamlRender: 33 | @staticmethod 34 | def loads(data): return yaml.safe_load(data) 35 | @staticmethod 36 | def dumps(data): return yaml.safe_dump(data) 37 | 38 | 39 | class TerminalExSchema(Schema): 40 | """The schema used for ``terminal-ex`` code blocks. 41 | """ 42 | command = fields.Str() 43 | rows = fields.Int(dump_default=10, load_default=10) 44 | init_text = fields.Str(dump_default=None, load_default=None) 45 | init_wait = fields.Str(dump_default=None, load_default=None) 46 | init_codeblock = fields.Bool(dump_default=True, load_default=True) 47 | init_codeblock_lang = fields.Str(dump_default="text", load_default="text") 48 | 49 | class Meta: 50 | render_module = YamlRender 51 | 52 | def loads(self, *args, **kwargs) -> Dict: 53 | res = super(self.__class__, self).loads(*args, **kwargs) 54 | if res is None: 55 | raise ValueError("Could not loads") 56 | return res 57 | 58 | def load(self, *args, **kwargs) -> Dict: 59 | res = super(self.__class__, self).load(*args, **kwargs) 60 | if res is None: 61 | raise ValueError("Could not load") 62 | return res 63 | 64 | 65 | CREATED_TERMS = [] 66 | 67 | 68 | def render_code(token, body, stack, loop): 69 | lang = token["lang"] or "" 70 | 71 | numbered_term_match = re.match(r'terminal(\d+)', lang) 72 | if lang != "terminal-ex" and numbered_term_match is None: 73 | raise IgnoredByContrib 74 | 75 | if numbered_term_match is not None: 76 | term_data = TerminalExSchema().load({ 77 | "command": token["text"].strip(), 78 | "rows": int(numbered_term_match.group(1)), 79 | "init_codeblock": False, 80 | }) 81 | 82 | else: 83 | term_data = TerminalExSchema().loads(token["text"]) 84 | 85 | if term_data["init_text"] is not None and term_data["init_wait"] is not None: 86 | term_data["command"] = " ".join([shlex.quote(x) for x in [ 87 | "expect", "-c", ";".join([ 88 | 'spawn -noecho {}'.format(term_data["command"]), 89 | 'expect {{{}}}'.format(term_data["init_wait"]), 90 | 'send {{{}}}'.format(term_data["init_text"]), 91 | 'interact', 92 | 'exit', 93 | ]) 94 | ]]) 95 | 96 | term = urwid.Terminal( 97 | shlex.split(term_data["command"].strip()), 98 | main_loop=loop, 99 | encoding="utf8", 100 | ) 101 | CREATED_TERMS.append(term) 102 | 103 | line_box = urwid.LineBox(urwid.BoxAdapter(term, height=term_data["rows"])) 104 | line_box.no_cache = ["render"] 105 | 106 | res = [] 107 | 108 | if term_data["init_codeblock"] is True: 109 | fake_token = { 110 | "text": term_data["init_text"], 111 | "lang": term_data["init_codeblock_lang"], 112 | } 113 | res += lookatme.render.markdown_block.render_code( 114 | fake_token, body, stack, loop 115 | ) 116 | 117 | res += [ 118 | urwid.Divider(), 119 | line_box, 120 | urwid.Divider(), 121 | ] 122 | 123 | return res 124 | 125 | 126 | def shutdown(): 127 | for idx, term in enumerate(CREATED_TERMS): 128 | lookatme.config.get_log().debug( 129 | f"Terminating terminal {idx+1}/{len(CREATED_TERMS)}") 130 | if term.pid is not None: 131 | term.terminate() 132 | -------------------------------------------------------------------------------- /tests/test_table.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module tests the Table widget in lookatme/widgets/table.py 3 | """ 4 | 5 | 6 | import pytest 7 | 8 | import lookatme.widgets.table 9 | import tests.utils as utils 10 | 11 | TEST_STYLE = { 12 | "style": "monokai", 13 | "table": { 14 | "column_spacing": 3, 15 | "header_divider": "&", 16 | }, 17 | } 18 | 19 | 20 | @pytest.fixture(autouse=True) 21 | def table_setup(tmpdir, mocker): 22 | utils.setup_lookatme(tmpdir, mocker, style=TEST_STYLE) 23 | 24 | 25 | def test_basic_render(tmpdir, mocker): 26 | """Test that a Table widget renders correctly 27 | """ 28 | headers = ["H1", "H2", "H3"] 29 | aligns = ["left", "center", "right"] 30 | rows = [ 31 | ["1", "22", "333"], 32 | ["*1*", "~~22~~", "**333**"], 33 | ] 34 | 35 | table = lookatme.widgets.table.Table(rows, headers=headers, aligns=aligns) 36 | canvas = table.render((20,)) 37 | content = list(canvas.content()) 38 | 39 | # four rows: 40 | # 1 headers 41 | # 2 DIVIDER 42 | # 3 row1 43 | # 4 row2 44 | assert len(content) == 4 45 | 46 | header_row = content[0] 47 | spec, text = utils.spec_and_text(header_row[0]) 48 | assert "bold" in spec.foreground 49 | assert text == b"H1" 50 | spec, text = utils.spec_and_text(header_row[2]) 51 | assert "bold" in spec.foreground 52 | assert text == b"H2" 53 | spec, text = utils.spec_and_text(header_row[5]) 54 | assert "bold" in spec.foreground 55 | assert text == b"H3" 56 | 57 | divider_row = content[1] 58 | spec, text = utils.spec_and_text(divider_row[0]) 59 | # no styling applied to the divider 60 | assert spec is None 61 | assert text == b"&&" 62 | spec, text = utils.spec_and_text(divider_row[2]) 63 | # no styling applied to the divider 64 | assert spec is None 65 | assert text == b"&&" 66 | spec, text = utils.spec_and_text(divider_row[4]) 67 | # no styling applied to the divider 68 | assert spec is None 69 | assert text == b"&&&" 70 | 71 | content_row1 = content[2] 72 | spec, text = utils.spec_and_text(content_row1[0]) 73 | # no styling applied to this row 74 | assert spec is None 75 | assert text == b"1 " 76 | spec, text = utils.spec_and_text(content_row1[2]) 77 | # no styling applied to this row 78 | assert spec is None 79 | assert text == b"22" 80 | spec, text = utils.spec_and_text(content_row1[4]) 81 | # no styling applied to this row 82 | assert spec is None 83 | assert text == b"333" 84 | 85 | content_row1 = content[3] 86 | spec, text = utils.spec_and_text(content_row1[0]) 87 | # no styling applied to this row 88 | assert "italics" in spec.foreground 89 | assert text == b"1" 90 | spec, text = utils.spec_and_text(content_row1[3]) 91 | # no styling applied to this row 92 | assert "strikethrough" in spec.foreground 93 | assert text == b"22" 94 | spec, text = utils.spec_and_text(content_row1[5]) 95 | # no styling applied to this row 96 | assert "underline" in spec.foreground 97 | assert text == b"333" 98 | 99 | 100 | def test_table_no_headers(mocker): 101 | """This situation could never happen as parsed from Markdown. See 102 | https://stackoverflow.com/a/17543474. 103 | 104 | However this situation could happen manually when using the Table() class 105 | directly. 106 | """ 107 | headers = None 108 | aligns = ["left", "center", "right"] 109 | rows = [ 110 | ["1", "22", "333"], 111 | ["*1*", "~~22~~", "**333**"], 112 | ] 113 | 114 | table = lookatme.widgets.table.Table(rows, headers=headers, aligns=aligns) 115 | canvas = table.render((20,)) 116 | content = list(canvas.content()) 117 | 118 | assert len(content) == 2 119 | 120 | 121 | def test_ignored_extra_column(mocker): 122 | """Test that extra columns beyond header values are ignored 123 | """ 124 | headers = ["H1", "H2", "H3"] 125 | aligns = ["left", "center", "right"] 126 | rows = [ 127 | ["1", "2", "3"], 128 | ["1", "2", "3", "4"], 129 | ["1", "2", "3", "4", "5"], 130 | ] 131 | 132 | table = lookatme.widgets.table.Table(rows, headers=headers, aligns=aligns) 133 | canvas = table.render((20,)) 134 | content = list(canvas.content()) 135 | 136 | # number of rows of output 137 | assert len(content) == 5 138 | assert b"4" not in utils.row_text(content[-2]) 139 | 140 | assert b"4" not in utils.row_text(content[-1]) 141 | assert b"5" not in utils.row_text(content[-1]) 142 | -------------------------------------------------------------------------------- /lookatme/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module handles loading and using lookatme_contrib modules 3 | 4 | def loads(self, *args, **kwargs) -> Dict: 5 | res = super(self.__class__, self).loads(*args, **kwargs) 6 | if res is None: 7 | raise ValueError("Could not loads") 8 | return res 9 | 10 | def load(self, *args, **kwargs) -> Dict: 11 | res = super(self.__class__, self).load(*args, **kwargs) 12 | if res is None: 13 | raise ValueError("Could not load") 14 | return res 15 | 16 | Contrib modules are directly used 17 | """ 18 | 19 | 20 | import functools 21 | from typing import List 22 | 23 | import lookatme.ascii_art 24 | import lookatme.prompt 25 | from lookatme.exceptions import IgnoredByContrib 26 | 27 | CONTRIB_MODULES = [] 28 | 29 | 30 | def validate_extension_mod(_ext_name, ext_mod): 31 | """Validate the extension, returns an array of warnings associated with the 32 | module 33 | """ 34 | res = [] 35 | if not hasattr(ext_mod, "user_warnings"): 36 | res.append("'user_warnings' is missing. Extension is not able to " 37 | "provide user warnings.") 38 | else: 39 | res += ext_mod.user_warnings() 40 | 41 | return res 42 | 43 | 44 | def load_contribs(contrib_names, safe_contribs, ignore_load_failure=False): 45 | """Load all contrib modules specified by ``contrib_names``. These should 46 | all be namespaced packages under the ``lookatmecontrib`` namespace. E.g. 47 | ``lookatmecontrib.calendar`` would be an extension provided by a 48 | contrib module, and would be added to an ``extensions`` list in a slide's 49 | YAML header as ``calendar``. 50 | 51 | ``safe_contribs`` is a set of contrib names that are manually provided 52 | by the user by the ``-e`` flag or env variable of extensions to auto-load. 53 | """ 54 | if contrib_names is None: 55 | return 56 | 57 | errors = [] 58 | all_warnings = [] 59 | for contrib_name in contrib_names: 60 | module_name = f"lookatme.contrib.{contrib_name}" 61 | try: 62 | mod = __import__(module_name, fromlist=[contrib_name]) 63 | except Exception as e: 64 | if ignore_load_failure: 65 | continue 66 | errors.append(str(e)) 67 | else: 68 | if contrib_name not in safe_contribs: 69 | ext_warnings = validate_extension_mod(contrib_name, mod) 70 | if len(ext_warnings) > 0: 71 | all_warnings.append((contrib_name, ext_warnings)) 72 | CONTRIB_MODULES.append(mod) 73 | 74 | _handle_load_errors_warnings(errors, all_warnings) 75 | 76 | 77 | def _handle_load_errors_warnings(errors: List[str], all_warnings: List[str]): 78 | """Handle all load errors and warnings from loading contrib modules 79 | """ 80 | if len(errors) > 0: 81 | raise Exception( 82 | "Error loading one or more extensions:\n\n" + "\n".join(errors), 83 | ) 84 | 85 | if len(all_warnings) == 0: 86 | return 87 | 88 | print("\nExtension-provided user warnings:") 89 | for ext_name, ext_warnings in all_warnings: 90 | print("\n {!r}:\n".format(ext_name)) 91 | for ext_warning in ext_warnings: 92 | print(" * {}".format(ext_warning)) 93 | print("") 94 | 95 | if not lookatme.prompt.yes("Continue anyways?"): 96 | exit(1) 97 | 98 | 99 | def contrib_first(fn): 100 | """A decorator that allows contrib modules to override default behavior 101 | of lookatme. E.g., a contrib module may override how a table is displayed 102 | to enable sorting, or enable displaying images rendered with ANSII color 103 | codes and box drawing characters, etc. 104 | 105 | Contrib modules may ignore chances to override default behavior by raising 106 | the ``lookatme.contrib.IgnoredByContrib`` exception. 107 | """ 108 | fn_name = fn.__name__ 109 | 110 | @functools.wraps(fn) 111 | def inner(*args, **kwargs): 112 | for mod in CONTRIB_MODULES: 113 | if not hasattr(mod, fn_name): 114 | continue 115 | try: 116 | return getattr(mod, fn_name)(*args, **kwargs) 117 | except IgnoredByContrib: 118 | pass 119 | 120 | return fn(*args, **kwargs) 121 | 122 | return inner 123 | 124 | 125 | def shutdown_contribs(): 126 | """Call the shutdown function on all contrib modules 127 | """ 128 | for mod in CONTRIB_MODULES: 129 | getattr(mod, "shutdown", lambda: 1)() 130 | -------------------------------------------------------------------------------- /.github/workflows/preview.yml: -------------------------------------------------------------------------------- 1 | name: Lookatme Preview 2 | on: 3 | issue_comment: 4 | types: 5 | - created 6 | 7 | 8 | jobs: 9 | generate_preview: 10 | continue-on-error: true 11 | runs-on: ubuntu-latest 12 | if: (github.event.issue && contains(github.event.issue.milestone.title, 'v3.0')) && contains(github.event.comment.body, 'gif=true') 13 | 14 | services: 15 | selenium: 16 | image: selenium/standalone-chrome 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Maybe Checkout 3.0-dev 22 | uses: actions/github-script@v6 23 | with: 24 | script: | 25 | async function checkout30dev() { 26 | await exec.exec("git", ["fetch", "origin", "3.0-dev:3.0-dev"]); 27 | await exec.exec("git", ["checkout", "3.0-dev"]); 28 | } 29 | 30 | if (context.payload.issue.pull_request) { 31 | let response = await github.rest.pulls.get({ 32 | owner: context.repo.owner, 33 | repo: context.repo.repo, 34 | pull_number: context.issue.number, 35 | }); 36 | core.info(`response: ${JSON.stringify(response)}`); 37 | const pr = response.data; 38 | 39 | if (pr.base.ref != "3.0-dev") { 40 | core.info("In a PR, but not merging into 3.0-dev, will directly use 3.0-dev for the preview"); 41 | await checkout30dev(); 42 | } else { 43 | core.info(`In a PR that is merging into 3.0-dev, using PR context for the preview: ${pr.head.ref} (${pr.head.sha})`); 44 | await exec.exec("git", [ 45 | "fetch", "origin", `${pr.head.sha}:${pr.head.ref}`, 46 | ]); 47 | await exec.exec("git", ["checkout", pr.head.ref]); 48 | } 49 | } else { 50 | core.info("Not in a PR, using 3.0-dev directly"); 51 | await checkout30dev(); 52 | } 53 | 54 | - name: Setup Python 3.10 55 | uses: actions/setup-python@v4 56 | with: 57 | python-version: "3.10" 58 | 59 | - name: Install Python dependencies 60 | run: | 61 | pip install .[dev] 62 | 63 | - name: Generate preview 64 | uses: actions/github-script@v6 65 | with: 66 | script: | 67 | const fs = require("fs/promises"); 68 | 69 | const comment = context.payload.comment; 70 | 71 | core.info("Writing comment info to disk"); 72 | await fs.writeFile("input.md", comment.body); 73 | 74 | core.info("Generating gif from extracted comment body"); 75 | await exec.exec("bin/md_gif_extractor.py", ["-i", "input.md", "-o", "preview.gif"]); 76 | 77 | core.info("Uploading preview to imgur"); 78 | 79 | let curlOutput = ""; 80 | const options = { 81 | listeners: { 82 | stdout: (data) => { 83 | curlOutput += data.toString(); 84 | } 85 | } 86 | }; 87 | 88 | // was using axios and directly uploading, ran into errors, used curl 89 | // to help debug, got it working, don't feel like reverting axios 90 | // now. "it works" - shrug 91 | await exec.exec("curl", [ 92 | "--location", 93 | "--request", "POST", 94 | "https://api.imgur.com/3/image", 95 | "--header", "Authorization: Client-ID ${{ secrets.IMGUR_CLIENT_ID }}", 96 | "--form", "image=@preview.gif", 97 | "--form", "type=file", 98 | ], options); 99 | 100 | 101 | core.info(`Got curl response:\n${curlOutput}\n`); 102 | let resp = JSON.parse(curlOutput); 103 | 104 | if (!resp.data) { 105 | core.info(`Error uploading to imgur: ${curlOutput}`); 106 | } else { 107 | core.info("Commenting on issue"); 108 | const imgurUrl = resp.data.link; 109 | 110 | github.rest.issues.createComment({ 111 | owner: context.repo.owner, 112 | repo: context.repo.repo, 113 | issue_number: context.issue.number, 114 | body: `The preview below was generated by the markdown in [this comment](${context.payload.comment.html_url}):\n\n![markdown preview image](${imgurUrl})`, 115 | }); 116 | } 117 | 118 | - name: Upload artifact 119 | uses: actions/upload-artifact@v3 120 | with: 121 | path: preview.gif 122 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the main CLI 3 | """ 4 | 5 | 6 | from typing import Optional 7 | 8 | import yaml 9 | from click.testing import CliRunner 10 | 11 | import lookatme 12 | import lookatme.schemas 13 | import lookatme.themes.dark as dark_theme 14 | import lookatme.themes.light as light_theme 15 | import lookatme.tui 16 | from lookatme.__main__ import main 17 | 18 | 19 | def run_cmd(*args): 20 | """Run the provided arguments 21 | """ 22 | runner = CliRunner() 23 | return runner.invoke(main, args) 24 | 25 | 26 | def test_dump_styles_unicode(): 27 | """Test that dump styles works correctly 28 | """ 29 | res = run_cmd("--dump-styles") 30 | assert res.exit_code == 0 31 | assert "█" in res.output 32 | 33 | 34 | def _get_dumped_style( 35 | tmpdir, 36 | theme: Optional[str] = None, 37 | md_meta_style: Optional[str] = None, 38 | cli_style: Optional[str] = None 39 | ) -> str: 40 | cli_args = ["--dump-styles"] 41 | 42 | if theme is not None: 43 | cli_args += ["--theme", theme] 44 | if md_meta_style is not None: 45 | tmpfile = tmpdir.join("test.md") 46 | with open(tmpfile, "w") as f: 47 | f.write("\n".join([ 48 | "---", 49 | "styles:", 50 | " style: {}".format(md_meta_style), 51 | "---", 52 | ])) 53 | cli_args += [str(tmpfile)] 54 | if cli_style is not None: 55 | cli_args += ["--style", cli_style] 56 | 57 | res = run_cmd(*cli_args) 58 | assert res.exit_code == 0 59 | 60 | yaml_data = yaml.safe_load(res.output) 61 | return yaml_data["style"] 62 | 63 | 64 | def test_style_override_precedence_dark(tmpdir): 65 | """Test that dump styles works correctly 66 | """ 67 | default_style = _get_dumped_style(tmpdir) 68 | themed_style = _get_dumped_style(tmpdir, theme="dark") 69 | themed_and_md = _get_dumped_style( 70 | tmpdir, 71 | theme="dark", 72 | md_meta_style="emacs" 73 | ) 74 | themed_and_md_and_cli = _get_dumped_style( 75 | tmpdir, 76 | theme="dark", 77 | md_meta_style="emacs", 78 | cli_style="zenburn" 79 | ) 80 | 81 | default = lookatme.schemas.MetaSchema().dump(None) 82 | assert default_style == default["styles"]["style"] 83 | 84 | dark_theme_styles = lookatme.schemas.StyleSchema().dump(dark_theme.theme) 85 | assert themed_style == dark_theme_styles["style"] # type: ignore 86 | 87 | assert themed_and_md == "emacs" 88 | assert themed_and_md_and_cli == "zenburn" 89 | 90 | 91 | def test_style_override_precedence_light(tmpdir): 92 | """Test that dump styles works correctly 93 | """ 94 | default_style = _get_dumped_style(tmpdir) 95 | themed_style = _get_dumped_style(tmpdir, theme="light") 96 | themed_and_md = _get_dumped_style( 97 | tmpdir, 98 | theme="light", 99 | md_meta_style="emacs" 100 | ) 101 | themed_and_md_and_cli = _get_dumped_style( 102 | tmpdir, 103 | theme="light", 104 | md_meta_style="emacs", 105 | cli_style="zenburn" 106 | ) 107 | 108 | default = lookatme.schemas.MetaSchema().dump(None) 109 | assert default_style == default["styles"]["style"] 110 | 111 | light_theme_styles = lookatme.schemas.StyleSchema().dump(light_theme.theme) 112 | assert themed_style == light_theme_styles["style"] # type: ignore 113 | 114 | assert themed_and_md == "emacs" 115 | assert themed_and_md_and_cli == "zenburn" 116 | 117 | 118 | def test_version(): 119 | """Test the version option 120 | """ 121 | res = run_cmd("--version") 122 | assert res.exit_code == 0 123 | assert lookatme.__version__ in res.output 124 | 125 | 126 | def test_exceptions(tmpdir, mocker): 127 | """Test exception handling on invalid inputs 128 | """ 129 | log_path = tmpdir.join("log.txt") 130 | pres_path = tmpdir.join("test.md") 131 | with pres_path.open("w") as f: 132 | f.write("# Hello") 133 | 134 | slide_number = 3 135 | exception_text = "EXCEPTION TEXT" 136 | 137 | def fake_create_tui(*args, **kwargs): 138 | res = mocker.MagicMock() 139 | res.curr_slide.number = slide_number 140 | res.run.side_effect = Exception(exception_text) 141 | return res 142 | mocker.patch.object(lookatme.tui, "create_tui", fake_create_tui) 143 | 144 | res = run_cmd("--log", str(log_path), str(pres_path)) 145 | assert exception_text in res.output 146 | assert f"slide {slide_number+1}" in res.output 147 | # should remind us to rerun with --debug to see the traceback 148 | assert "--debug" in res.output 149 | 150 | res = run_cmd("--debug", "--log", str(log_path), str(pres_path)) 151 | assert exception_text in res.output 152 | assert f"slide {slide_number+1}" in res.output 153 | # should remind us to check log_path for the traceback 154 | assert str(log_path) in res.output 155 | -------------------------------------------------------------------------------- /examples/tour.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: lookatme Tour 3 | date: 2020-10-09 4 | author: James Johnson 5 | extensions: 6 | - terminal 7 | - qrcode 8 | - image_ueberzug 9 | styles: 10 | style: monokai 11 | table: 12 | column_spacing: 15 13 | margin: 14 | top: 3 15 | bottom: 0 16 | padding: 17 | top: 3 18 | bottom: 3 19 | --- 20 | 21 | # Markdown Support: Inline 22 | 23 | | Markdown | Result | 24 | |---------------------------------:|--------------------------------| 25 | | `*italic*` | *italic* | 26 | | `_italic_` | _italic_ | 27 | | `**bold**` | **bold** | 28 | | `__bold__` | __bold__ | 29 | | `***bold underline***` | ***bold underline*** | 30 | | `___bold underline___` | ___bold underline___ | 31 | | `~~strikethrough~~` | ~~strikethrough~~ | 32 | | `[CLICK ME](https://google.com)` | [CLICK ME](https://google.com) | 33 | | `` `code` `` | `code` | 34 | 35 | --- 36 | 37 | # Markdown Support: Headers 38 | 39 | ## Heading 2 40 | 41 | ### Heading 3 42 | 43 | #### Heading 4 44 | 45 | More text 46 | 47 | --- 48 | 49 | # Markdown Support: Code Blocks & Quotes 50 | 51 | Code blocks with language syntax highlighting 52 | 53 | ~~~python 54 | def a_function(arg1, arg2): 55 | """This is a function 56 | """ 57 | print(arg1) 58 | ~~~ 59 | 60 | A quote is below: 61 | 62 | > This is a quote more quote contents 63 | 64 | --- 65 | 66 | # Markdown Support: Lists 67 | 68 | * Top level 69 | * Level 2 70 | * Level 3 71 | * Level 4 72 | * Level 2 73 | * Level 3 74 | * Level 4 75 | * Level 2 76 | * Level 3 77 | * Level 4 78 | 79 | --- 80 | 81 | # Markdown Support: Numbered Lists 82 | 83 | * Top level 84 | 1. Level 2 85 | 1. Level 3 86 | 1. Level 3 87 | 1. Level 3 88 | * Level 4 89 | 1. Level 2 90 | 1. Level 3 91 | 1. Level 4 92 | 1. Level 4 93 | 1. Level 4 94 | 1. Level 2 95 | * Level 3 96 | * Level 4 97 | 98 | --- 99 | 100 | # Progressive Slides 101 | 102 | Add a `` comment between paragraphs to progressively render 103 | the current slide! 104 | 105 | For example, the markdown in the codeblock below is used at the end of this 106 | slide: 107 | 108 | ```markdown 109 | paragraph 1 110 | 111 | 112 | 113 | paragraph 2 114 | ``` 115 | 116 | paragraph 1 117 | 118 | 119 | 120 | paragraph 2 121 | 122 | --- 123 | 124 | # Extensions 125 | 126 | lookatme supports extensions that can add additional functionality to lookatme 127 | presentations. 128 | 129 | --- 130 | 131 | # Extensions > QR Codes 132 | 133 | E.g., with the [qrcode](https://github.com/d0c-s4vage/lookatme.contrib.qrcode) 134 | extension enabled, this: 135 | 136 | ~~~ 137 | ```qrcode 138 | hello 139 | ``` 140 | ~~~ 141 | 142 | becomes 143 | 144 | ```qrcode 145 | hello 146 | ``` 147 | --- 148 | 149 | # Extensions > Images 150 | 151 | ![15](./nasa_orion.jpg) 152 | 153 | Extensions can also provide support for images! the 154 | [image_ueberzug](https://github.com/d0c-s4vage/lookatme.contrib.image_ueberzug) 155 | plugin makes images work in slides! 156 | 157 | --- 158 | 159 | # Embeddable Terminals 160 | 161 | Terminals can be embedded directly into slides! 162 | 163 | The markdown below: 164 | 165 | ~~~md 166 | ```terminal8 167 | bash -il 168 | ``` 169 | ~~~ 170 | 171 | becomes 172 | 173 | ```terminal8 174 | bash -il 175 | ``` 176 | 177 | --- 178 | 179 | # Embeddable Terminals: Docker containers 180 | 181 | Want to drop directly into a docker container for a clean environment 182 | in the middle of a slide? 183 | 184 | ~~~md 185 | ```terminal8 186 | docker run --rm -it ubuntu:18.04 187 | ``` 188 | ~~~ 189 | 190 | ```terminal8 191 | docker run --rm -it ubuntu:18.04 192 | ``` 193 | 194 | --- 195 | 196 | # Live Editing 197 | 198 | Hello from vim! The `--live` flag makes lookatme watch the source input 199 | for file changes and auto-reloads the slides. 200 | 201 | --- 202 | 203 | # Live Editing: Including Styles! 204 | 205 | ```python 206 | def a_function(test): 207 | print "Hello again from vim again" 208 | ``` 209 | 210 | | h1 | h2 | h3 | 211 | |--------|--------|-------| 212 | | value1 | value2 | value3 | 213 | | value1 | value2 | value3 | 214 | | value1 | value2 | value3 | 215 | | value1 | value2 | value3 | 216 | | value1 | value2 | value3 | 217 | 218 | --- 219 | 220 | # Slide Scrolling 221 | 222 | * Slides 223 | * Can 224 | * Be 225 | * Scrolled 226 | * With 227 | * Up 228 | * And 229 | * Down 230 | * Arrows 231 | * **NOTE** 232 | - Does 233 | - Not 234 | - Work 235 | - Well 236 | - With 237 | - Images 238 | -------------------------------------------------------------------------------- /tests/test_schemas.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test tha lookatme YAML schemas behave as expected 3 | """ 4 | 5 | 6 | import datetime 7 | 8 | import marshmallow.exceptions 9 | import pytest 10 | from marshmallow import Schema, fields 11 | 12 | import lookatme.schemas as schemas 13 | 14 | 15 | def test_meta_schema(): 16 | """Test the meta schema 17 | """ 18 | title = "TITLE" 19 | author = "AUTHOR" 20 | date = "2019-01-01" 21 | yaml_text = f""" 22 | title: {title} 23 | author: {author} 24 | date: {date} 25 | """ 26 | 27 | schema = schemas.MetaSchema().loads(yaml_text) 28 | assert schema["title"] == title 29 | assert schema["author"] == author 30 | assert schema["date"] == "2019-01-01" 31 | 32 | 33 | def test_meta_schema_allowed_extra_top_level(): 34 | """Test that extra top-level fields are allowed in the metadata. A separate 35 | test will test that the style metadata is strictly validated. 36 | """ 37 | """Test the meta schema 38 | """ 39 | title = "TITLE" 40 | author = "AUTHOR" 41 | date = "2019-01-01" 42 | yaml_text = f""" 43 | title: {title} 44 | author: {author} 45 | date: {date} 46 | tags: [t1, t2, t3] 47 | status: status_str 48 | """ 49 | 50 | schema = schemas.MetaSchema().loads(yaml_text) 51 | assert schema["title"] == title 52 | assert schema["author"] == author 53 | assert schema["date"] == "2019-01-01" 54 | assert schema["tags"] == ["t1", "t2", "t3"] 55 | assert schema["status"] == "status_str" 56 | 57 | 58 | def test_meta_schema_strict_style_validation(): 59 | """Test that the style schema is STRICTLY validated. Not having this will 60 | make it hard to debug why mistakes in style names aren't having the desired 61 | effect on lookatme. We want the user to know what fields they got wrong 62 | in the style. 63 | """ 64 | """Test the meta schema 65 | """ 66 | title = "TITLE" 67 | author = "AUTHOR" 68 | date = "2019-01-01" 69 | yaml_text = f""" 70 | title: {title} 71 | author: {author} 72 | date: {date} 73 | styles: 74 | invalid: 100 75 | """ 76 | with pytest.raises(marshmallow.exceptions.ValidationError) as exc_info: 77 | schemas.MetaSchema().loads(yaml_text) 78 | 79 | assert exc_info.value.messages_dict \ 80 | == {"styles": {"invalid": ["Unknown field."]}} 81 | 82 | 83 | def _validate_field_recursive(path, field, gend_value): 84 | """Only validate the leaf nodes - we want *specific* values that have 85 | changed! 86 | """ 87 | if isinstance(field, Schema): 88 | for field_name, sub_field in field.fields.items(): 89 | _validate_field_recursive( 90 | f"{path}.{field_name}", sub_field, gend_value[field_name]) 91 | elif isinstance(field, fields.Nested): 92 | if field.dump_default is None: 93 | nested_field = field.nested() # type: ignore 94 | _validate_field_recursive(path, nested_field, gend_value) 95 | else: 96 | for field_name, sub_field in field.dump_default.items(): 97 | _validate_field_recursive( 98 | f"{path}.{field_name}", sub_field, gend_value[field_name]) 99 | elif isinstance(field, fields.Field): 100 | if isinstance(field.dump_default, datetime.datetime): 101 | return 102 | assert field.dump_default == gend_value, f"Default value not correct at {path}" 103 | elif isinstance(field, dict): 104 | for field_name, sub_field in field.items(): 105 | _validate_field_recursive( 106 | f"{path}.{field_name}", sub_field, gend_value[field_name]) 107 | else: 108 | assert field == gend_value, f"Default value not correct at {path}" 109 | 110 | 111 | def test_sanity_check_that_errors_are_detected(): 112 | """Perform a sanity check that we can actually catch errors in generating 113 | the default schema values. 114 | """ 115 | schema = schemas.MetaSchema() 116 | 117 | # force a discrepancy in the 118 | gend_default = schemas.MetaSchema().dump(None) 119 | gend_default["styles"]["padding"]["left"] = 100 120 | 121 | with pytest.raises(AssertionError) as excinfo: 122 | _validate_field_recursive( 123 | "__root__.styles", schema.fields["styles"], gend_default["styles"]) 124 | assert "Default value not correct at __root__.styles.padding" in str( 125 | excinfo) 126 | 127 | 128 | def test_styles_defaults(): 129 | """Ensure that style value defaults are generated correctly 130 | """ 131 | schema = schemas.MetaSchema() 132 | gend_default = schemas.MetaSchema().dump(None) 133 | _validate_field_recursive( 134 | "__root__.styles", schema.fields["styles"], gend_default["styles"]) 135 | 136 | 137 | def test_meta_defaults(): 138 | """Test that the default values in the schema are actually used 139 | """ 140 | schema = schemas.MetaSchema() 141 | gend_default = schemas.MetaSchema().dump(None) 142 | _validate_field_recursive("__root__", schema, gend_default) 143 | -------------------------------------------------------------------------------- /docs/source/getting_started.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _getting_started: 3 | 4 | Getting Started 5 | =============== 6 | 7 | Installation 8 | ------------ 9 | 10 | ``lookatme`` can be installed with pip using the command: 11 | 12 | .. code-block:: bash 13 | 14 | pip install lookatme 15 | 16 | Usage 17 | ----- 18 | 19 | The ``lookatme`` CLI has a few options to control it's behavior: 20 | 21 | .. code-block:: text 22 | 23 | {{LOOKATME_HELP_OUTPUT_INDENTED}} 24 | 25 | ``--live`` / ``--live-reload`` 26 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 27 | 28 | This flag turns on live reloading within lookatme. If the input markdown 29 | is a filepath (and not stdin), the filepath with be watched for changes to its 30 | modification time. If a change to the file's modification time is observed, 31 | the slide deck is re-read and rendered, keeping the current slide in focus. 32 | 33 | If your editor supports saving with every keystroke, instant slide updates 34 | are possible: 35 | 36 | .. image:: _static/lookatme_live_updates.gif 37 | :width: 800 38 | :alt: Live Updates 39 | 40 | ``-e EXT_NAME1,EXT_NAME2`` / ``--exts EXT_NAME1,EXT_NAME2`` 41 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 42 | 43 | Allows a comma-separated list of extension names to be pre-loaded into lookatme 44 | without requring them to be declared in the Markdown source. 45 | 46 | ``-s`` / ``--safe`` 47 | ^^^^^^^^^^^^^^^^^^^ 48 | 49 | Do **NOT** load any new extensions specified in the markdown (ignore them). New 50 | extensions are extensions that have not manually been allowed via the ``-e`` 51 | argument or the ``LOOKATME_EXTS`` environment variable. 52 | 53 | ``--no-ext-warn`` 54 | ^^^^^^^^^^^^^^^^^ 55 | 56 | Do not warn about new extensions that are to-be-loaded that are specified in 57 | the source markdown. New extensions are extensions that have not manually been 58 | allowed via the ``-e`` argument or the ``LOOKATME_EXTS`` environment variable. 59 | 60 | ``-i`` 61 | ^^^^^^ 62 | 63 | Ignore failure loading extensions. This does not ignore warnings, but ignores 64 | any hard-errors during import, such as ``ImportError``. 65 | 66 | 67 | ``--single`` / ``--one`` 68 | ^^^^^^^^^^^^^^^^^^^^^^^^ 69 | 70 | Render the markdown source as a single slide, ignoring all hrules. Scroll 71 | overflowing slides with the up/down arrow keys and page up/page down. 72 | 73 | ``--debug`` and ``--log`` 74 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 75 | 76 | Turns on debug logging for lookatme. The debug log will be created in your platform's 77 | temporary directory by default and will be named ``lookatme.log``: 78 | 79 | .. code-block:: bash 80 | 81 | $> lookatme slides.md --debug 82 | 83 | # in another terminal 84 | $> tail -f /tmp/lookatme.log 85 | DEBUG:lookatme.RENDER: Rendering token {'type': 'heading', 'level': 2, 'text': 'TOC'} 86 | DEBUG:lookatme.RENDER: Rendering token {'type': 'list_start', 'ordered': False} 87 | DEBUG:lookatme.RENDER: Rendering token {'type': 'list_item_start'} 88 | DEBUG:lookatme.RENDER: Rendering token {'type': 'text', 'text': '[Features](#features)'} 89 | DEBUG:lookatme.RENDER: Rendering token {'type': 'list_start', 'ordered': False} 90 | DEBUG:lookatme.RENDER: Rendering token {'type': 'list_item_start'} 91 | 92 | You may set a custom log location with the ``--log`` flag 93 | 94 | ``--theme`` 95 | ^^^^^^^^^^^ 96 | 97 | Themes in lookatme are pre-defined stylings. Lookatme comes with two built-in 98 | themes: ``dark`` and ``light``. These themes are intended to look good on 99 | dark terminals and light terminals. 100 | 101 | See the :ref:`dark_theme` and :ref:`light_theme` pages for more details. 102 | See the :ref:`style_precedence` page for details on the order style overrides 103 | and settings are applied. 104 | 105 | ``--style`` 106 | ^^^^^^^^^^^ 107 | 108 | This option overrides the `Pygments `_ syntax highlighting 109 | style to use. See the :ref:`style_precedence` for details about style overriding 110 | order. 111 | 112 | At the time of this writing, available Pygments style options include: 113 | 114 | * default 115 | * emacs 116 | * friendly 117 | * colorful 118 | * autumn 119 | * murphy 120 | * manni 121 | * monokai 122 | * perldoc 123 | * pastie 124 | * borland 125 | * trac 126 | * native 127 | * fruity 128 | * bw 129 | * vim 130 | * vs 131 | * tango 132 | * rrt 133 | * xcode 134 | * igor 135 | * paraiso-light 136 | * paraiso-dark 137 | * lovelace 138 | * algol 139 | * algol_nu 140 | * arduino 141 | * rainbow_dash 142 | * abap 143 | * solarized-dark 144 | * solarized-light 145 | * sas 146 | * stata 147 | * stata-light 148 | * stata-dark 149 | 150 | ``--dump-styles`` 151 | ^^^^^^^^^^^^^^^^^ 152 | 153 | Print the final, resolved style definition that will be used to render the 154 | markdown as currently specified on the command-line. See the :ref:`style_precedence` 155 | section for details on how this works. 156 | 157 | E.g.: 158 | 159 | .. code-block:: bash 160 | 161 | lookatme examples/tour.md -theme --style solarized-dark --dump-styles 162 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | 14 | import locale 15 | import os 16 | import sys 17 | 18 | 19 | def fake_locale_set(*args, **kwargs): 20 | try: 21 | locale.setlocale(*args, **kwargs) 22 | except Exception: 23 | pass 24 | orig_set_locale = locale.setlocale 25 | locale.setlocale = fake_locale_set 26 | import urwid 27 | locale.setlocale = orig_set_locale 28 | 29 | 30 | PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) 31 | DOCS_SOURCE_DIR = os.path.abspath(os.path.dirname(__file__)) 32 | 33 | 34 | def read_file(*parts): 35 | with open(os.path.join(PROJECT_DIR, *parts), "r") as f: 36 | return f.read() 37 | 38 | 39 | # sys.path.insert(0, os.path.abspath('.')) 40 | 41 | 42 | # -- Project information ----------------------------------------------------- 43 | 44 | project = 'lookatme' 45 | copyright = "2019, James 'd0c-s4vage' Johnson" 46 | author = "James 'd0c-s4vage' Johnson" 47 | 48 | 49 | # The full version, including alpha/beta/rc tags 50 | release = os.environ.get("READTHEDOCS_VERSION", '{{VERSION}}') 51 | 52 | 53 | # -- General configuration --------------------------------------------------- 54 | 55 | # Add any Sphinx extension module names here, as strings. They can be 56 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 57 | # ones. 58 | extensions = [ 59 | "sphinx.ext.autodoc", 60 | "sphinx.ext.viewcode", 61 | ] 62 | 63 | # Add any paths that contain templates here, relative to this directory. 64 | templates_path = ['_templates'] 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | # This pattern also affects html_static_path and html_extra_path. 69 | exclude_patterns = [] 70 | 71 | 72 | # -- Options for HTML output ------------------------------------------------- 73 | 74 | # The theme to use for HTML and HTML Help pages. See the documentation for 75 | # a list of builtin themes. 76 | 77 | # Add any paths that contain custom static files (such as style sheets) here, 78 | # relative to this directory. They are copied after the builtin static files, 79 | # so a file named "default.css" will overwrite the builtin "default.css". 80 | html_static_path = ['_static'] 81 | 82 | master_doc = "index" 83 | 84 | #----------------------------------------------------------------------------- 85 | # Generate list of overrideable funcitons within lookatme 86 | #----------------------------------------------------------------------------- 87 | 88 | 89 | def get_contrib_functions(*file_parts): 90 | render_module = file_parts[-1].replace(".py", "") 91 | full_mod_path = ".".join(list(file_parts[:-1]) + [render_module]) 92 | lines = read_file(*file_parts).split("\n") 93 | 94 | res = [] 95 | in_contrib = False 96 | for idx, line in enumerate(lines): 97 | line = line.strip() 98 | 99 | if "@contrib_first" == line: 100 | in_contrib = True 101 | continue 102 | if line.startswith("@"): 103 | continue 104 | elif line.startswith("def "): 105 | if in_contrib: 106 | fn_name = line.split()[1].split("(")[0] 107 | res.append(f":any:`{fn_name} <{full_mod_path}.{fn_name}>`") 108 | in_contrib = False 109 | return res 110 | 111 | 112 | contrib_fns = [] 113 | contrib_fns += get_contrib_functions("lookatme", "render", "markdown_block.py") 114 | contrib_fns += get_contrib_functions("lookatme", "render", "markdown_inline.py") 115 | contrib_fns += get_contrib_functions("lookatme", "tui.py") 116 | 117 | 118 | list_text = [] 119 | for fn_ref in contrib_fns: 120 | list_text.append(f" * {fn_ref}") 121 | list_text = "\n".join(list_text) 122 | 123 | 124 | with open(os.path.join(DOCS_SOURCE_DIR, "contrib_extensions.rst"), "r") as f: 125 | orig_data = f.read() 126 | 127 | new_data = orig_data.replace("LOOKATME_OVERRIDES", list_text) 128 | 129 | with open(os.path.join(DOCS_SOURCE_DIR, "contrib_extensions_auto.rst"), "w") as f: 130 | f.write(new_data) 131 | 132 | 133 | def run_apidoc(_): 134 | from sphinx.ext.apidoc import main 135 | import os 136 | import sys 137 | 138 | sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..")) 139 | cur_dir = os.path.abspath(os.path.dirname(__file__)) 140 | module = os.path.join(cur_dir, "..", "..", "lookatme") 141 | main(["-e", "-o", os.path.join(cur_dir, "autodoc"), module, "--force"]) 142 | 143 | 144 | def setup(app): 145 | app.connect('builder-inited', run_apidoc) 146 | -------------------------------------------------------------------------------- /bin/ci: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 5 | THIS_PROG="$0" 6 | 7 | set -o pipefail 8 | 9 | # load utility functions 10 | . "${DIR}/_utils.sh" 11 | 12 | # ----------------------------------------------------------------- 13 | # Main Functions -------------------------------------------------- 14 | # ----------------------------------------------------------------- 15 | 16 | SUBCOMMAND="all" 17 | AUTOFIX=false 18 | COLOR=false 19 | RUN_PREFIX="run_boxed" 20 | FORWARDED_OPTS=() 21 | 22 | CODE_DIRS=( 23 | lookatme 24 | tests 25 | ) 26 | 27 | function run_flake8 { 28 | if [ "$AUTOFIX" == "true" ] ; then 29 | log "No autofix for flake8" 30 | else 31 | local exit_code=0 32 | local color_opt="" 33 | [ "$COLOR" == true ] && color_opt="--color always" 34 | 35 | $RUN_PREFIX flake8 "${CODE_DIRS[@]}" $color_opt \ 36 | --count \ 37 | --select=E9,F63,F7,F82 \ 38 | --show-source \ 39 | --statistics 40 | [ $? -ne 0 ] && exit_code=1 41 | 42 | $RUN_PREFIX flake8 "${CODE_DIRS[@]}" $color_opt \ 43 | --count \ 44 | --exit-zero \ 45 | --max-complexity=10 \ 46 | --max-line-length=127 \ 47 | --statistics 48 | [ $? -ne 0 ] && exit_code=1 49 | 50 | return $exit_code 51 | fi 52 | } 53 | 54 | function run_autopep8 { 55 | if [ "$AUTOFIX" == "true" ] ; then 56 | $RUN_PREFIX autopep8 -r --in-place "${CODE_DIRS[@]}" 57 | else 58 | log "autopep8 is only used with --autofix" 59 | fi 60 | } 61 | 62 | function run_pytest { 63 | if [ "$AUTOFIX" == "true" ] ; then 64 | log "🤣 LOL, no autofix for pytest!" 65 | else 66 | local color_opt="" 67 | [ "$COLOR" == true ] && color_opt="--color=yes" 68 | 69 | $RUN_PREFIX pytest $color_opt "$@" 70 | fi 71 | } 72 | 73 | function run_isort { 74 | if [ "$AUTOFIX" == "true" ] ; then 75 | $RUN_PREFIX isort "${CODE_DIRS[@]}" 76 | else 77 | $RUN_PREFIX isort --check --diff "${CODE_DIRS[@]}" 78 | fi 79 | } 80 | 81 | function run_pyright { 82 | if [ "$AUTOFIX" == "true" ] ; then 83 | log "🤣 LOL, no autofix for pyright!" 84 | else 85 | if [ "$COLOR" == true ] ; then 86 | pyright "${CODE_DIRS[@]}" 87 | else 88 | $RUN_PREFIX pyright "${CODE_DIRS[@]}" 89 | fi 90 | fi 91 | } 92 | 93 | function run_all { 94 | check_deps --log \ 95 | isort \ 96 | flake8 \ 97 | pytest \ 98 | autopep8 \ 99 | pyright 100 | if [ $? -ne 0 ] ; then 101 | log "Can't run all, did you forget to use a virtual environment?" 102 | exit 1 103 | fi 104 | 105 | run_with_summary \ 106 | run_isort \ 107 | run_flake8 \ 108 | run_autopep8 \ 109 | run_pyright \ 110 | run_pytest 111 | } 112 | 113 | function show_help { 114 | cat <<-EOF 115 | USAGE: ${THIS_PROG} [SUBCOMMAND] [--auto|--autofix] [--color] [-- fwd opts] 116 | 117 | This is a basic way to run CI locally for lookatme. This is the same script 118 | that lookatme's CI uses in GitHub actions. Running this script with zero 119 | arguments (or the subcommand "all") will run all linting/analysis/tests that 120 | CI runs. 121 | 122 | SUBCOMMAND may be one of the below values. The default is "all": 123 | all - Run all commands 124 | isort - Run isort 125 | pytest - Run pytest tests 126 | flake8 - Run flake8 127 | autopep8 - Run autopep8 128 | 129 | --autofix Run the command's autofix functionality 130 | --color Run the command with color 131 | --plain Do not box the output of the command, let it be printed plain 132 | -- Forward all remaining options to the subcommand 133 | EOF 134 | exit 1 135 | } 136 | 137 | function parse_args { 138 | while [ $# -ne 0 ] ; do 139 | param="$1" 140 | shift 141 | 142 | case "$param" in 143 | --help|-h) 144 | show_help 145 | exit 1 146 | ;; 147 | all|pytest|flake8|test|isort|autopep8|pyright) 148 | SUBCOMMAND="$param" 149 | ;; 150 | --plain) 151 | RUN_PREFIX="" 152 | ;; 153 | --color) 154 | COLOR=true 155 | ;; 156 | --autofix|--auto) 157 | AUTOFIX=true 158 | ;; 159 | --) 160 | while [ $# -ne 0 ] ; do 161 | FORWARDED_OPTS+=("$1") 162 | shift 163 | done 164 | ;; 165 | *) 166 | echo "[!] Unrecognized parameter $param" 167 | echo 168 | show_help 169 | exit 1 170 | ;; 171 | esac 172 | done 173 | } 174 | 175 | parse_args "$@" 176 | 177 | case "$SUBCOMMAND" in 178 | all) 179 | run_all 180 | ;; 181 | isort) 182 | run_isort "${FORWARDED_OPTS[@]}" 183 | ;; 184 | flake8) 185 | run_flake8 "${FORWARDED_OPTS[@]}" 186 | ;; 187 | autopep8) 188 | run_autopep8 "${FORWARDED_OPTS[@]}" 189 | ;; 190 | pyright) 191 | run_pyright "${FORWARDED_OPTS[@]}" 192 | ;; 193 | test|pytest) 194 | run_pytest "${FORWARDED_OPTS[@]}" 195 | ;; 196 | esac 197 | -------------------------------------------------------------------------------- /lookatme/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | This is the main CLI for lookatme 5 | """ 6 | 7 | 8 | import io 9 | import os 10 | import tempfile 11 | 12 | import click 13 | import pygments.styles 14 | 15 | import lookatme 16 | import lookatme.config 17 | import lookatme.log 18 | import lookatme.tui 19 | import lookatme.tutorial 20 | from lookatme.pres import Presentation 21 | from lookatme.schemas import StyleSchema 22 | 23 | 24 | @click.command("lookatme") 25 | @click.option("--debug", "debug", is_flag=True, default=False) 26 | @click.option( 27 | "-l", 28 | "--log", 29 | "log_path", 30 | type=click.Path(writable=True), 31 | default=os.path.join(tempfile.gettempdir(), "lookatme.log"), 32 | ) 33 | @click.option( 34 | "--tutorial", 35 | "tutorial", 36 | is_flag=False, 37 | flag_value="all", 38 | show_default=True, 39 | help=( 40 | "As a flag: show all tutorials. " 41 | "With a value/comma-separated values: show the specific tutorials. " 42 | "Use the value 'help' for more help" 43 | ) 44 | ) 45 | @click.option( 46 | "-t", 47 | "--theme", 48 | "theme", 49 | type=click.Choice(["dark", "light"]), 50 | default="dark", 51 | ) 52 | @click.option( 53 | "--style", 54 | "code_style", 55 | default=None, 56 | type=click.Choice(list(pygments.styles.get_all_styles())), 57 | ) 58 | @click.option( 59 | "--dump-styles", 60 | help="Dump the resolved styles that will be used with the presentation to stdout", 61 | is_flag=True, 62 | default=False, 63 | ) 64 | @click.option( 65 | "--live", 66 | "--live-reload", 67 | "live_reload", 68 | help="Watch the input filename for modifications and automatically reload", 69 | is_flag=True, 70 | default=False, 71 | ) 72 | @click.option( 73 | "-s", 74 | "--safe", 75 | help="Do not load any new extensions specified in the source markdown. " 76 | "Extensions specified via env var or -e are still loaded", 77 | is_flag=True, 78 | default=False, 79 | ) 80 | @click.option( 81 | "--no-ext-warn", 82 | help="Load new extensions specified in the source markdown without warning", 83 | is_flag=True, 84 | default=False, 85 | ) 86 | @click.option( 87 | "-i", 88 | "--ignore-ext-failure", 89 | help="Ignore load failures of extensions", 90 | is_flag=True, 91 | default=False, 92 | ) 93 | @click.option( 94 | "-e", 95 | "--exts", 96 | "extensions", 97 | help="A comma-separated list of extension names to automatically load" 98 | " (LOOKATME_EXTS)", 99 | envvar="LOOKATME_EXTS", 100 | default="", 101 | ) 102 | @click.option( 103 | "--single", 104 | "--one", 105 | "single_slide", 106 | help="Render the source as a single slide", 107 | is_flag=True, 108 | default=False 109 | ) 110 | @click.version_option(lookatme.__version__) 111 | @click.argument( 112 | "input_files", 113 | type=click.File("r"), 114 | nargs=-1, 115 | ) 116 | def main(tutorial, debug, log_path, theme, code_style, dump_styles, 117 | input_files, live_reload, extensions, single_slide, safe, no_ext_warn, 118 | ignore_ext_failure): 119 | """lookatme - An interactive, terminal-based markdown presentation tool. 120 | 121 | See https://lookatme.readthedocs.io/en/v{{VERSION}} for documentation 122 | """ 123 | if debug: 124 | lookatme.config.LOG = lookatme.log.create_log(log_path) 125 | else: 126 | lookatme.config.LOG = lookatme.log.create_null_log() 127 | 128 | if len(input_files) == 0: 129 | input_files = [io.StringIO("")] 130 | 131 | if tutorial: 132 | if tutorial == "all": 133 | tutors = ["general", "markdown"] 134 | else: 135 | tutors = [x.strip() for x in tutorial.split(",")] 136 | 137 | theme_mod = __import__("lookatme.themes." + theme, fromlist=[theme]) 138 | lookatme.config.set_global_style_with_precedence( 139 | theme_mod, 140 | {}, 141 | code_style, 142 | ) 143 | tutorial_md = lookatme.tutorial.get_tutorial_md(tutors) 144 | if tutorial_md is None: 145 | lookatme.tutorial.print_tutorial_help() 146 | return 1 147 | 148 | input_files = [io.StringIO(tutorial_md)] 149 | 150 | preload_exts = [x.strip() for x in extensions.split(",")] 151 | preload_exts = list(filter(lambda x: x != "", preload_exts)) 152 | pres = Presentation( 153 | input_files[0], 154 | theme, 155 | code_style, 156 | live_reload=live_reload, 157 | single_slide=single_slide, 158 | preload_extensions=preload_exts, 159 | safe=safe, 160 | no_ext_warn=no_ext_warn, 161 | ignore_ext_failure=ignore_ext_failure, 162 | ) 163 | 164 | if dump_styles: 165 | print(StyleSchema().dumps(pres.styles)) 166 | return 0 167 | 168 | try: 169 | pres.run() 170 | except Exception as e: 171 | number = pres.get_tui().curr_slide.number + 1 172 | click.echo(f"Error rendering slide {number}: {e}") 173 | if not debug: 174 | click.echo("Rerun with --debug to view the full traceback in logs") 175 | else: 176 | lookatme.config.get_log().exception( 177 | f"Error rendering slide {number}: {e}") 178 | click.echo(f"See {log_path} for traceback") 179 | raise click.Abort() 180 | 181 | 182 | if __name__ == "__main__": 183 | main() 184 | -------------------------------------------------------------------------------- /lookatme/pres.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines Presentation specific objects 3 | """ 4 | 5 | 6 | import os 7 | import threading 8 | import time 9 | 10 | import lookatme.ascii_art 11 | import lookatme.config 12 | import lookatme.contrib 13 | import lookatme.prompt 14 | import lookatme.themes 15 | import lookatme.tui 16 | from lookatme.parser import Parser 17 | from lookatme.tutorial import tutor 18 | 19 | 20 | @tutor( 21 | "general", 22 | "intro", 23 | r""" 24 | `lookatme` is a terminal-based markdown presentation tool. 25 | 26 | That means that you can: 27 | 28 | | Write | Render | Use | 29 | |-----------------------|-----------------|--------------------------------| 30 | | basic markdown slides | in the terminal | anywhere with markdown support | 31 | 32 | > **NOTE** `l | j | right arrow` advance the slides 33 | > 34 | > **NOTE** `q` quits 35 | """, 36 | order=0, 37 | ) 38 | class Presentation(object): 39 | """Defines a presentation 40 | """ 41 | 42 | def __init__(self, input_stream, theme, style_override=None, live_reload=False, 43 | single_slide=False, preload_extensions=None, safe=False, 44 | no_ext_warn=False, ignore_ext_failure=False): 45 | """Creates a new Presentation 46 | 47 | :param stream input_stream: An input stream from which to read the 48 | slide data 49 | """ 50 | self.preload_extensions = preload_extensions or [] 51 | self.input_filename = None 52 | if hasattr(input_stream, "name"): 53 | lookatme.config.SLIDE_SOURCE_DIR = os.path.dirname( 54 | input_stream.name) 55 | self.input_filename = input_stream.name 56 | 57 | self.style_override = style_override 58 | self.live_reload = live_reload 59 | self.tui = None 60 | self.single_slide = single_slide 61 | self.safe = safe 62 | self.no_ext_warn = no_ext_warn 63 | self.ignore_ext_failure = ignore_ext_failure 64 | self.initial_load_complete = False 65 | 66 | self.theme_mod = __import__( 67 | "lookatme.themes." + theme, fromlist=[theme]) 68 | 69 | if self.live_reload: 70 | self.reload_thread = threading.Thread(target=self.reload_watcher) 71 | self.reload_thread.daemon = True 72 | self.reload_thread.start() 73 | 74 | self.reload(data=input_stream.read()) 75 | self.initial_load_complete = True 76 | 77 | def reload_watcher(self): 78 | """Watch for changes to the input filename, automatically reloading 79 | when the modified time has changed. 80 | """ 81 | if self.input_filename is None: 82 | return 83 | 84 | last_mod_time = os.path.getmtime(self.input_filename) 85 | while True: 86 | try: 87 | curr_mod_time = os.path.getmtime(self.input_filename) 88 | if curr_mod_time != last_mod_time: 89 | self.get_tui().reload() 90 | self.get_tui().loop.draw_screen() 91 | last_mod_time = curr_mod_time 92 | except Exception: 93 | pass 94 | finally: 95 | time.sleep(0.25) 96 | 97 | def reload(self, data=None): 98 | """Reload this presentation 99 | 100 | :param str data: The data to render for this slide deck (optional) 101 | """ 102 | if data is None: 103 | with open(str(self.input_filename), "r") as f: 104 | data = f.read() 105 | 106 | parser = Parser(single_slide=self.single_slide) 107 | self.meta, self.slides = parser.parse(data) 108 | 109 | # only load extensions once! Live editing does not support 110 | # auto-extension reloading 111 | if not self.initial_load_complete: 112 | safe_exts = set(self.preload_extensions) 113 | new_exts = set() 114 | # only load if running with safe=False 115 | if not self.safe: 116 | source_exts = set(self.meta.get("extensions", [])) 117 | new_exts = source_exts - safe_exts 118 | self.warn_exts(new_exts) 119 | 120 | all_exts = safe_exts | new_exts 121 | 122 | lookatme.contrib.load_contribs( 123 | all_exts, 124 | safe_exts, 125 | self.ignore_ext_failure, 126 | ) 127 | 128 | self.styles = lookatme.config.set_global_style_with_precedence( 129 | self.theme_mod, 130 | self.meta.get("styles", {}), 131 | self.style_override, 132 | ) 133 | 134 | self.initial_load_complete = True 135 | 136 | def warn_exts(self, exts): 137 | """Warn about source-provided extensions that are to-be-loaded 138 | """ 139 | if len(exts) == 0 or self.no_ext_warn: 140 | return 141 | 142 | warning = lookatme.ascii_art.WARNING 143 | print("\n".join([" " + x for x in warning.split("\n")])) 144 | 145 | print("New extensions required by {!r} are about to be loaded:\n".format( 146 | self.input_filename 147 | )) 148 | for ext in exts: 149 | print(" - {!r}".format("lookatme.contrib." + ext)) 150 | print("") 151 | 152 | if not lookatme.prompt.yes("Are you ok with attempting to load them?"): 153 | print("Bailing due to unacceptance of source-required extensions") 154 | exit(1) 155 | 156 | def run(self, start_slide=0): 157 | """Run the presentation! 158 | """ 159 | self.tui = lookatme.tui.create_tui(self, start_slide=start_slide) 160 | self.tui.run() 161 | 162 | def get_tui(self) -> lookatme.tui.MarkdownTui: 163 | if self.tui is None: 164 | raise ValueError( 165 | "Tui has not been set, has the presentation been run yet?") 166 | return self.tui 167 | -------------------------------------------------------------------------------- /lookatme/render/pygments.py: -------------------------------------------------------------------------------- 1 | """Pygments related rendering 2 | """ 3 | 4 | 5 | import time 6 | 7 | import pygments 8 | import pygments.lexers 9 | import pygments.styles 10 | import pygments.util 11 | import urwid 12 | from pygments.formatter import Formatter 13 | 14 | import lookatme.config as config 15 | 16 | LEXER_CACHE = {} 17 | STYLE_CACHE = {} 18 | FORMATTER_CACHE = {} 19 | 20 | 21 | def get_formatter(style_name): 22 | style = get_style(style_name) 23 | 24 | formatter, style_bg = FORMATTER_CACHE.get(style_name, (None, None)) 25 | if formatter is None: 26 | style_bg = UrwidFormatter.findclosest( 27 | style.background_color.replace("#", "")) 28 | formatter = UrwidFormatter( 29 | style=style, 30 | usebg=(style_bg is not None), 31 | ) 32 | FORMATTER_CACHE[style_name] = (formatter, style_bg) 33 | return formatter, style_bg 34 | 35 | 36 | def get_lexer(lang, default="text"): 37 | lexer = LEXER_CACHE.get(lang, None) 38 | if lexer is None: 39 | try: 40 | lexer = pygments.lexers.get_lexer_by_name(lang) 41 | except pygments.util.ClassNotFound: 42 | lexer = pygments.lexers.get_lexer_by_name(default) 43 | LEXER_CACHE[lang] = lexer 44 | return lexer 45 | 46 | 47 | def get_style(style_name): 48 | style = STYLE_CACHE.get(style_name, None) 49 | if style is None: 50 | style = pygments.styles.get_style_by_name(style_name) 51 | STYLE_CACHE[style_name] = style 52 | return style 53 | 54 | 55 | def render_text(text, lang="text", style_name=None, plain=False): 56 | """Render the provided text with the pygments renderer 57 | """ 58 | if style_name is None: 59 | style_name = config.get_style()["style"] 60 | 61 | lexer = get_lexer(lang) 62 | formatter, style_bg = get_formatter(style_name) 63 | 64 | start = time.time() 65 | code_tokens = lexer.get_tokens(text) 66 | config.get_log().debug( 67 | f"Took {time.time()-start}s to render {len(text)} bytes") 68 | 69 | markup = [] 70 | for x in formatter.formatgenerator(code_tokens): 71 | if style_bg: 72 | x[0].background = style_bg 73 | markup.append(x) 74 | 75 | if markup[-1][1] == "\n": 76 | markup = markup[:-1] 77 | 78 | if len(markup) == 0: 79 | markup = [(None, "")] 80 | elif markup[-1][1].endswith("\n"): 81 | markup[-1] = (markup[-1][0], markup[-1][1][:-1]) 82 | 83 | if plain: 84 | return markup 85 | else: 86 | return urwid.AttrMap(urwid.Text(markup), urwid.AttrSpec("default", style_bg)) 87 | 88 | 89 | class UrwidFormatter(Formatter): 90 | """Formatter that returns [(text,attrspec), ...], 91 | where text is a piece of text, and attrspec is an urwid.AttrSpec""" 92 | 93 | def __init__(self, **options): 94 | """Extra arguments: 95 | 96 | usebold: if false, bold will be ignored and always off 97 | default: True 98 | usebg: if false, background color will always be 'default' 99 | default: True 100 | colors: number of colors to use (16, 88, or 256) 101 | default: 256""" 102 | self.usebold = options.get('usebold', True) 103 | self.usebg = options.get('usebg', True) 104 | self.style_attrs = {} 105 | Formatter.__init__(self, **options) 106 | 107 | @property 108 | def style(self): 109 | return self._style 110 | 111 | @style.setter 112 | def style(self, newstyle): 113 | self._style = newstyle 114 | self._setup_styles() 115 | 116 | @staticmethod 117 | def _distance(col1, col2): 118 | r1, g1, b1 = col1 119 | r2, g2, b2 = col2 120 | 121 | rd = r1 - r2 122 | gd = g1 - g2 123 | bd = b1 - b2 124 | 125 | return rd*rd + gd*gd + bd*bd 126 | 127 | @classmethod 128 | def findclosest(cls, colstr, colors=256): 129 | """Takes a hex string and finds the nearest color to it. 130 | 131 | Returns a string urwid will recognize.""" 132 | 133 | rgb = int(colstr, 16) 134 | r = (rgb >> 16) & 0xff 135 | g = (rgb >> 8) & 0xff 136 | b = rgb & 0xff 137 | 138 | dist = 257 * 257 * 3 139 | bestcol = urwid.AttrSpec('h0', 'default') 140 | 141 | for i in range(colors): 142 | curcol = urwid.AttrSpec('h%d' % i, 'default', colors=colors) 143 | currgb = curcol.get_rgb_values()[:3] 144 | curdist = cls._distance((r, g, b), currgb) 145 | if curdist < dist: 146 | dist = curdist 147 | bestcol = curcol 148 | 149 | return bestcol.foreground 150 | 151 | def findclosestattr(self, fgcolstr=None, bgcolstr=None, othersettings='', colors=256): 152 | """Takes two hex colstring (e.g. 'ff00dd') and returns the 153 | nearest urwid style.""" 154 | fg = bg = 'default' 155 | if fgcolstr: 156 | fg = self.findclosest(fgcolstr, colors) 157 | if bgcolstr: 158 | bg = self.findclosest(bgcolstr, colors) 159 | if othersettings: 160 | fg = fg + ',' + othersettings 161 | return urwid.AttrSpec(fg, bg, colors) 162 | 163 | def _setup_styles(self, colors=256): 164 | """Fills self.style_attrs with urwid.AttrSpec attributes 165 | corresponding to the closest equivalents to the given style.""" 166 | for ttype, ndef in self.style: 167 | fgcolstr = bgcolstr = None 168 | othersettings = '' 169 | if ndef['color']: 170 | fgcolstr = ndef['color'] 171 | if self.usebg and ndef['bgcolor']: 172 | bgcolstr = ndef['bgcolor'] 173 | if self.usebold and ndef['bold']: 174 | othersettings = 'bold' 175 | self.style_attrs[str(ttype)] = self.findclosestattr( 176 | fgcolstr, bgcolstr, othersettings, colors) 177 | 178 | def formatgenerator(self, tokensource): 179 | """Takes a token source, and generates 180 | (tokenstring, urwid.AttrSpec) pairs""" 181 | for (ttype, tstring) in tokensource: 182 | parts = str(ttype).split(".") 183 | while str(ttype) not in self.style_attrs: 184 | parts = parts[:-1] 185 | ttype = ".".join(parts) 186 | 187 | attr = self.style_attrs[str(ttype)] 188 | yield attr, tstring 189 | 190 | def format(self, tokensource, outfile): 191 | for (attr, tstring) in self.formatgenerator(tokensource): 192 | outfile.write(attr, tstring) 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Master Build Status](https://travis-ci.org/d0c-s4vage/lookatme.svg?branch=master)](https://travis-ci.org/d0c-s4vage/lookatme) 2 | [![Coverage Status](https://coveralls.io/repos/github/d0c-s4vage/lookatme/badge.svg?branch=master)](https://coveralls.io/github/d0c-s4vage/lookatme?branch=master) 3 | [![PyPI Statistics](https://img.shields.io/pypi/dm/lookatme)](https://pypistats.org/packages/lookatme) 4 | [![Latest Release](https://img.shields.io/pypi/v/lookatme)](https://pypi.python.org/pypi/lookatme/) 5 | [![Documentation Status](https://readthedocs.org/projects/lookatme/badge/?version=latest)](https://lookatme.readthedocs.io/en/latest/) 6 | 7 | [![Twitter Follow](https://img.shields.io/twitter/follow/d0c_s4vage?style=plastic)](https://twitter.com/d0c_s4vage) 8 | 9 | # `lookatme` 10 | 11 | `lookatme` is an interactive, extensible, terminal-based markdown presentation 12 | tool. 13 | 14 | ## TOC 15 | 16 | - [TOC](#toc) 17 | - [Features](#features) 18 | * [Tour](#tour) 19 | - [Navigating the Presentation](#navigating-the-presentation) 20 | - [CLI Options](#cli-options) 21 | - [Known Extensions](#known-extensions) 22 | - [Documentation](#documentation) 23 | 24 | ## Features 25 | 26 | * Markdown rendering 27 | * Built-in tutorial slides `lookatme --tutorial` 28 | * Live (input file modification time watching) and manual reloading 29 | * Live terminals embedded directly in slides 30 | * Syntax highlighting using [Pygments](https://pygments.org/) 31 | * Loading external files into code blocks 32 | * Support for contrib extensions 33 | * Smart slide splitting 34 | * Progressive slides with `` comments between block elements 35 | 36 | ### Tutorial 37 | 38 | ```bash 39 | pip install --upgrade lookatme 40 | lookatme --tutorial 41 | ``` 42 | 43 | **NOTE**: [lookatme 3.0](https://github.com/d0c-s4vage/lookatme/milestone/1) is nearing completion! Check out the latest release 44 | candidate with: `pip install --upgrade --pre lookatme`. Be warned, version 3.0 45 | may not be as stable and contains breaking changes (mostly with styles) from 46 | previous major versions. 47 | 48 | General tour 49 | 50 | ![lookatme_tour](docs/source/_static/lookatme_tour.gif) 51 | 52 | Embedded terminal example 53 | 54 | ![terminal example](docs/source/_static/ext_terminal_example.gif) 55 | 56 | Sourcing external files example 57 | 58 | ![file loader example](docs/source/_static/ext_file_loader_example.gif) 59 | 60 | ## Navigating the Presentation 61 | 62 | | Action | Keys | Notes | 63 | |--------------------------------|----------------------------------|-------| 64 | | Next Slide | `l j right space` | | 65 | | Prev Slide | `h k left delete backspace` | | 66 | | Quit | `q Q` | | 67 | | Terminal Focus | Click on the terminal | | 68 | | Exit Terminal | `ctrl+a` and then a slide action | | 69 | | Vertically scroll within slide | `up/down or page up/page down` | | 70 | 71 | ## CLI Options 72 | 73 | ``` 74 | Usage: lookatme [OPTIONS] [INPUT_FILES]... 75 | 76 | lookatme - An interactive, terminal-based markdown presentation tool. 77 | 78 | See https://lookatme.readthedocs.io/en/v{{VERSION}} for documentation 79 | 80 | Options: 81 | --debug 82 | -l, --log PATH 83 | --tutorial TEXT As a flag: show all tutorials. With a 84 | value/comma-separated values: show the 85 | specific tutorials. Use the value 'help' for 86 | more help 87 | -t, --theme [dark|light] 88 | --style [default|emacs|friendly|friendly_grayscale|colorful|autumn|murphy|manni|material|monokai|perldoc|pastie|borland|trac|native|fruity|bw|vim|vs|tango|rrt|xcode|igor|paraiso-light|paraiso-dark|lovelace|algol|algol_nu|arduino|rainbow_dash|abap|solarized-dark|solarized-light|sas|staroffice|stata|stata-light|stata-dark|inkpot|zenburn|gruvbox-dark|gruvbox-light|dracula|one-dark|lilypond|nord|nord-darker|github-dark] 89 | --dump-styles Dump the resolved styles that will be used 90 | with the presentation to stdout 91 | --live, --live-reload Watch the input filename for modifications 92 | and automatically reload 93 | -s, --safe Do not load any new extensions specified in 94 | the source markdown. Extensions specified 95 | via env var or -e are still loaded 96 | --no-ext-warn Load new extensions specified in the source 97 | markdown without warning 98 | -i, --ignore-ext-failure Ignore load failures of extensions 99 | -e, --exts TEXT A comma-separated list of extension names to 100 | automatically load (LOOKATME_EXTS) 101 | --single, --one Render the source as a single slide 102 | --version Show the version and exit. 103 | --help Show this message and exit. 104 | ``` 105 | 106 | ## Known Extensions 107 | 108 | Below is a list of known extensions for lookatme: 109 | 110 | | Extension Name | Install Name | Notes | 111 | |----------------|--------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------| 112 | | qrcode | [lookatme.contrib.qrcode](https://github.com/d0c-s4vage/lookatme.contrib.qrcode) | Renders QR codes from code blocks | 113 | | image_ueberzug | [lookatme.contrib.image_ueberzug](https://github.com/d0c-s4vage/lookatme.contrib.image_ueberzug) | Renders images with [ueberzug](https://github.com/seebye/ueberzug) (Linux only) | 114 | | render | [lookatme.contrib.render](https://github.com/d0c-s4vage/lookatme.contrib.render) | Renders supported code blocks (graphviz and mermaid-js) by calling an external program. requires an image-rendering extension | 115 | 116 | ## Documentation 117 | 118 | See the [documentation](https://lookatme.readthedocs.io/en/latest/) for details. 119 | -------------------------------------------------------------------------------- /lookatme/widgets/table.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines a basic Table widget for urwid 3 | """ 4 | 5 | 6 | from collections import defaultdict 7 | from typing import List, Optional 8 | 9 | import urwid 10 | 11 | import lookatme.config as config 12 | from lookatme.render.markdown_block import render_text 13 | from lookatme.utils import styled_text 14 | from lookatme.widgets.clickable_text import ClickableText 15 | 16 | 17 | class Table(urwid.Pile): 18 | """Create a table from a list of headers, alignment values, and rows. 19 | """ 20 | 21 | signals = ["change"] 22 | 23 | def __init__(self, rows, headers=None, aligns: Optional[List[str]] = None): 24 | """Create a new table 25 | 26 | :param list columns: The rows to use for the table 27 | :param list headers: (optional) Headers for the table 28 | :param list aligns: (optional) Alignment values for each column 29 | """ 30 | self.table_rows = rows 31 | self.table_headers = headers 32 | 33 | if headers is not None: 34 | self.num_columns = len(headers) 35 | elif headers is None: 36 | self.num_columns = len(rows[0]) 37 | else: 38 | raise ValueError( 39 | "Invalid table specification: could not determine # of columns" 40 | ) 41 | 42 | if aligns is None: 43 | aligns = ["left"] * self.num_columns 44 | self.table_aligns = aligns 45 | 46 | def header_modifier(cell): 47 | return ClickableText(styled_text(cell.text, "bold"), align=cell.align) 48 | 49 | if self.table_headers is not None: 50 | self.rend_headers = self.create_cells( 51 | [self.table_headers], modifier=header_modifier 52 | ) 53 | else: 54 | self.rend_headers = [] 55 | self.rend_rows = self.create_cells(self.table_rows) 56 | 57 | self.column_maxes = self.calc_column_maxes() 58 | 59 | cell_spacing = config.get_style()["table"]["column_spacing"] 60 | self.total_width = sum(self.column_maxes.values()) + ( 61 | cell_spacing * (self.num_columns - 1) 62 | ) 63 | 64 | # final rows 65 | final_rows = [] 66 | 67 | # put headers in Columns 68 | if self.table_headers is not None: 69 | header_columns = [] 70 | for idx, header in enumerate(self.rend_headers[0]): 71 | header = header[0] 72 | header_with_div = urwid.Pile([ 73 | self.watch(header), 74 | urwid.Divider(config.get_style()[ 75 | "table"]["header_divider"]), 76 | ]) 77 | header_columns.append( 78 | (self.column_maxes[idx], header_with_div)) 79 | final_rows.append(urwid.Columns(header_columns, cell_spacing)) 80 | 81 | for rend_row in self.rend_rows: 82 | row_columns = [] 83 | for cell_idx, rend_cell in enumerate(rend_row): 84 | rend_widgets = [self.watch(rend_widget) 85 | for rend_widget in rend_cell] 86 | rend_pile = urwid.Pile(rend_widgets) 87 | row_columns.append((self.column_maxes[cell_idx], rend_pile)) 88 | 89 | column_row = urwid.Columns(row_columns, cell_spacing) 90 | final_rows.append(column_row) 91 | 92 | urwid.Pile.__init__(self, final_rows) 93 | 94 | def render(self, *args, **kwargs): 95 | """Do whatever needs to be done to render the table 96 | """ 97 | self.set_column_maxes() 98 | return urwid.Pile.render(self, *args, **kwargs) 99 | 100 | def watch(self, w): 101 | """Watch the provided widget w for changes 102 | """ 103 | if "change" not in getattr(w, "signals", []): 104 | return w 105 | 106 | def wrapper(*_, **__): 107 | self._invalidate() 108 | self._emit("change") 109 | 110 | urwid.connect_signal(w, "change", wrapper) 111 | return w 112 | 113 | def _invalidate(self): 114 | self.set_column_maxes() 115 | urwid.Pile._invalidate(self) 116 | 117 | def set_column_maxes(self): 118 | """Calculate and set the column maxes for this table 119 | """ 120 | self.column_maxes = self.calc_column_maxes() 121 | cell_spacing = config.get_style()["table"]["column_spacing"] 122 | self.total_width = sum(self.column_maxes.values()) + ( 123 | cell_spacing * (self.num_columns - 1) 124 | ) 125 | 126 | for columns, info in self.contents: 127 | # row should be a Columns instance 128 | new_columns = [] 129 | for idx, column_items in enumerate(columns.contents): 130 | column_widget, column_info = column_items 131 | new_columns.append(( 132 | column_widget, 133 | (column_info[0], self.column_maxes[idx], column_info[2]), 134 | )) 135 | columns.contents = new_columns 136 | 137 | def calc_column_maxes(self): 138 | column_maxes = defaultdict(int) 139 | for row in self.rend_headers + self.rend_rows: 140 | for idx, cell in enumerate(row): 141 | for widget in cell: 142 | if not isinstance(widget, urwid.Text): 143 | widg_len = 15 144 | else: 145 | widg_len = len(widget.text) 146 | if idx > self.num_columns: 147 | break 148 | column_maxes[idx] = max(column_maxes[idx], widg_len) 149 | return column_maxes 150 | 151 | def create_cells(self, body_rows, modifier=None): 152 | """Create the rows for the body, optionally calling a modifier function 153 | on each created cell Text. The modifier must accept an urwid.Text object 154 | and must return an urwid.Text object. 155 | """ 156 | res = [] 157 | 158 | for row in body_rows: 159 | rend_row = [] 160 | for idx, cell in enumerate(row): 161 | if idx >= self.num_columns: 162 | break 163 | rend_cell_widgets = render_text(text=cell) 164 | new_widgets = [] 165 | for widget in rend_cell_widgets: 166 | if isinstance(widget, urwid.Text): 167 | widget.align = self.table_aligns[idx] or "left" 168 | if modifier is not None: 169 | widget = modifier(widget) 170 | new_widgets.append(widget) 171 | rend_row.append(new_widgets) 172 | res.append(rend_row) 173 | 174 | return res 175 | -------------------------------------------------------------------------------- /lookatme/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | 5 | import urwid 6 | 7 | 8 | def prefix_text(text: str, prefix: str, split: str = "\n") -> str: 9 | return split.join(prefix + part for part in text.split(split)) 10 | 11 | 12 | def row_text(rendered_row): 13 | """Return all text joined together from the rendered row 14 | """ 15 | return b"".join(x[-1] for x in rendered_row) 16 | 17 | 18 | def resolve_bag_of_text_markup_or_widgets(items): 19 | """Resolve the list of items into either contiguous urwid.Text() instances, 20 | or pre-existing urwid.Widget objects 21 | """ 22 | res = [] 23 | curr_text_markup = [] 24 | for item in items: 25 | if isinstance(item, tuple) or isinstance(item, str): 26 | curr_text_markup.append(item) 27 | else: 28 | if len(curr_text_markup) > 0: 29 | res.append(urwid.Text(curr_text_markup)) 30 | curr_text_markup = [] 31 | res.append(item) 32 | 33 | if len(curr_text_markup) > 0: 34 | res.append(urwid.Text(curr_text_markup)) 35 | 36 | return res 37 | 38 | 39 | def dict_deep_update(to_update, new_vals): 40 | """Deeply update the to_update dict with the new_vals 41 | """ 42 | for key, value in new_vals.items(): 43 | if isinstance(value, dict): 44 | node = to_update.setdefault(key, {}) 45 | dict_deep_update(node, value) 46 | else: 47 | to_update[key] = value 48 | 49 | 50 | def spec_from_style(styles): 51 | """Create an urwid.AttrSpec from a {fg:"", bg:""} style dict. If styles 52 | is a string, it will be used as the foreground 53 | """ 54 | if isinstance(styles, str): 55 | return urwid.AttrSpec(styles, "") 56 | else: 57 | return urwid.AttrSpec(styles.get("fg", ""), styles.get("bg", "")) 58 | 59 | 60 | def get_fg_bg_styles(style): 61 | if style is None: 62 | return [], [] 63 | 64 | def non_empty_split(data): 65 | res = [x.strip() for x in data.split(",")] 66 | return list(filter(None, res)) 67 | 68 | # from lookatme.config.get_style() 69 | if isinstance(style, dict): 70 | return non_empty_split(style["fg"]), non_empty_split(style["bg"]) 71 | # just a str will only set the foreground color 72 | elif isinstance(style, str): 73 | return non_empty_split(style), [] 74 | elif isinstance(style, urwid.AttrSpec): 75 | return non_empty_split(style.foreground), non_empty_split(style.background) 76 | else: 77 | raise ValueError("Unsupported style value {!r}".format(style)) 78 | 79 | 80 | def overwrite_spec(orig_spec, new_spec): 81 | if orig_spec is None: 82 | orig_spec = urwid.AttrSpec("", "") 83 | if new_spec is None: 84 | new_spec = urwid.AttrSpec("", "") 85 | 86 | fg_orig = orig_spec.foreground.split(",") 87 | fg_orig_color = orig_spec._foreground_color() 88 | fg_orig.remove(fg_orig_color) 89 | 90 | bg_orig = orig_spec.background.split(",") 91 | bg_orig_color = orig_spec._background() 92 | bg_orig.remove(bg_orig_color) 93 | 94 | fg_new = new_spec.foreground.split(",") 95 | fg_new_color = new_spec._foreground_color() 96 | fg_new.remove(fg_new_color) 97 | 98 | bg_new = new_spec.background.split(",") 99 | bg_new_color = new_spec._background() 100 | bg_new.remove(bg_new_color) 101 | 102 | if fg_new_color == "default": 103 | fg_orig.append(fg_orig_color) 104 | else: 105 | fg_new.append(fg_new_color) 106 | 107 | if bg_new_color == "default": 108 | bg_orig.append(bg_orig_color) 109 | else: 110 | bg_new.append(bg_new_color) 111 | 112 | return urwid.AttrSpec( 113 | ",".join(set(fg_orig + fg_new)), 114 | ",".join(set(bg_orig + bg_new)), 115 | ) 116 | 117 | 118 | def flatten_text(text, new_spec=None): 119 | """Return a flattend list of tuples that can be used as the first argument 120 | to a new urwid.Text(). 121 | 122 | :param urwid.Text text: The text to flatten 123 | :param urwid.AttrSpec new_spec: A new spec to merge with existing styles 124 | :returns: list of tuples 125 | """ 126 | text, chunk_stylings = text.get_text() 127 | 128 | res = [] 129 | total_len = 0 130 | for spec, chunk_len in chunk_stylings: 131 | split_text = text[total_len:total_len + chunk_len] 132 | total_len += chunk_len 133 | 134 | split_text_spec = overwrite_spec(new_spec, spec) 135 | res.append((split_text_spec, split_text)) 136 | 137 | if len(text[total_len:]) > 0: 138 | res.append((new_spec, text[total_len:])) 139 | 140 | return res 141 | 142 | 143 | def can_style_item(item): 144 | """Return true/false if ``style_text`` can work with the given item 145 | """ 146 | return isinstance(item, (urwid.Text, list, tuple)) 147 | 148 | 149 | def styled_text(text, new_styles, old_styles=None, supplement_style=False): 150 | """Return a styled text tuple that can be used within urwid.Text. 151 | 152 | .. note:: 153 | 154 | If an urwid.Text instance is passed in as the ``text`` parameter, 155 | alignment values will be lost and must be explicitly re-added by the 156 | caller. 157 | """ 158 | if isinstance(text, urwid.Text): 159 | new_spec = spec_from_style(new_styles) 160 | return flatten_text(text, new_spec) 161 | elif (isinstance(text, tuple) 162 | and isinstance(text[0], urwid.AttrSpec) 163 | and isinstance(text[1], urwid.Text)): 164 | text = text[1].text 165 | old_styles = text[0] 166 | 167 | new_fg, new_bg = get_fg_bg_styles(new_styles) 168 | old_fg, old_bg = get_fg_bg_styles(old_styles) 169 | 170 | def join(items): 171 | return ",".join(set(items)) 172 | 173 | spec = urwid.AttrSpec( 174 | join(new_fg + old_fg), 175 | join(new_bg + old_bg), 176 | ) 177 | return (spec, text) 178 | 179 | 180 | def pile_or_listbox_add(container, widgets): 181 | """Add the widget/widgets to the container 182 | """ 183 | if isinstance(container, urwid.ListBox): 184 | return listbox_add(container, widgets) 185 | elif isinstance(container, urwid.Pile): 186 | return pile_add(container, widgets) 187 | else: 188 | raise ValueError("Container was not listbox, nor pile") 189 | 190 | 191 | def listbox_add(listbox, widgets): 192 | if not isinstance(widgets, list): 193 | widgets = [widgets] 194 | 195 | for w in widgets: 196 | if len(listbox.body) > 0 \ 197 | and isinstance(w, urwid.Divider) \ 198 | and isinstance(listbox.body[-1], urwid.Divider): 199 | continue 200 | listbox.body.append(w) 201 | 202 | 203 | def pile_add(pile, widgets): 204 | """ 205 | """ 206 | if not isinstance(widgets, list): 207 | widgets = [widgets] 208 | 209 | for w in widgets: 210 | if len(pile.contents) > 0 \ 211 | and isinstance(w, urwid.Divider) \ 212 | and isinstance(pile.contents[-1][0], urwid.Divider): 213 | continue 214 | pile.contents.append((w, pile.options())) 215 | 216 | 217 | def int_to_roman(integer): 218 | integer = int(integer) 219 | ints = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1] 220 | nums = ["m", "cm", "d", "cd", "c", "xc", 221 | "l", "xl", "x", "ix", "v", "iv", "i"] 222 | result = [] 223 | for i in range(len(ints)): 224 | count = integer // ints[i] 225 | result.append(nums[i] * count) 226 | integer -= ints[i] * count 227 | return "".join(result) 228 | -------------------------------------------------------------------------------- /lookatme/render/markdown_inline.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines render functions that work with mistune's markdown inline lexer render 3 | interface 4 | """ 5 | 6 | 7 | import functools 8 | 9 | import lookatme.config as config 10 | import lookatme.render.pygments as pygments_render 11 | import lookatme.utils as utils 12 | from lookatme.contrib import contrib_first 13 | from lookatme.tutorial import tutor 14 | from lookatme.widgets.clickable_text import LinkIndicatorSpec 15 | 16 | options = {} 17 | 18 | 19 | def expanded_styles(fn): 20 | @functools.wraps(fn) 21 | def inner(text): 22 | styles = dict(fg="", bg="") 23 | if isinstance(text, str): 24 | return fn(text, styles) 25 | elif isinstance(text, list) and isinstance(text[0], str): 26 | return fn(text[0], styles) 27 | elif isinstance(text, list) and isinstance(text[0], tuple): 28 | attr_spec = text[0][0] 29 | styles = dict(fg=attr_spec.foreground, bg=attr_spec.background) 30 | text = text[0][1] 31 | return fn(text, styles) 32 | else: 33 | return fn(text, styles) 34 | return inner 35 | 36 | 37 | # ------------------------------------------------------------------------- 38 | 39 | 40 | def placeholder(): 41 | """The starting point of the rendering. The final result will be this 42 | returned list with all inline markdown tokens translated into urwid objects 43 | """ 44 | return [] 45 | 46 | 47 | def render_no_change(text): 48 | """Render inline markdown text with no changes 49 | """ 50 | return [text] 51 | 52 | 53 | @contrib_first 54 | def inline_html(text): 55 | """Renders inline html as plaintext 56 | 57 | :returns: list of `urwid Text markup `_ 58 | tuples. 59 | """ 60 | return render_no_change(text) 61 | 62 | 63 | @contrib_first 64 | def text(text): 65 | """Renders plain text (does nothing) 66 | 67 | :returns: list of `urwid Text markup `_ 68 | tuples. 69 | """ 70 | return render_no_change(text) 71 | 72 | 73 | @contrib_first 74 | def escape(text): 75 | """Renders escapes 76 | 77 | :returns: list of `urwid Text markup `_ 78 | tuples. 79 | """ 80 | return render_no_change(text) 81 | 82 | 83 | @contrib_first 84 | def autolink(link_uri, is_email=False): 85 | """Renders a URI as a link 86 | 87 | :returns: list of `urwid Text markup `_ 88 | tuples. 89 | """ 90 | return link(link_uri, None, link_uri) 91 | 92 | 93 | @contrib_first 94 | def footnote_ref(key, index): 95 | """Renders a footnote 96 | 97 | :returns: list of `urwid Text markup `_ 98 | tuples. 99 | """ 100 | return render_no_change(key) 101 | 102 | 103 | @tutor( 104 | "markdown", 105 | "images", 106 | r""" 107 | Vanilla lookatme renders images as links. Some extensions provide ways to 108 | render images in the terminal. 109 | 110 | Consider exploring: 111 | 112 | * [lookatme.contrib.image_ueberzug](https://github.com/d0c-s4vage/lookatme.contrib.image_ueberzug) 113 | * This works on Linux only, with X11, and must be separately installed 114 | 115 | 116 | ![image alt](https://image/url) 117 | 118 | """ 119 | ) 120 | @contrib_first 121 | def image(link_uri, title, text): 122 | """Renders an image as a link. This would be a cool extension to render 123 | referenced images as scaled-down ansii pixel blocks. 124 | 125 | :returns: list of `urwid Text markup `_ 126 | tuples. 127 | """ 128 | return link(link_uri, title, text) 129 | 130 | 131 | @tutor( 132 | "markdown", 133 | "links", 134 | r""" 135 | Links are inline elements in markdown and have the form `[text](link)` 136 | 137 | 138 | [lookatme on GitHub](https://github.com/d0c-s4vage/lookatme) 139 | 140 | 141 | ## Style 142 | 143 | Links can be styled with slide metadata. This is the default style: 144 | 145 | link 146 | """ 147 | ) 148 | @contrib_first 149 | def link(link_uri, title, link_text): 150 | """Renders a link. This function does a few special things to make the 151 | clickable links happen. All text in lookatme is rendered using the 152 | :any:`ClickableText` class. The ``ClickableText`` class looks for 153 | ``urwid.AttrSpec`` instances that are actually ``LinkIndicatorSpec`` instances 154 | within the Text markup. If an AttrSpec is an instance of ``LinkIndicator`` 155 | spec in the Text markup, ClickableText knows to handle clicks on that 156 | section of the text as a link. 157 | 158 | :returns: list of `urwid Text markup `_ 159 | tuples. 160 | """ 161 | raw_link_text = [] 162 | for x in link_text: 163 | if isinstance(x, tuple): 164 | raw_link_text.append(x[1]) 165 | else: 166 | raw_link_text.append(x) 167 | raw_link_text = "".join(raw_link_text) 168 | 169 | spec, text = utils.styled_text( 170 | link_text, utils.spec_from_style(config.get_style()["link"])) 171 | spec = LinkIndicatorSpec(raw_link_text, link_uri, spec) 172 | return [(spec, text)] 173 | 174 | 175 | @tutor( 176 | "markdown", 177 | "emphasis", 178 | r""" 179 | 180 | The donut jumped *under* the crane. 181 | 182 | """ 183 | ) 184 | @expanded_styles 185 | @contrib_first 186 | def emphasis(text, old_styles): 187 | """Renders double emphasis. Handles both ``*word*`` and ``_word_`` 188 | 189 | :returns: list of `urwid Text markup `_ 190 | tuples. 191 | """ 192 | return [utils.styled_text(text, "italics", old_styles)] 193 | 194 | 195 | @tutor( 196 | "markdown", 197 | "double emphasis", 198 | r""" 199 | 200 | They jumped **over** the wagon 201 | 202 | """ 203 | ) 204 | @expanded_styles 205 | @contrib_first 206 | def double_emphasis(text, old_styles): 207 | """Renders double emphasis. Handles both ``**word**`` and ``__word__`` 208 | 209 | :returns: list of `urwid Text markup `_ 210 | tuples. 211 | """ 212 | return [utils.styled_text(text, "underline", old_styles)] 213 | 214 | 215 | @tutor( 216 | "markdown", 217 | "inline code", 218 | r""" 219 | 220 | The `OddOne` class accepts `Table` instances, converts them to raw pointers, 221 | forces garbage collection to run. 222 | 223 | """ 224 | ) 225 | @expanded_styles 226 | @contrib_first 227 | def codespan(text, old_styles): 228 | """Renders inline code using the pygments renderer. This function also makes 229 | use of the coding style: 230 | 231 | .. code-block:: yaml 232 | 233 | style: monokai 234 | 235 | :returns: list of `urwid Text markup `_ 236 | tuples. 237 | """ 238 | res = pygments_render.render_text(" " + text + " ", plain=True) 239 | return res 240 | 241 | 242 | @contrib_first 243 | def linebreak(): 244 | """Renders a line break 245 | 246 | :returns: list of `urwid Text markup `_ 247 | tuples. 248 | """ 249 | return ["\n"] 250 | 251 | 252 | @tutor( 253 | "markdown", 254 | "strikethrough", 255 | r""" 256 | 257 | I lost my ~~mind~~ keyboard and couldn't type anymore. 258 | 259 | """ 260 | ) 261 | @expanded_styles 262 | @contrib_first 263 | def strikethrough(text, old_styles): 264 | """Renders strikethrough text (``~~text~~``) 265 | 266 | :returns: list of `urwid Text markup `_ 267 | tuples. 268 | """ 269 | return [utils.styled_text(text, "strikethrough", old_styles)] 270 | -------------------------------------------------------------------------------- /presentations/san_diego_python_meetup/2019-12-20.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Markdown Presentation Tool: lookatme' 3 | author: James Johnson - @d0c_s4vage 4 | date: 2019-12-20 5 | extensions: 6 | - qrcode 7 | --- 8 | 9 | # Terminal-Based 10 | ## Markdown 11 | ### Presentations 12 | 13 | # Introduction: Me 14 | 15 | * I 16 | * am a 17 | * software developer 18 | * security researcher 19 | * team leader 20 | * enjoy 21 | * open-source software development 22 | * teaching 23 | * parfaits 24 | 25 | # Introduction: Me 26 | 27 | I can be found on twitter and github: 28 | 29 | ```qrcode-ex 30 | columns: 31 | - data: "https://twitter.com/d0c_s4vage" 32 | caption: "Twitter: @d0c_s4vage" 33 | - data: "https://github.com/d0c-s4vage" 34 | caption: "GitHub: d0c-s4vage" 35 | ``` 36 | 37 | # lookatme: A Markdown Presentation Tool 38 | 39 | I wrote a cool tool: `lookatme` ([on GitHub](https://github.com/d0c-s4vage/lookatme)) 40 | 41 | `lookatme` presents Markdown in a terminal 42 | 43 | ## Installation 44 | 45 | lookatme can be installed via pip with: 46 | 47 | ```bash 48 | pip install lookatme 49 | ``` 50 | 51 | # Why? 52 | 53 | * I am very familiar with Markdown 54 | * I enjoy being close to the code and in the terminal 55 | * Markdown is simple to read and write 56 | * It lets human-readable slides live alongside code 57 | * Presentations as code! 58 | 59 | # Why: Part 2.1 - MDP 60 | 61 | Existing tools do similar things. E.g., [mdp](https://github.com/visit1985/mdp) 62 | 63 | ```file 64 | path: examples/mdp.md 65 | lang: md 66 | ``` 67 | 68 | ```terminal15 69 | mdp --invert examples/mdp.md 70 | ``` 71 | 72 | # Why: Part 2.2 73 | 74 | [patat](https://github.com/jaspervdj/patat) is another existing tool that 75 | presents markdown slides on the command-line: 76 | 77 | ```file 78 | path: examples/patat.md 79 | lang: md 80 | ``` 81 | 82 | ```terminal15 83 | patat examples/patat.md 84 | ``` 85 | 86 | # Why: Part 3 87 | 88 | I was asked on Twitter why I didn't use a browser-based tool: 89 | 90 | > Looks nice. Out of curiosity, any reason why you are not using remarkjs 91 | > except it runs in a terminal vs a browser? 92 | 93 | Although rendering Markdown can look much nicer using GUIs, I wanted to have 94 | it close to the code and to be able to seamlessly shift between 95 | 96 | * presenting concepts 97 | * displaying source code/CLI commands 98 | * interactively running commands 99 | 100 | # Why: Part 3 - Examples 101 | 102 | For example, suppose I was presenting on Flask and I wanted to show a minimal 103 | flask application running. 104 | 105 | It's easy enough to do this from other slide presentation tools, as long as 106 | you're fine with: 107 | 108 | * duplicating your source code in 109 | * the slides themselves 110 | * the actual code being run 111 | 112 | and 113 | 114 | * context switching between 115 | * running the program in a separate terminal 116 | * creating yet another terminal (or browser) to interact with the running program 117 | * possibly pulling up an IDE to show/modify the actual source being run 118 | 119 | It gets complicated. I don't like complicated 120 | 121 | # Why: Part 3 - Flask Example 122 | 123 | Source 124 | 125 | ```terminal7 126 | bash -c "TERM=xterm-256color vim --clean ./source/minimal_flask.py -c 'colors peachpuff | set number'" 127 | ``` 128 | 129 | Running 130 | 131 | ```terminal6 132 | bash -c "FLASK_APP=./source/minimal_flask.py flask run --reload" 133 | ``` 134 | 135 | Python3 shell 136 | 137 | ```terminal6 138 | python3 139 | ``` 140 | 141 | 142 | # Why: Part 4.1 143 | 144 | I also wanted to make it easy to create extensions that can have a first-chance 145 | opportunity to handle specific markdown rendering events. 146 | 147 | E.g. below is the code to render code blocks: 148 | 149 | ```python 150 | @contrib_first 151 | def render_code(token, body, stack, loop): 152 | """Renders a code block using the Pygments library. 153 | 154 | See :any:`lookatme.tui.SlideRenderer.do_render` for additional argument and 155 | return value descriptions. 156 | """ 157 | lang = token.get("lang", "text") or "text" 158 | res = pygments_render.render_text(token["text"], lang=lang) 159 | 160 | return [ 161 | urwid.Divider(), 162 | res, 163 | urwid.Divider(), 164 | ] 165 | ``` 166 | 167 | # Why: Part 4.2 168 | 169 | Below is the code used by file loader to provide an alternative rendering of 170 | the code blocks: 171 | 172 | ```python 173 | def render_code(token, body, stack, loop): 174 | """Render the code, ignoring all code blocks except ones with the language 175 | set to ``file``. 176 | """ 177 | lang = token["lang"] or "" 178 | if lang != "file": 179 | raise IgnoredByContrib 180 | 181 | file_info_data = token["text"] 182 | file_info = FileSchema().loads(file_info_data) 183 | 184 | # ... 185 | 186 | token["text"] = file_data 187 | token["lang"] = file_info["lang"] 188 | raise IgnoredByContrib 189 | ``` 190 | 191 | # Features: Markdown Support: Headings 192 | 193 | ## Heading 2 194 | 195 | ### Heading 3 196 | 197 | #### Heading 4 198 | 199 | # Features: Markdown Support: Lists 200 | 201 | * list1 202 | * list 2 203 | * item 2 204 | ```python 205 | print("Nested code blocks") 206 | ``` 207 | * list 2 208 | * list 2 209 | > nested quote 210 | * list 2 211 | 212 | 213 | # Features: Markdown Support: Code Blocks 214 | 215 | ```python 216 | def this_is_a_function(arg1, arg2): 217 | print(f"arg1: {arg1}, arg2: {arg2}") 218 | 219 | return arg1 + arg2 220 | 221 | if __name__ == "__main__": 222 | this_is_a_function(sys.argv[1], sys.argv[2]) 223 | ``` 224 | 225 | 226 | # Features: Markdown Support: Quotes 227 | 228 | > This is a quote. This is a quote. This is a quote. This is a quote. This 229 | is a quote. This is a quote. This is a quote. This is a quote. This is a 230 | quote. This is a quote. This is a quote. This is a quote. 231 | 232 | # Features: Markdown Support: Inline 233 | 234 | All inline markdown features are supported, with footnotes being the only 235 | exception. 236 | 237 | | markdown | rendered | 238 | |---------------------------------:|--------------------------------| 239 | | `*italic*` | *italic* | 240 | | `_italic_` | _italic_ | 241 | | `**bold**` | **bold** | 242 | | `__bold__` | __bold__ | 243 | | `***bold underline***` | ***bold underline*** | 244 | | `___bold underline___` | ___bold underline___ | 245 | | `~~strikethrough~~` | ~~strikethrough~~ | 246 | | `[CLICK ME](https://google.com)` | [CLICK ME](https://google.com) | 247 | | `` `code` `` | `code` | 248 | 249 | # Features: Live Reloading 250 | 251 | Hi everyone 252 | 253 | # Features: Builtin Extensions: Terminal 254 | 255 | ~~~md 256 | ```terminal8 257 | python3 258 | ``` 259 | ~~~ 260 | 261 | ```terminal8 262 | python3 263 | ``` 264 | 265 | # Features: Builtin Extensions: File Loader 266 | 267 | ~~~md 268 | ```file 269 | path: 2019-12-20.md 270 | transform: grep -e "^# " | sort 271 | lines: 272 | end: 10 273 | ``` 274 | ~~~ 275 | 276 | ```file 277 | path: 2019-12-20.md 278 | lang: md 279 | transform: grep -e "^# " | sort 280 | lines: 281 | end: 10 282 | ``` 283 | 284 | # Features: Contrib Extensions: QR Codes 285 | 286 | ```file 287 | path: ./examples/lookatme_qrcode.md 288 | lang: md 289 | ``` 290 | 291 | ```qrcode 292 | a 293 | ``` 294 | 295 | # Summary 296 | 297 | > Markdown is intended to be as easy-to-read and easy-to-write as is feasible. 298 | > 299 | > Readability, however, is emphasized above all else. A Markdown-formatted 300 | > document should be publishable as-is, as plain text, without looking like 301 | > it’s been marked up with tags or formatting instructions. 302 | 303 | [Mark Gruber, Markdown Co-Creator](https://daringfireball.net/projects/markdown/syntax#philosophy) 304 | 305 | In keeping with the original Markdown philosophy, here are these slides 306 | rendered in Github: [slides](https://github.com/d0c-s4vage/lookatme/tree/master/presentations/san_diego_python_meetup/2019-12-20.md) 307 | 308 | Not everything carries over, but it's still pretty readable. 309 | 310 | # FIN 311 | 312 | ```qrcode-ex 313 | columns: 314 | - data: https://www.youtube.com/watch?v=oHg5SJYRHA0 315 | caption: Questions? 316 | ``` 317 | -------------------------------------------------------------------------------- /tests/test_markdown.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test basic markdown renderings 3 | """ 4 | 5 | 6 | from tests.utils import (assert_render, render_markdown, row_text, 7 | setup_lookatme) 8 | 9 | 10 | def test_headings(tmpdir, mocker): 11 | """Test basic header rendering 12 | """ 13 | setup_lookatme(tmpdir, mocker, style={ 14 | "headings": { 15 | "default": { 16 | "fg": "bold", 17 | "bg": "", 18 | "prefix": "|", 19 | "suffix": "|", 20 | }, 21 | }, 22 | }) 23 | 24 | rendered = render_markdown(""" 25 | # H1 26 | ## H2 27 | ### H3 28 | --- 29 | """) 30 | 31 | stripped_rows = [ 32 | b"", 33 | b"|H1|", 34 | b"", 35 | b"|H2|", 36 | b"", 37 | b"|H3|", 38 | b"", 39 | ] 40 | assert_render(stripped_rows, rendered) 41 | 42 | 43 | def test_table(tmpdir, mocker): 44 | """Test basic table rendering 45 | """ 46 | setup_lookatme(tmpdir, mocker, style={ 47 | "table": { 48 | "column_spacing": 1, 49 | "header_divider": "-", 50 | }, 51 | }) 52 | 53 | rendered = render_markdown(""" 54 | | H1 | H2 | H3 | 55 | |:--------|:------:|-------:| 56 | | 1value1 | value2 | value3 | 57 | | 1 | 2 | 3 | 58 | """) 59 | 60 | stripped_rows = [ 61 | b"H1 H2 H3", 62 | b"------- ------ ------", 63 | b"1value1 value2 value3", 64 | b"1 2 3", 65 | ] 66 | assert_render(stripped_rows, rendered, full_strip=True) 67 | 68 | 69 | def test_lists(tmpdir, mocker): 70 | """Test list rendering 71 | """ 72 | setup_lookatme(tmpdir, mocker, style={ 73 | "bullets": { 74 | "default": "*", 75 | "1": "-", 76 | "2": "=", 77 | "3": "^", 78 | }, 79 | }) 80 | 81 | rendered = render_markdown(""" 82 | * list 1 83 | * list 2 84 | * list 3 85 | * list 4 86 | * list 2 87 | * list 3 88 | * list 3 89 | 90 | * list 2 91 | """) 92 | 93 | stripped_rows = [ 94 | b'', 95 | b' - list 1', 96 | b' = list 2', 97 | b' ^ list 3', 98 | b' * list 4', 99 | b' = list 2', 100 | b' ^ list 3', 101 | b' ^ list 3', 102 | b' - list 2', 103 | b'', 104 | ] 105 | assert_render(stripped_rows, rendered) 106 | 107 | 108 | def test_lists_with_newline(tmpdir, mocker): 109 | """Test list rendering with a newline between a new nested list and the 110 | previous list item 111 | """ 112 | setup_lookatme(tmpdir, mocker, style={ 113 | "bullets": { 114 | "default": "*", 115 | "1": "-", 116 | "2": "=", 117 | "3": "^", 118 | }, 119 | }) 120 | 121 | rendered = render_markdown(""" 122 | * list 1 123 | 124 | * list 2 125 | """) 126 | 127 | stripped_rows = [ 128 | b'', 129 | b' - list 1', 130 | b'', 131 | b' = list 2', 132 | b'', 133 | ] 134 | assert_render(stripped_rows, rendered) 135 | 136 | 137 | def test_numbered_lists(tmpdir, mocker): 138 | """Test list rendering 139 | """ 140 | setup_lookatme(tmpdir, mocker, style={ 141 | "bullets": { 142 | "default": "*", 143 | "1": "-", 144 | "2": "=", 145 | "3": "^", 146 | }, 147 | "numbering": { 148 | "default": "numeric", 149 | "1": "numeric", 150 | "2": "alpha", 151 | "3": "roman", 152 | }, 153 | }) 154 | 155 | rendered = render_markdown(""" 156 | 1. list 1 157 | 1. alpha1 158 | 1. alpha2 159 | 1. alpha3 160 | 1. list 2 161 | 1. alpha1.1 162 | 1. roman1 163 | 1. roman2 164 | 1. roman3 165 | 1. alpha1.2 166 | * test1 167 | * test2 168 | 1. list 3 169 | """) 170 | 171 | stripped_rows = [ 172 | b'', 173 | b' 1. list 1', 174 | b' a. alpha1', 175 | b' b. alpha2', 176 | b' c. alpha3', 177 | b' 2. list 2', 178 | b' a. alpha1.1', 179 | b' i. roman1', 180 | b' ii. roman2', 181 | b' iii. roman3', 182 | b' b. alpha1.2', 183 | b' ^ test1', 184 | b' ^ test2', 185 | b' 3. list 3', 186 | b'', 187 | ] 188 | assert_render(stripped_rows, rendered) 189 | 190 | 191 | def test_hrule(tmpdir, mocker): 192 | """Test that hrules render correctly 193 | """ 194 | setup_lookatme(tmpdir, mocker, style={ 195 | "hrule": { 196 | "style": { 197 | "fg": "", 198 | "bg": "", 199 | }, 200 | "char": "=", 201 | }, 202 | }) 203 | 204 | rendered = render_markdown("---", width=10, single_slide=True) 205 | stripped_rows = [ 206 | b'', 207 | b'==========', 208 | b'', 209 | ] 210 | assert_render(stripped_rows, rendered) 211 | 212 | 213 | def test_block_quote(tmpdir, mocker): 214 | """Test block quote rendering 215 | """ 216 | setup_lookatme(tmpdir, mocker, style={ 217 | "quote": { 218 | "style": { 219 | "fg": "", 220 | "bg": "", 221 | }, 222 | "side": ">", 223 | "top_corner": "-", 224 | "bottom_corner": "=", 225 | }, 226 | }) 227 | 228 | rendered = render_markdown(""" 229 | > this is a quote 230 | """) 231 | 232 | stripped_rows = [ 233 | b'', 234 | b'-', 235 | b'> this is a quote', 236 | b'=', 237 | b'', 238 | ] 239 | assert_render(stripped_rows, rendered) 240 | 241 | 242 | def test_code(tmpdir, mocker): 243 | """Test code block rendering 244 | """ 245 | setup_lookatme(tmpdir, mocker, style={ 246 | "style": "monokai", 247 | }) 248 | 249 | rendered = render_markdown(""" 250 | ```python 251 | def some_fn(*args, **kargs): 252 | pass``` 253 | """) 254 | 255 | stripped_rows = [ 256 | b'', 257 | b'def some_fn(*args, **kargs):', 258 | b' pass', 259 | b'', 260 | ] 261 | assert_render(stripped_rows, rendered) 262 | 263 | 264 | def test_empty_codeblock(tmpdir, mocker): 265 | """Test that empty code blocks render correctly 266 | """ 267 | setup_lookatme(tmpdir, mocker, style={ 268 | "style": "monokai", 269 | }) 270 | 271 | render_markdown(""" 272 | ```python 273 | 274 | ```""") 275 | 276 | 277 | def test_code_yaml(tmpdir, mocker): 278 | """Test code block rendering with yaml language 279 | """ 280 | setup_lookatme(tmpdir, mocker, style={ 281 | "style": "monokai", 282 | }) 283 | 284 | rendered = render_markdown(""" 285 | ```yaml 286 | test: a value 287 | test2: "another value" 288 | array: 289 | - item1 290 | - item2 291 | - item3 292 | ```""") 293 | 294 | stripped_rows = [ 295 | b'', 296 | b'test: a value', 297 | b'test2: "another value"', 298 | b'array:', 299 | b' - item1', 300 | b' - item2', 301 | b' - item3', 302 | b'', 303 | ] 304 | assert_render(stripped_rows, rendered) 305 | 306 | 307 | def test_inline(tmpdir, mocker): 308 | """Test inline markdown 309 | """ 310 | setup_lookatme(tmpdir, mocker, style={ 311 | "style": "monokai", 312 | "link": { 313 | "fg": "underline", 314 | "bg": "default", 315 | }, 316 | }) 317 | 318 | rendered = render_markdown("*emphasis*") 319 | assert rendered[1][0][0].foreground == "default,italics" 320 | assert row_text(rendered[1]).strip() == b"emphasis" 321 | 322 | rendered = render_markdown("**emphasis**") 323 | assert rendered[1][0][0].foreground == "default,underline" 324 | assert row_text(rendered[1]).strip() == b"emphasis" 325 | 326 | rendered = render_markdown("_emphasis_") 327 | assert rendered[1][0][0].foreground == "default,italics" 328 | assert row_text(rendered[1]).strip() == b"emphasis" 329 | 330 | rendered = render_markdown("__emphasis__") 331 | assert rendered[1][0][0].foreground == "default,underline" 332 | assert row_text(rendered[1]).strip() == b"emphasis" 333 | 334 | rendered = render_markdown("`inline code`") 335 | assert row_text(rendered[1]).rstrip() == b" inline code" 336 | 337 | rendered = render_markdown("~~strikethrough~~") 338 | assert rendered[1][0][0].foreground == "default,strikethrough" 339 | assert row_text(rendered[1]).rstrip() == b"strikethrough" 340 | 341 | rendered = render_markdown("[link](http://domain.tld)") 342 | assert rendered[1][0][0].foreground == "default,underline" 343 | assert row_text(rendered[1]).rstrip() == b"link" 344 | 345 | rendered = render_markdown("http://domain.tld") 346 | assert rendered[1][0][0].foreground == "default,underline" 347 | assert row_text(rendered[1]).rstrip() == b"http://domain.tld" 348 | 349 | rendered = render_markdown("![link](http://domain.tld)") 350 | assert rendered[1][0][0].foreground == "default,underline" 351 | assert row_text(rendered[1]).rstrip() == b"link" 352 | -------------------------------------------------------------------------------- /lookatme/tutorial.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions and sources for the markdown tutorial slides! 3 | """ 4 | 5 | import inspect 6 | import re 7 | from collections import OrderedDict 8 | from typing import Callable, List, Optional, Union 9 | 10 | import yaml 11 | 12 | import lookatme 13 | import lookatme.config as config 14 | import lookatme.utils as utils 15 | 16 | 17 | class Tutor: 18 | """A class to handle/process tutorials for specific functionality 19 | 20 | In addition to name, group, and slides content of the tutor, each Tutor 21 | must also be associated with the implementation. 22 | """ 23 | 24 | def __init__( 25 | self, 26 | name: str, 27 | group: str, 28 | slides_md: str, 29 | impl_fn: Callable, 30 | order: int, 31 | lazy_formatting: Optional[Callable] = None, 32 | ): 33 | """Create a new Tutor 34 | 35 | Args: 36 | lazy_formatting. Callable. Should return a dictionary that will be 37 | unpacked into the kwargs of str.format() 38 | """ 39 | self.name = name 40 | self.group = group 41 | self.slides_md = inspect.cleandoc(slides_md).strip() 42 | self.impl_fn = impl_fn 43 | self.order = order 44 | self.lazy_formatting = lazy_formatting 45 | 46 | def get_md(self, rendered_example=True) -> str: 47 | """Get the tutor's markdown text after resolving any special markup 48 | contained in it. 49 | opts. Dict[str, Any] 50 | slides. Current can include `{ 51 | """ 52 | slides_md = self.slides_md 53 | if self.lazy_formatting is not None: 54 | slides_md = slides_md.format(**self.lazy_formatting()) 55 | 56 | tag_handlers = { 57 | "EXAMPLE": lambda contents: self._handle_show_and_render(contents, rendered_example), 58 | "STYLE": self._handle_style_yaml, 59 | } 60 | 61 | res_md = [] 62 | last_idx = 0 63 | regex = "<(?PTUTOR:(?P[A-Z_]+))>(?P.*)" 64 | for match in re.finditer(regex, slides_md, re.MULTILINE | re.DOTALL): 65 | res_md.append(slides_md[last_idx: match.start()]) 66 | match_groups = match.groupdict() 67 | handler = tag_handlers.get(match_groups["type"], None) 68 | if handler is None: 69 | raise ValueError( 70 | "No handler defined for 'TUTOR:{}' tags".format( 71 | match_groups["type"] 72 | ) 73 | ) 74 | res_md.append(handler(match_groups["inner"])) 75 | last_idx = match.end() + 1 76 | 77 | res_md.append(slides_md[last_idx:]) 78 | 79 | return "\n\n".join([ 80 | self._get_heading(), 81 | "".join(res_md), 82 | self._get_source_note(), 83 | ]) 84 | 85 | def _get_heading(self) -> str: 86 | return "# {group}: {name}".format( 87 | group=self.group.title(), 88 | name=self.name.title(), 89 | ) 90 | 91 | def _get_source_note(self) -> str: 92 | link = self._get_source_link() 93 | return "> This is implemented in {}".format(link) 94 | 95 | def _get_source_link(self): 96 | file_name = inspect.getsourcefile(inspect.unwrap(self.impl_fn)) 97 | if file_name is None: 98 | return "??" 99 | 100 | relpath = file_name.split("lookatme/", 1)[1] 101 | _, lineno = inspect.getsourcelines(self.impl_fn) 102 | 103 | version = "v" + lookatme.VERSION 104 | 105 | return "[{module}.{fn_name}]({link})".format( 106 | module=self.impl_fn.__module__, 107 | fn_name=self.impl_fn.__qualname__, 108 | link="https://github.com/d0c-s4vage/lookatme/blob/{version}/{path}#L{lineno}".format( 109 | version=version, 110 | path=relpath, 111 | lineno=lineno, 112 | ) 113 | ) 114 | 115 | def _handle_show_and_render(self, contents, rendered_example: bool = True) -> str: 116 | contents = contents.strip() 117 | 118 | markdown_example = "\n".join([ 119 | "~~~markdown", 120 | contents, 121 | "~~~", 122 | ]) 123 | quoted_example = utils.prefix_text(markdown_example, "> ") 124 | 125 | res = [ 126 | "***Markdown***:", 127 | quoted_example, 128 | ] 129 | 130 | if rendered_example: 131 | res += [ 132 | "***Rendered***:", 133 | contents + "\n", 134 | ] 135 | 136 | return "\n\n".join(res) 137 | 138 | def _handle_style_yaml(self, contents: str) -> str: 139 | contents = contents.strip() 140 | style = config.get_style()[contents] 141 | style = {"styles": {contents: style}} 142 | return "```yaml\n---\n{style_yaml}---\n```".format( 143 | style_yaml=yaml.dump(style).encode().decode("unicode-escape"), 144 | ) 145 | 146 | 147 | GROUPED_TUTORIALS = OrderedDict() 148 | NAMED_TUTORIALS = OrderedDict() 149 | 150 | 151 | def get_tutorial_help() -> str: 152 | res = [] 153 | res.append(inspect.cleandoc(""" 154 | Help for 'lookatme --tutorial' 155 | 156 | Specific tutorials can be run with a comma-separated list of group or 157 | tutorial names. Below are the groups and tutorial names currently defined. 158 | """).strip()) 159 | 160 | for group_name, group_tutors in GROUPED_TUTORIALS.items(): 161 | res.append("") 162 | res.append(" " + group_name) 163 | for tutor_name in group_tutors.keys(): 164 | res.append(" " + tutor_name) 165 | 166 | res.append("") 167 | res.append(inspect.cleandoc(""" 168 | Substring matching is used to identify tutorials and groups. All matching 169 | tutorials and groups are then run. 170 | 171 | Examples: 172 | lookatme --tutorial 173 | lookatme --tutorial link,table 174 | lookatme --tutorial general,list 175 | """).strip()) 176 | 177 | return "\n".join(res) 178 | 179 | 180 | def print_tutorial_help(): 181 | print(get_tutorial_help()) 182 | 183 | 184 | def tutor( 185 | group: str, 186 | name: str, 187 | slides_md: str, 188 | order: int = 99999, 189 | lazy_formatting: Optional[Callable] = None 190 | ): 191 | """Define tutorial slides by using this as a decorator on a function!""" 192 | def capture_fn(fn): 193 | tutor = Tutor(name, group, slides_md, fn, order, lazy_formatting) 194 | tutor_list = ( 195 | GROUPED_TUTORIALS 196 | .setdefault(group, OrderedDict()) 197 | .setdefault(name, []) 198 | ) 199 | tutor_list.append(tutor) 200 | NAMED_TUTORIALS.setdefault(name, []).append(tutor) 201 | return fn 202 | return capture_fn 203 | 204 | 205 | def pretty_close_match(str1, str2): 206 | str1 = str1.lower() 207 | str2 = str2.lower() 208 | if str1 in str2 or str2 in str1: 209 | return True 210 | 211 | 212 | @tutor( 213 | "general", 214 | "tutorial", 215 | r""" 216 | Lookatme has a built-in tutorial feature that can be used for reference. 217 | 218 | To launch lookatme's tutorial slides, run lookatme with the `--tutorial` 219 | argument: 220 | 221 | ``` 222 | lookatme --tutorial 223 | ``` 224 | 225 | ## Specific Tutorials 226 | 227 | Tutorials each have individual names and are organized into groups. You can 228 | pass comma-separated values of strings that ~match group or tutorial names 229 | to only run those tutorial slides: 230 | 231 | ```bash 232 | # only run the general slide group, and the list-related tutorials 233 | lookatme --tutorial general,list 234 | 235 | # only run the table tutorial slides 236 | lookatme --tutorial table 237 | ``` 238 | 239 | ## Seeing What's Available 240 | 241 | If you pass the `help` value to `--tutorial`, lookatme me will list all 242 | groups defined and all tutorial names within those groups. Currently this 243 | is the output of `lookatme --tutorial help`. Notice that tutorial names 244 | are nested under the group names: 245 | 246 | ``` 247 | {lookatme_help} 248 | ``` 249 | """, 250 | order=99, # last 251 | lazy_formatting=lambda: { 252 | "lookatme_help": get_tutorial_help() 253 | } 254 | ) 255 | def get_tutors(group_or_tutor: str) -> List[Tutor]: 256 | for group_name, group_value in GROUPED_TUTORIALS.items(): 257 | if pretty_close_match(group_name, group_or_tutor): 258 | return list(group_value.values()) 259 | 260 | res = [] 261 | for grouped_tutors in GROUPED_TUTORIALS.values(): 262 | for tutor_name, tutor_list in grouped_tutors.items(): 263 | if pretty_close_match(tutor_name, group_or_tutor): 264 | res.append(tutor_list) 265 | 266 | return res 267 | 268 | 269 | def _sort_tutors_by_order(): 270 | for group_name, tutors in list(GROUPED_TUTORIALS.items()): 271 | del GROUPED_TUTORIALS[group_name] 272 | tutor_list = list(tutors.items()) 273 | tutor_list = list(sorted( 274 | tutor_list, 275 | key=lambda x: min(tutor.order for tutor in x[1]), 276 | )) 277 | GROUPED_TUTORIALS[group_name] = OrderedDict(tutor_list) 278 | 279 | 280 | def get_tutorial_md(groups_or_tutors: List[str]) -> Union[None, str]: 281 | _sort_tutors_by_order() 282 | 283 | tutors = [] 284 | for group_or_tutor in groups_or_tutors: 285 | tutors += get_tutors(group_or_tutor) 286 | 287 | if len(tutors) == 0: 288 | return None 289 | 290 | res_slides = [] 291 | for tutor in tutors: 292 | tutor_md = "\n\n".join(t.get_md() for t in tutor) 293 | res_slides.append(tutor_md) 294 | 295 | meta = inspect.cleandoc(""" 296 | --- 297 | title: lookatme Tutorial 298 | author: lookatme devs 299 | --- 300 | """).strip() 301 | 302 | return meta + "\n" + "\n\n---\n\n".join(res_slides) + "\n\n---\n\n# End" 303 | -------------------------------------------------------------------------------- /lookatme/parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines the parser for the markdown presentation file 3 | """ 4 | 5 | 6 | import re 7 | from collections import defaultdict 8 | from typing import AnyStr, Callable, Dict, List, Tuple 9 | 10 | import mistune 11 | 12 | from lookatme.schemas import MetaSchema 13 | from lookatme.slide import Slide 14 | from lookatme.tutorial import tutor 15 | 16 | 17 | def is_progressive_slide_delimiter_token(token): 18 | """Returns True if the token indicates the end of a progressive slide 19 | 20 | :param dict token: The markdown token 21 | :returns: True if the token is a progressive slide delimiter 22 | """ 23 | return token["type"] == "close_html" and re.match(r'', token["text"]) 24 | 25 | 26 | class Parser(object): 27 | """A parser for markdown presentation files 28 | """ 29 | 30 | def __init__(self, single_slide=False): 31 | """Create a new Parser instance 32 | """ 33 | self._single_slide = single_slide 34 | 35 | def parse(self, input_data): 36 | """Parse the provided input data into a Presentation object 37 | 38 | :param str input_data: The input markdown presentation to parse 39 | :returns: Presentation 40 | """ 41 | input_data, meta = self.parse_meta(input_data) 42 | input_data, slides = self.parse_slides(meta, input_data) 43 | return meta, slides 44 | 45 | def parse_slides(self, meta, input_data): 46 | """Parse the Slide out of the input data 47 | 48 | :param dict meta: The parsed meta values 49 | :param str input_data: The input data string 50 | :returns: tuple of (remaining_data, slide) 51 | """ 52 | # slides are delimited by --- 53 | md = mistune.Markdown() 54 | 55 | state = {} 56 | tokens = md.block.parse(input_data, state) 57 | 58 | num_hrules, hinfo = self._scan_for_smart_split(tokens) 59 | keep_split_token = True 60 | 61 | if self._single_slide: 62 | def slide_split_check(_): # type: ignore 63 | return False 64 | 65 | def heading_mod(_): # type: ignore 66 | pass 67 | elif num_hrules == 0: 68 | if meta.get("title", "") in ["", None]: 69 | meta["title"] = hinfo["title"] 70 | 71 | def slide_split_check(token): # type: ignore 72 | nonlocal hinfo 73 | return ( 74 | token["type"] == "heading" 75 | and token["level"] == hinfo["lowest_non_title"] 76 | ) 77 | 78 | def heading_mod(token): # type: ignore 79 | nonlocal hinfo 80 | token["level"] = max( 81 | token["level"] - (hinfo["title_level"] or 0), 82 | 1, 83 | ) 84 | keep_split_token = True 85 | else: 86 | def slide_split_check(token): # type: ignore 87 | return token["type"] == "hrule" 88 | 89 | def heading_mod(_): # type: ignore 90 | pass 91 | keep_split_token = False 92 | 93 | slides = self._split_tokens_into_slides( 94 | tokens, slide_split_check, heading_mod, keep_split_token) 95 | 96 | return "", slides 97 | 98 | def _split_tokens_into_slides( 99 | self, 100 | tokens: List[Dict], 101 | slide_split_check: Callable, 102 | heading_mod: Callable, 103 | keep_split_token: bool 104 | ) -> List[Slide]: 105 | """Split the provided tokens into slides using the slide_split_check 106 | and heading_mod arguments. 107 | """ 108 | slides = [] 109 | curr_slide_tokens = [] 110 | for token in tokens: 111 | should_split = slide_split_check(token) 112 | if token["type"] == "heading": 113 | heading_mod(token) 114 | 115 | # new slide! 116 | if should_split: 117 | if keep_split_token and len(slides) == 0 and len(curr_slide_tokens) == 0: 118 | pass 119 | else: 120 | slides.extend(self._create_slides( 121 | curr_slide_tokens, len(slides))) 122 | curr_slide_tokens = [] 123 | if keep_split_token: 124 | curr_slide_tokens.append(token) 125 | continue 126 | else: 127 | curr_slide_tokens.append(token) 128 | 129 | slides.extend(self._create_slides(curr_slide_tokens, len(slides))) 130 | 131 | return slides 132 | 133 | @tutor( 134 | "general", 135 | "slides splitting", 136 | r""" 137 | Slides can be: 138 | 139 | ## Separated by horizontal rules (three or more `*`, `-`, or `_`) 140 | 141 | ```markdown 142 | slide 1 143 | *** 144 | slide 2 145 | ``` 146 | 147 | ## Split using existing headings ("smart" splitting) 148 | 149 | ```markdown 150 | # Slide 1 151 | 152 | # Slide 2 153 | ``` 154 | 155 | ## Rendered as a single slide with the `--single` or `--one` CLI parameter 156 | 157 | ```bash 158 | lookatme --single content.md 159 | ``` 160 | """, 161 | order=2, 162 | ) 163 | def _scan_for_smart_split(self, tokens): 164 | """Scan the provided tokens for the number of hrules, and the lowest 165 | (h1 < h2) header level. 166 | 167 | :returns: tuple (num_hrules, lowest_header_level) 168 | """ 169 | hinfo = { 170 | "title_level": None, 171 | "lowest_non_title": 10, 172 | "counts": defaultdict(int), 173 | "title": "", 174 | } 175 | num_hrules = 0 176 | first_heading = None 177 | for token in tokens: 178 | if token["type"] == "hrule": 179 | num_hrules += 1 180 | elif token["type"] == "heading": 181 | hinfo["counts"][token["level"]] += 1 182 | if first_heading is None: 183 | first_heading = token 184 | 185 | # started off with the lowest heading, make this title 186 | if ( 187 | hinfo["counts"] 188 | and first_heading 189 | and hinfo["counts"][first_heading["level"]] == 1 190 | ): 191 | hinfo["title"] = first_heading["text"] 192 | del hinfo["counts"][first_heading["level"]] 193 | hinfo["title_level"] = first_heading["level"] 194 | 195 | low_level = min(list(hinfo["counts"].keys()) + [10]) 196 | hinfo["title_level"] = low_level - 1 197 | hinfo["lowest_non_title"] = low_level 198 | 199 | return num_hrules, hinfo 200 | 201 | @tutor( 202 | "general", 203 | "metadata", 204 | r""" 205 | The YAML metadata that can be prefixed in slides includes these top level 206 | fields: 207 | 208 | ```yaml 209 | --- 210 | title: "title" 211 | date: "date" 212 | author: "author" 213 | extensions: 214 | - extension 1 215 | # .. list of extensions 216 | styles: 217 | # .. nested style fields .. 218 | --- 219 | ``` 220 | 221 | > **NOTE** The `styles` field will be explained in detail with each markdown 222 | > element. 223 | """, 224 | order=3, 225 | ) 226 | def parse_meta(self, input_data) -> Tuple[AnyStr, Dict]: 227 | """Parse the PresentationMeta out of the input data 228 | 229 | :param str input_data: The input data string 230 | :returns: tuple of (remaining_data, meta) 231 | """ 232 | found_first = False 233 | yaml_data = [] 234 | skipped_chars = 0 235 | for line in input_data.split("\n"): 236 | skipped_chars += len(line) + 1 237 | stripped_line = line.strip() 238 | 239 | is_marker = (re.match(r'----*', stripped_line) is not None) 240 | if is_marker: 241 | if not found_first: 242 | found_first = True 243 | # found the second one 244 | else: 245 | break 246 | 247 | if found_first and not is_marker: 248 | yaml_data.append(line) 249 | continue 250 | 251 | # there was no ----* marker 252 | if not found_first and stripped_line != "": 253 | break 254 | 255 | if not found_first: 256 | return input_data, MetaSchema().load_partial_styles({}, partial=True) 257 | 258 | new_input = input_data[skipped_chars:] 259 | if len(yaml_data) == 0: 260 | return new_input, MetaSchema().load_partial_styles({}, partial=True) 261 | 262 | yaml_data = "\n".join(yaml_data) 263 | data = MetaSchema().loads_partial_styles(yaml_data, partial=True) 264 | return new_input, data 265 | 266 | @tutor( 267 | "general", 268 | "progressive slides", 269 | r""" 270 | Slides can be progressively displayed by inserting `` 271 | comments between block elemtents (as in, inline within some other 272 | markdown element). 273 | 274 | 275 | This will display first, and after you press advance ... 276 | 277 | 278 | 279 | This will display! 280 | 281 | """, 282 | order=2, 283 | ) 284 | def _create_slides(self, tokens, number): 285 | """Iterate on tokens and create slides out of them. Can create multiple 286 | slides if the tokens contain progressive slide delimiters. 287 | 288 | :param list tokens: The tokens to create slides out of 289 | :param int number: The starting slide number 290 | :returns: A list of Slides 291 | """ 292 | slide_tokens = [] 293 | for token in tokens: 294 | if is_progressive_slide_delimiter_token(token): 295 | yield Slide(slide_tokens[:], number) 296 | number += 1 297 | else: 298 | slide_tokens.append(token) 299 | yield Slide(slide_tokens, number) 300 | -------------------------------------------------------------------------------- /lookatme/schemas.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines all schemas used in lookatme 3 | """ 4 | 5 | 6 | import datetime 7 | from typing import Dict 8 | 9 | import pygments.styles 10 | import yaml 11 | from marshmallow import INCLUDE, RAISE, Schema, fields, validate 12 | 13 | 14 | class NoDatesSafeLoader(yaml.SafeLoader): 15 | @classmethod 16 | def remove_implicit_resolver(cls, tag_to_remove): 17 | """ 18 | Remove implicit resolvers for a particular tag 19 | 20 | Takes care not to modify resolvers in super classes. 21 | 22 | We want to load datetimes as strings, not dates, because we 23 | go on to serialise as json which doesn't have the advanced types 24 | of yaml, and leads to incompatibilities down the track. 25 | """ 26 | if 'yaml_implicit_resolvers' not in cls.__dict__: 27 | cls.yaml_implicit_resolvers = cls.yaml_implicit_resolvers.copy() 28 | 29 | for first_letter, mappings in cls.yaml_implicit_resolvers.items(): 30 | cls.yaml_implicit_resolvers[first_letter] = [ 31 | (tag, regexp) for tag, regexp in mappings if tag != tag_to_remove 32 | ] 33 | 34 | 35 | NoDatesSafeLoader.remove_implicit_resolver('tag:yaml.org,2002:timestamp') 36 | 37 | 38 | class YamlRender: 39 | @staticmethod 40 | def loads(data): return yaml.load(data, Loader=NoDatesSafeLoader) 41 | @staticmethod 42 | def dumps(data): return yaml.safe_dump(data, allow_unicode=True) 43 | 44 | 45 | class BulletsSchema(Schema): 46 | default = fields.Str(dump_default="•") 47 | 48 | class Meta: 49 | include = { 50 | "1": fields.Str(dump_default="•"), 51 | "2": fields.Str(dump_default="⁃"), 52 | "3": fields.Str(dump_default="◦"), 53 | "4": fields.Str(dump_default="•"), 54 | "5": fields.Str(dump_default="⁃"), 55 | "6": fields.Str(dump_default="◦"), 56 | "7": fields.Str(dump_default="•"), 57 | "8": fields.Str(dump_default="⁃"), 58 | "9": fields.Str(dump_default="◦"), 59 | "10": fields.Str(dump_default="•"), 60 | } 61 | 62 | 63 | _NUMBERING_VALIDATION = validate.OneOf(["numeric", "alpha", "roman"]) 64 | 65 | 66 | class NumberingSchema(Schema): 67 | default = fields.Str(dump_default="numeric", 68 | validate=_NUMBERING_VALIDATION) 69 | 70 | class Meta: 71 | include = { 72 | "1": fields.Str(dump_default="numeric", validate=_NUMBERING_VALIDATION), 73 | "2": fields.Str(dump_default="alpha", validate=_NUMBERING_VALIDATION), 74 | "3": fields.Str(dump_default="roman", validate=_NUMBERING_VALIDATION), 75 | "4": fields.Str(dump_default="numeric", validate=_NUMBERING_VALIDATION), 76 | "5": fields.Str(dump_default="alpha", validate=_NUMBERING_VALIDATION), 77 | "6": fields.Str(dump_default="roman", validate=_NUMBERING_VALIDATION), 78 | "7": fields.Str(dump_default="numeric", validate=_NUMBERING_VALIDATION), 79 | "8": fields.Str(dump_default="alpha", validate=_NUMBERING_VALIDATION), 80 | "9": fields.Str(dump_default="roman", validate=_NUMBERING_VALIDATION), 81 | "10": fields.Str(dump_default="numeric", validate=_NUMBERING_VALIDATION), 82 | } 83 | 84 | 85 | class StyleFieldSchema(Schema): 86 | fg = fields.Str(dump_default="") 87 | bg = fields.Str(dump_default="") 88 | 89 | 90 | class SpacingSchema(Schema): 91 | top = fields.Int(dump_default=0) 92 | bottom = fields.Int(dump_default=0) 93 | left = fields.Int(dump_default=0) 94 | right = fields.Int(dump_default=0) 95 | 96 | 97 | class HeadingStyleSchema(Schema): 98 | prefix = fields.Str() 99 | suffix = fields.Str() 100 | fg = fields.Str(dump_default="") 101 | bg = fields.Str(dump_default="") 102 | 103 | 104 | class HruleSchema(Schema): 105 | char = fields.Str(dump_default="─") 106 | style = fields.Nested(StyleFieldSchema, dump_default=StyleFieldSchema().dump({ 107 | "fg": "#777", 108 | "bg": "default", 109 | })) 110 | 111 | 112 | class BlockQuoteSchema(Schema): 113 | side = fields.Str(dump_default="╎") 114 | top_corner = fields.Str(dump_default="┌") 115 | bottom_corner = fields.Str(dump_default="└") 116 | style = fields.Nested(StyleFieldSchema, dump_default=StyleFieldSchema().dump({ 117 | "fg": "italics,#aaa", 118 | "bg": "default", 119 | })) 120 | 121 | 122 | class HeadingsSchema(Schema): 123 | default = fields.Nested(HeadingStyleSchema, dump_default={ 124 | "fg": "#346,bold", 125 | "bg": "default", 126 | "prefix": "░░░░░ ", 127 | "suffix": "", 128 | }) 129 | 130 | class Meta: 131 | include = { 132 | "1": fields.Nested(HeadingStyleSchema, dump_default={ 133 | "fg": "#9fc,bold", 134 | "bg": "default", 135 | "prefix": "██ ", 136 | "suffix": "", 137 | }), 138 | "2": fields.Nested(HeadingStyleSchema, dump_default={ 139 | "fg": "#1cc,bold", 140 | "bg": "default", 141 | "prefix": "▓▓▓ ", 142 | "suffix": "", 143 | }), 144 | "3": fields.Nested(HeadingStyleSchema, dump_default={ 145 | "fg": "#29c,bold", 146 | "bg": "default", 147 | "prefix": "▒▒▒▒ ", 148 | "suffix": "", 149 | }), 150 | "4": fields.Nested(HeadingStyleSchema, dump_default={ 151 | "fg": "#559,bold", 152 | "bg": "default", 153 | "prefix": "░░░░░ ", 154 | "suffix": "", 155 | }), 156 | "5": fields.Nested(HeadingStyleSchema), 157 | "6": fields.Nested(HeadingStyleSchema), 158 | } 159 | 160 | 161 | class TableSchema(Schema): 162 | header_divider = fields.Str(dump_default="─") 163 | column_spacing = fields.Int(dump_default=3) 164 | 165 | 166 | class StyleSchema(Schema): 167 | """Styles schema for themes and style overrides within presentations 168 | """ 169 | class Meta: 170 | render_module = YamlRender 171 | unknown = RAISE 172 | 173 | style = fields.Str( 174 | dump_default="monokai", 175 | validate=validate.OneOf(list(pygments.styles.get_all_styles())), 176 | ) 177 | 178 | title = fields.Nested(StyleFieldSchema, dump_default={ 179 | "fg": "#f30,bold,italics", 180 | "bg": "default", 181 | }) 182 | author = fields.Nested(StyleFieldSchema, dump_default={ 183 | "fg": "#f30", 184 | "bg": "default", 185 | }) 186 | date = fields.Nested(StyleFieldSchema, dump_default={ 187 | "fg": "#777", 188 | "bg": "default", 189 | }) 190 | slides = fields.Nested(StyleFieldSchema, dump_default={ 191 | "fg": "#f30", 192 | "bg": "default", 193 | }) 194 | margin = fields.Nested(SpacingSchema, dump_default={ 195 | "top": 0, 196 | "bottom": 0, 197 | "left": 2, 198 | "right": 2, 199 | }) 200 | padding = fields.Nested(SpacingSchema, dump_default={ 201 | "top": 0, 202 | "bottom": 0, 203 | "left": 10, 204 | "right": 10, 205 | }) 206 | 207 | headings = fields.Nested( 208 | HeadingsSchema, dump_default=HeadingsSchema().dump(None)) 209 | bullets = fields.Nested( 210 | BulletsSchema, dump_default=BulletsSchema().dump(None)) 211 | numbering = fields.Nested( 212 | NumberingSchema, dump_default=NumberingSchema().dump(None)) 213 | table = fields.Nested(TableSchema, dump_default=TableSchema().dump(None)) 214 | quote = fields.Nested( 215 | BlockQuoteSchema, dump_default=BlockQuoteSchema().dump(None)) 216 | hrule = fields.Nested(HruleSchema, dump_default=HruleSchema().dump(None)) 217 | link = fields.Nested(StyleFieldSchema, dump_default={ 218 | "fg": "#33c,underline", 219 | "bg": "default", 220 | }) 221 | 222 | 223 | class MetaSchema(Schema): 224 | """The schema for presentation metadata 225 | """ 226 | class Meta: 227 | render_module = YamlRender 228 | unknown = INCLUDE 229 | 230 | title = fields.Str(dump_default="", load_default="") 231 | date = fields.Str( 232 | dump_default=datetime.datetime.now().strftime("%Y-%m-%d"), 233 | load_default=datetime.datetime.now().strftime("%Y-%m-%d"), 234 | ) 235 | author = fields.Str(dump_default="", load_default="") 236 | styles = fields.Nested( 237 | StyleSchema, 238 | dump_default=StyleSchema().dump(None), 239 | load_default=StyleSchema().dump(None), 240 | ) 241 | extensions = fields.List(fields.Str(), dump_default=[], load_default=[]) 242 | 243 | def _ensure_top_level_defaults(self, res: Dict) -> Dict: 244 | res.setdefault("title", "") 245 | res.setdefault("author", "") 246 | res.setdefault("date", datetime.datetime.now().strftime("%Y-%m-%d")) 247 | res.setdefault("extensions", []) 248 | 249 | return res 250 | 251 | def loads_partial_styles(self, *args, **kwargs): 252 | kwargs["partial"] = True 253 | res = super(self.__class__, self).loads(*args, **kwargs) 254 | if res is None: 255 | raise ValueError("Could not loads") 256 | 257 | res = self._ensure_top_level_defaults(res) 258 | return res 259 | 260 | def loads(self, *args, **kwargs) -> Dict: 261 | res = super(self.__class__, self).loads(*args, **kwargs) 262 | if res is None: 263 | raise ValueError("Could not loads") 264 | 265 | return res 266 | 267 | def load_partial_styles(self, *args, **kwargs): 268 | kwargs["partial"] = True 269 | res = super(self.__class__, self).load(*args, **kwargs) 270 | if res is None: 271 | raise ValueError("Could not loads") 272 | 273 | res = self._ensure_top_level_defaults(res) 274 | return res 275 | 276 | def load(self, *args, **kwargs) -> Dict: 277 | res = super(self.__class__, self).load(*args, **kwargs) 278 | if res is None: 279 | raise ValueError("Could not load") 280 | 281 | return res 282 | 283 | def dump(self, *args, **kwargs) -> Dict: 284 | res = super(self.__class__, self).dump(*args, **kwargs) 285 | if res is None: 286 | raise ValueError("Could not dump") 287 | return res 288 | --------------------------------------------------------------------------------