├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .pep8speaks.yml ├── .pre-commit-config.yaml ├── .prettierignore ├── .prettierrc ├── LICENSE ├── MANIFEST.in ├── README.md ├── binder ├── environment.yml └── postBuild ├── example ├── demo.ipyg └── demo.png ├── install.json ├── ipyspaghetti ├── __init__.py ├── _version.py ├── graph.py └── handlers.py ├── jupyter-config └── node_editor.json ├── package.json ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py ├── src ├── graph.ts ├── graph_api.ts ├── graph_panel.ts ├── graph_widget.tsx ├── index.ts ├── manager.ts ├── mime.ts ├── utils.ts └── widget.ts ├── style ├── base.css ├── index.css ├── index.js ├── litegraph-editor.css └── litegraph.css └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | **/*.d.ts 5 | tests 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:@typescript-eslint/eslint-recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:prettier/recommended' 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | project: 'tsconfig.json', 11 | sourceType: 'module' 12 | }, 13 | plugins: ['@typescript-eslint'], 14 | rules: { 15 | '@typescript-eslint/interface-name-prefix': [ 16 | 'error', 17 | { prefixWithI: 'always' } 18 | ], 19 | '@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }], 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | '@typescript-eslint/no-namespace': 'off', 22 | '@typescript-eslint/no-use-before-define': 'off', 23 | '@typescript-eslint/quotes': [ 24 | 'error', 25 | 'single', 26 | { avoidEscape: true, allowTemplateLiterals: false } 27 | ], 28 | curly: ['error', 'all'], 29 | eqeqeq: 'error', 30 | 'prefer-arrow-callback': 'error' 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: 7 | branches: '*' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | - name: Install node 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: '10.x' 19 | - name: Install Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: '3.7' 23 | architecture: 'x64' 24 | - name: Install dependencies 25 | run: python -m pip install 'jupyterlab>=3.0.0' 26 | - name: Build the extension 27 | run: | 28 | set -x 29 | jlpm 30 | jlpm run eslint:check 31 | python -m pip install . 32 | 33 | jupyter serverextension list 2>&1 | grep -ie "ipyspaghetti.*OK" 34 | 35 | jupyter labextension list 2>&1 | grep -ie "ipyspaghetti.*OK" 36 | python -m jupyterlab.browser_check 37 | set +x 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.egg-info/ 5 | .ipynb_checkpoints 6 | *.tsbuildinfo 7 | ipyspaghetti/labextension 8 | 9 | # Created by https://www.gitignore.io/api/python 10 | # Edit at https://www.gitignore.io/?templates=python 11 | 12 | ### Python ### 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | *.py[cod] 16 | *$py.class 17 | 18 | # C extensions 19 | *.so 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | pip-wheel-metadata/ 36 | share/python-wheels/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | .hypothesis/ 62 | .pytest_cache/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | .spyproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | # Mr Developer 94 | .mr.developer.cfg 95 | .project 96 | .pydevproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | .dmypy.json 104 | dmypy.json 105 | 106 | # Pyre type checker 107 | .pyre/ 108 | 109 | # End of https://www.gitignore.io/api/python 110 | 111 | # OSX files 112 | .DS_Store 113 | -------------------------------------------------------------------------------- /.pep8speaks.yml: -------------------------------------------------------------------------------- 1 | # File : .pep8speaks.yml 2 | # See https://pep8speaks.com 3 | 4 | message: # Customize the comment made by the bot 5 | opened: # Messages when a new PR is submitted 6 | header: "Hi there, @{name}! Thanks for opening this PR. " 7 | # The keyword {name} is converted into the author's username 8 | footer: "Do see both the [yt style guide](http://yt-project.org/doc/developing/developing.html#code-style-guide) )and the [Hitchhiker's guide to code style](https://goo.gl/hqbW4r)" 9 | # The messages can be written as they would over GitHub 10 | updated: # Messages when new commits are added to the PR 11 | header: "Hi there, @{name}! Thanks for updating this PR. " 12 | footer: "" # Why to comment the link to the style guide everytime? :) 13 | no_errors: "There are currently no PEP 8 issues detected in this Pull Request. Hooray! :fireworks: " 14 | 15 | scanner: 16 | diff_only: True # If True, errors caused by only the patch are shown 17 | 18 | pycodestyle: 19 | max-line-length: 999 # Default is 79 in PEP 8 20 | ignore: # Errors and warnings to ignore 21 | - E111 22 | - E121 23 | - E122 24 | - E123 25 | - E124 26 | - E125 27 | - E126 28 | - E127 29 | - E128 30 | - E129 31 | - E131 32 | - E201 33 | - E202 34 | - E211 35 | - E221 36 | - E222 37 | - E227 38 | - E228 39 | - E241 40 | - E301 41 | - E203 42 | - E225 43 | - E226 44 | - E231 45 | - E251 46 | - E261 47 | - E262 48 | - E265 49 | - E266 50 | - E302 51 | - E303 52 | - E305 53 | - E306 54 | - E402 55 | - E502 56 | - E701 57 | - E703 58 | - E722 59 | - E741 60 | - E731 61 | - W291 62 | - W292 63 | - W293 64 | - W391 65 | - W503 66 | - W504 67 | - W605 68 | - E203 69 | - W504 70 | exclude: 71 | - doc 72 | - benchmarks 73 | - \*/api.py 74 | - \*/__init__.py 75 | - \*/__config__.py 76 | - yt/visualization/_mpl_imports.py 77 | - yt/utilities/lodgeit.py 78 | - yt/extern/\* 79 | - yt/mods.py 80 | - yt/utilities/fits_image.py 81 | 82 | only_mention_files_with_errors: True # If False, a separate status comment for each file is made. 83 | descending_issues_order: False # If True, PEP 8 issues in message will be displayed in descending order of line numbers in the file 84 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # pre-commit 1.1.0 is required for `exclude` 2 | # however `minimum_pre_commit_version` itself requires 1.15.0 3 | minimum_pre_commit_version: "1.15.0" 4 | 5 | # note: isort can't be applied to yt/__init__.py because it creates circular imports 6 | exclude: "^()" 7 | 8 | repos: 9 | 10 | # Note that in rare cases, flynt may undo some of the formating from black. 11 | # A stable configuration is run black last. 12 | - repo: https://github.com/ikamensh/flynt 13 | rev: '0.52' # keep in sync with tests/lint_requirements.txt 14 | hooks: 15 | - id: flynt 16 | - repo: https://github.com/ambv/black 17 | rev: 19.10b0 # keep in sync with tests/lint_requirements.txt 18 | hooks: 19 | - id: black 20 | language_version: python3 21 | - repo: https://github.com/timothycrosley/isort 22 | rev: '5.6.4' # keep in sync with tests/lint_requirements.txt 23 | hooks: 24 | - id: isort 25 | additional_dependencies: [toml] 26 | - repo: https://gitlab.com/pycqa/flake8 27 | rev: '3.8.1' # keep in sync with tests/lint_requirements.txt 28 | hooks: 29 | - id: flake8 30 | additional_dependencies: [mccabe, flake8-bugbear] 31 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/node_modules 3 | **/lib 4 | **/package.json 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Corentin Cadiou 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include pyproject.toml 4 | include jupyter-config/ipyspaghetti.json 5 | 6 | include package.json 7 | include install.json 8 | include ts*.json 9 | 10 | graft ipyspaghetti/labextension 11 | 12 | # Javascript files 13 | graft src 14 | graft style 15 | prune **/node_modules 16 | prune lib 17 | 18 | # Patterns to exclude from any directory 19 | global-exclude *~ 20 | global-exclude *.pyc 21 | global-exclude *.pyo 22 | global-exclude .git 23 | global-exclude .ipynb_checkpoints 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IpySpaghetti — WORK IN PROGRESS 2 | 3 | ![Github Actions Status](https://github.com/cphyc/ipyspaghetti/workflows/Build/badge.svg)[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/cphyc/ipyspaghetti/main?urlpath=lab/tree/example/demo.ipyg) 4 | 5 | This extension is composed of a Python package named `ipyspaghetti` 6 | for the server extension and a NPM package named `ipyspaghetti` 7 | for the frontend extension. 8 | 9 | It is subject to changes as this is still very much a work in progress. DO NOT RELY EXPECT IT TO WORK NOR RELY ON IT FOR IMPORTANT WORK. 10 | If you are interested in the idea, please feel free to contact me, as I don't have much time to progress on the project for the foreseeable future. 11 | 12 | Comments, reviews and contributions are more than welcome! 13 | 14 | ## Rationale 15 | 16 | I really enjoy working with IPython Notebook, especially in jupyterlab for the level of interactivity it allows. Some of the shortcomings of notebooks ([from this blog post](https://datapastry.com/blog/why-i-dont-use-jupyter-notebooks-and-you-shouldnt-either/), but similar posts are numerous!). 17 | - They encourage polluting the global namespace, 18 | - they discourage effective code reuse, 19 | - they harm reproducibility (because the only order that make sense is top to bottom, but that's usually not the order in which cells were executed), 20 | - they don’t play nicely with source control, 21 | - they're painful to test. 22 | 23 | I would also add that notebooks do not convey the way I think about data analysis. In my mind, analysing data requires multiple independent steps that get some data in, transform them and yield new data. 24 | 25 | This projects aims to address these issues by providing a slightly more constrained development environment based on data flows rather than cells. To address the points above, this project provides a **file format** and a **development environment**, all integrated in JupyterLab. 26 | 27 | ![Example image](example/demo.png) 28 | 29 | The project is based on the idea of data flows, represented as nodes in a graph. The graph describes how to load, modify and output data, where data is an abstraction that comprise files on disk, resources on the Internet, the result of a plot, etc. The underlying file format is a _valid Python file_ that contains a global variable named `___GRAPH`. This variable is a string containing a JSON-formatted description of the graph. It can either be loaded in the development environment, or eventually be parsed entirely in Python and executed in headless environments (this *hasn't been coded yet*). 30 | 31 | The project aims to reuse as much as possible what's already been done for JupyterLab to allow a similar level of interactivity (including ipywidgets). 32 | 33 | The python package provides a registry in which you can register _any_ Python function using `register_node`. Any import will happen in the global scope, and any function which is not registered will be global. Registered function can then be used as many times as you want as a node in the graph. The node takes as inputs the function's input and returns some outputs. If you add type annotations to your function, it will also take them into account to decide which inputs and outputs are compatible. This allows to address the points above as follows: 34 | - Polluting the global namespace: most of the logic of the code is wrapped in Python functions, so the global namespace is cleaner. 35 | - Code reuse: because the files are plain Python, you can import them as regular Python modules, and rellocate the functions to other files _without affecting the graph_ (i.e. change the underlying code structure without harming your data pipeline). 36 | - Reproducibility: the graph enforces the fact that each node is up-to-date with its input(s), so you cannot run into out-of-order issues. Simply select a node, run it and all its dependencies will be updated (if required, and only if required!). 37 | - Source control: since the logic of the code is separated from the graph, you can either git track the file (but then you'll also get the graph with its metadata and pollute the commit history) or you can move the registered functions to another Python file that contains no graph and import them in your graph file. 38 | - Testing: there is no magic happening when registering the function (except its name is saved), so the function is _unchanged_. This means you can unit-test it as any other regular Python function. 39 | 40 | Of course, this approach has a few shortcomings. First, completion is not as friendly as in a regular notebook, as the global scope is clean (but it should be possible to integrate [JupyterLab-LSP](https://github.com/krassowski/jupyterlab-lsp), since we're reusing many JupyterLab components). Second, there is some magic happening under the hood to connect the nodes together and manage the inputs/outputs. This may be a source of confusion and hard-to-understand bugs. 41 | 42 | 43 | ## Features & TODO list 44 | 45 | - [x] Provide a basic interface to create the graph 46 | - [x] Load the nodes from Python 47 | - [x] Basic interface with ipykernel 48 | - [x] Basic output back in the browser 49 | - [x] Lazy execution of the nodes (only the new ones are executed, or those downstream) 50 | - [x] Basic save/restore graph 51 | - [x] Integrate in JupyterLab 52 | - [x] Support `ipyg` mimetype 53 | - [x] Edit the nodes' code in the browser _à la_ Jupyter Notebook 54 | - [x] Support multiple outputs 55 | - [x] (partially done) Rename to IPySphaghetti 56 | - [ ] Easy way to create new graphs (for the moment, an already-existing `.ipyg` need to be opened) 57 | - [ ] Nicer packaging 58 | 59 | ## Requirements 60 | 61 | * JupyterLab >= 3.0 62 | 63 | ## Install 64 | 65 | For the moment, the package needs to be installed from source, which you can achieve using 66 | ```bash 67 | pip install . -v 68 | ``` 69 | 70 | 71 | ## Troubleshoot 72 | 73 | If you are seeing the frontend extension, but it is not working, check 74 | that the server extension is enabled: 75 | 76 | ```bash 77 | jupyter server extension list 78 | ``` 79 | 80 | If the server extension is installed and enabled, but you are not seeing 81 | the frontend extension, check the frontend extension is installed: 82 | 83 | ```bash 84 | jupyter labextension list 85 | ``` 86 | 87 | 88 | ## Contributing 89 | 90 | ### Development install 91 | 92 | Note: You will need NodeJS to build the extension package. 93 | 94 | The `jlpm` command is JupyterLab's pinned version of 95 | [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use 96 | `yarn` or `npm` in lieu of `jlpm` below. 97 | 98 | ```bash 99 | # Clone the repo to your local environment 100 | # Change directory to the ipyspaghetti directory 101 | # Install package in development mode 102 | pip install -e . 103 | # Link your development version of the extension with JupyterLab 104 | jupyter labextension develop . --overwrite 105 | # Rebuild extension Typescript source after making changes 106 | jlpm run build 107 | ``` 108 | 109 | You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension. 110 | 111 | ```bash 112 | # Watch the source directory in one terminal, automatically rebuilding when needed 113 | jlpm run watch 114 | # Run JupyterLab in another terminal 115 | jupyter lab 116 | ``` 117 | 118 | With the watch command running, every saved change will immediately be built locally and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt). 119 | 120 | By default, the `jlpm run build` command generates the source maps for this extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command: 121 | 122 | ```bash 123 | jupyter lab build --minimize=False 124 | ``` 125 | 126 | ### Uninstall 127 | 128 | ```bash 129 | pip uninstall ipyspaghetti 130 | ``` 131 | -------------------------------------------------------------------------------- /binder/environment.yml: -------------------------------------------------------------------------------- 1 | # a mybinder.org-ready environment for demoing ipyspaghetti 2 | # this environment may also be used locally on Linux/MacOS/Windows, e.g. 3 | # 4 | # conda env update --file binder/environment.yml 5 | # conda activate ipyspaghetti-demo 6 | # 7 | name: ipyspaghetti-demo 8 | 9 | channels: 10 | - conda-forge 11 | 12 | dependencies: 13 | # runtime dependencies 14 | - python >=3.8,<3.9.0a0 15 | - jupyterlab >=3,<4.0.0a0 16 | # to appease `pip check` 17 | - jupyter_telemetry >=0.1.0 18 | # labextension build dependencies 19 | - nodejs >=14,<15 20 | - pip 21 | - wheel 22 | # additional packages for demos 23 | - ipywidgets 24 | - cython 25 | - git 26 | - sympy 27 | - ipython 28 | - matplotlib-base 29 | - netCDF4 30 | - pooch # to load datasets from the yt repository 31 | - pip: 32 | - git+https://github.com/yt-project/yt.git@main#egg=yt 33 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ perform a development install of ipyspaghetti 3 | 4 | On Binder, this will run _after_ the environment has been fully created from 5 | the environment.yml in this directory. 6 | 7 | This script should also run locally on Linux/MacOS/Windows: 8 | 9 | python3 binder/postBuild 10 | """ 11 | import subprocess 12 | import sys 13 | from pathlib import Path 14 | 15 | 16 | ROOT = Path.cwd() 17 | 18 | def _(*args, **kwargs): 19 | """ Run a command, echoing the args 20 | 21 | fails hard if something goes wrong 22 | """ 23 | print("\n\t", " ".join(args), "\n") 24 | return_code = subprocess.call(args, **kwargs) 25 | if return_code != 0: 26 | print("\nERROR", return_code, " ".join(args)) 27 | sys.exit(return_code) 28 | 29 | # verify the environment is self-consistent before even starting 30 | _(sys.executable, "-m", "pip", "check") 31 | 32 | # install the labextension 33 | _(sys.executable, "-m", "pip", "install", "-e", ".") 34 | 35 | # verify the environment the extension didn't break anything 36 | _(sys.executable, "-m", "pip", "check") 37 | 38 | # list the extensions 39 | _("jupyter", "server", "extension", "list") 40 | 41 | # remove Lab-3 incompatible extension 42 | subprocess.call(["jupyter", "labextension", "uninstall", "--no-build", "jupyter-offlinenotebook"]) 43 | 44 | # initially list installed extensions to determine if there are any surprises 45 | _("jupyter", "labextension", "list") 46 | 47 | 48 | print("JupyterLab with ipyspaghetti is ready to run with:\n") 49 | print("\tjupyter lab\n") 50 | -------------------------------------------------------------------------------- /example/demo.ipyg: -------------------------------------------------------------------------------- 1 | # % IPYS: Globals 2 | from typing import Any, Tuple 3 | 4 | import yt 5 | from yt.data_objects.static_output import Dataset 6 | from yt.visualization.plot_window import PlotWindow 7 | 8 | from ipyspaghetti.graph import register_node, registry 9 | 10 | 11 | # % IPYS: Nodes 12 | @register_node 13 | def load_dataset( 14 | path: str = "output_00080", 15 | ) -> Dataset: 16 | ds = yt.load_sample(path) 17 | ds.index 18 | return ds 19 | 20 | 21 | @register_node 22 | def pair_to_tuple(a: Any, b: Any) -> Tuple[Any, Any]: 23 | return (a, b) 24 | 25 | 26 | @register_node 27 | def projection_plot( 28 | ds: Dataset, axis: str = "x", field: Tuple[str, str] = ("gas", "density") 29 | ) -> PlotWindow: 30 | p = yt.ProjectionPlot(ds, axis, field) 31 | return p 32 | 33 | 34 | # % IPYS: Graph 35 | ___GRAPH = """{ 36 | "last_node_id": 5, 37 | "last_link_id": 2, 38 | "nodes": [ 39 | { 40 | "id": 5, 41 | "type": "nodes/projection_plot", 42 | "pos": { 43 | "0": 458, 44 | "1": 86, 45 | "2": 0, 46 | "3": 0, 47 | "4": 0, 48 | "5": 0, 49 | "6": 0, 50 | "7": 0, 51 | "8": 0, 52 | "9": 0 53 | }, 54 | "size": { 55 | "0": 140, 56 | "1": 66 57 | }, 58 | "flags": {}, 59 | "order": 1, 60 | "mode": 0, 61 | "inputs": [ 62 | { 63 | "name": "ds", 64 | "type": "", 65 | "link": 2, 66 | "color_on": "#66F2FF", 67 | "color_off": "#4DF0FF", 68 | "shape": 4 69 | }, 70 | { 71 | "name": "axis", 72 | "type": "string", 73 | "link": null, 74 | "color_on": "#EBFF66", 75 | "color_off": "#E7FF4D", 76 | "shape": 1 77 | }, 78 | { 79 | "name": "field", 80 | "type": "[(typing.Any, typing.Any)])", 81 | "link": null, 82 | "color_on": "#FFDB66", 83 | "color_off": "#FFD54D", 84 | "shape": 1 85 | } 86 | ], 87 | "outputs": [ 88 | { 89 | "name": "output", 90 | "type": "", 91 | "links": null, 92 | "color_on": "#F5FF66", 93 | "color_off": "#F3FF4D", 94 | "shape": 4 95 | } 96 | ], 97 | "properties": { 98 | "state": 4, 99 | "count": 0, 100 | "previous_input": [], 101 | "type": 1 102 | }, 103 | "boxcolor": "purple" 104 | }, 105 | { 106 | "id": 4, 107 | "type": "nodes/load_dataset", 108 | "pos": { 109 | "0": 208, 110 | "1": 102, 111 | "2": 0, 112 | "3": 0, 113 | "4": 0, 114 | "5": 0, 115 | "6": 0, 116 | "7": 0, 117 | "8": 0, 118 | "9": 0 119 | }, 120 | "size": { 121 | "0": 140, 122 | "1": 26 123 | }, 124 | "flags": {}, 125 | "order": 0, 126 | "mode": 0, 127 | "inputs": [ 128 | { 129 | "name": "path", 130 | "type": "string", 131 | "link": null, 132 | "color_on": "#EBFF66", 133 | "color_off": "#E7FF4D", 134 | "shape": 1 135 | } 136 | ], 137 | "outputs": [ 138 | { 139 | "name": "output", 140 | "type": "", 141 | "links": [ 142 | 2 143 | ], 144 | "color_on": "#66F2FF", 145 | "color_off": "#4DF0FF", 146 | "shape": 4 147 | } 148 | ], 149 | "properties": { 150 | "state": 4, 151 | "count": 0, 152 | "previous_input": [], 153 | "type": 1 154 | }, 155 | "boxcolor": "purple" 156 | } 157 | ], 158 | "links": [ 159 | [ 160 | 2, 161 | 4, 162 | 0, 163 | 5, 164 | 0, 165 | "" 166 | ] 167 | ], 168 | "groups": [], 169 | "config": {}, 170 | "extra": {}, 171 | "version": 0.4 172 | }""" 173 | -------------------------------------------------------------------------------- /example/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cphyc/ipyspaghetti/5d0559380d16a794fa9dff22e5af5ebbf4dbe0c1/example/demo.png -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "ipyspaghetti", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package ipyspaghetti" 5 | } 6 | -------------------------------------------------------------------------------- /ipyspaghetti/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os.path as osp 3 | 4 | from ._version import __version__ # noqa: F401 5 | from .handlers import setup_handlers 6 | 7 | HERE = osp.abspath(osp.dirname(__file__)) 8 | 9 | with open(osp.join(HERE, "labextension", "package.json")) as fid: 10 | data = json.load(fid) 11 | 12 | 13 | def _jupyter_labextension_paths(): 14 | return [{"src": "labextension", "dest": data["name"]}] 15 | 16 | 17 | def _jupyter_server_extension_points(): 18 | return [{"module": "ipyspaghetti"}] 19 | 20 | 21 | def _load_jupyter_server_extension(server_app): 22 | """Registers the API handler to receive HTTP requests from the frontend extension. 23 | 24 | Parameters 25 | ---------- 26 | lab_app: jupyterlab.labapp.LabApp 27 | JupyterLab application instance 28 | """ 29 | setup_handlers(server_app.web_app) 30 | server_app.log.info("Registered HelloWorld extension at URL path /ipyspaghetti") 31 | -------------------------------------------------------------------------------- /ipyspaghetti/_version.py: -------------------------------------------------------------------------------- 1 | __all__ = ["__version__"] 2 | 3 | 4 | def _fetchVersion(): 5 | import json 6 | import os 7 | 8 | HERE = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | for d, _, _ in os.walk(HERE): 11 | try: 12 | with open(os.path.join(d, "package.json")) as f: 13 | return json.load(f)["version"] 14 | except FileNotFoundError: 15 | pass 16 | 17 | raise FileNotFoundError(f"Could not find package.json under dir {HERE}") 18 | 19 | 20 | __version__ = _fetchVersion() 21 | -------------------------------------------------------------------------------- /ipyspaghetti/graph.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import json 3 | from inspect import _empty, getsource, signature 4 | from types import FunctionType 5 | from typing import Callable, Dict, Union 6 | 7 | import typing_utils 8 | from IPython.display import JSON, display 9 | 10 | 11 | class Node(dict): 12 | function: FunctionType 13 | type_map: Dict[str, type] 14 | 15 | def __init__(self, inputs, outputs, fun, type_map, namespace): 16 | super(Node, self).__init__( 17 | inputs=inputs, 18 | outputs=outputs, 19 | name=fun.__name__, 20 | source=getsource(fun), 21 | namespace=namespace 22 | ) 23 | self.function = fun 24 | self.type_map = type_map 25 | 26 | def __call__(self, *args, **kwargs): 27 | return self.function(*args, **kwargs) 28 | 29 | def __repr__(self): 30 | data = super(Node, self).__repr__() 31 | return f"" 32 | 33 | def _ipython_display_(self): 34 | return display(JSON(self)) 35 | 36 | 37 | class NodeRegistry: 38 | nodes: Dict[str, Node] 39 | 40 | def __init__(self): 41 | self.nodes = {} 42 | 43 | def register_function(self, fun: Callable, namespace: str = 'nodes'): 44 | # Extract signature 45 | sig = signature(fun) 46 | 47 | type_map = {} 48 | inputs = {} 49 | for pname, p in sig.parameters.items(): 50 | tp = typing_utils.normalize(p.annotation) 51 | # Mandatory 52 | if (p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD) and ( 53 | p.default == _empty 54 | ): 55 | optional = False 56 | else: 57 | optional = True 58 | inputs[pname] = {"type": str(tp), "optional": optional} 59 | type_map[str(tp)] = p.annotation 60 | 61 | tp = typing_utils.normalize(sig.return_annotation) 62 | if sig.return_annotation != _empty: 63 | outputs = {"output": {"type": str(tp), "optional": False}} 64 | type_map[str(tp)] = sig.return_annotation 65 | else: 66 | outputs = {} 67 | 68 | self.nodes[fun.__name__] = Node(inputs, outputs, fun, type_map, namespace) 69 | return fun 70 | 71 | def register(self, function_or_namespace: Union[FunctionType, str]): 72 | if isinstance(function_or_namespace, str): 73 | namespace: str = function_or_namespace 74 | def decorator(fun: FunctionType): 75 | return self.register_function(fun, namespace=namespace) 76 | return decorator 77 | else: 78 | fun: FunctionType = function_or_namespace 79 | return self.register_function(fun) 80 | 81 | def get_nodes(self) -> Dict[str, Node]: 82 | return self.nodes 83 | 84 | def get_nodes_as_json(self) -> str: 85 | return json.dumps(self.get_nodes()) 86 | 87 | @staticmethod 88 | def _resolve_parent( 89 | t1_str: str, t2_str: str, types: Dict[str, type], parent_types: Dict[str, str] 90 | ) -> bool: 91 | t1 = types[t1_str] 92 | t2 = types[t2_str] 93 | # Check whether t1 is a subtype of t2 94 | if not typing_utils.issubtype(t1, t2): 95 | return False 96 | 97 | while t2_str in parent_types: 98 | t2_str = parent_types[t2_str] 99 | 100 | parent_types[t1_str] = t2_str 101 | return True 102 | 103 | def get_parent_types(self) -> Dict[str, str]: 104 | type_map: Dict[str, type] = {} 105 | parent_types: Dict[str, str] = {} 106 | 107 | for node in self.nodes.values(): 108 | type_map.update(node.type_map) 109 | 110 | type_str = [t for t in type_map.keys() if t != "typing.Any"] 111 | 112 | for i1, t1_str in enumerate(type_str): 113 | for t2_str in type_str[i1 + 1 :]: 114 | if not self._resolve_parent(t1_str, t2_str, type_map, parent_types): 115 | self._resolve_parent(t2_str, t1_str, type_map, parent_types) 116 | return parent_types 117 | 118 | def get_parent_types_as_json(self) -> str: 119 | return json.dumps(self.get_parent_types()) 120 | 121 | 122 | registry = NodeRegistry() 123 | 124 | 125 | def register_node(fun: Union[FunctionType, str]) -> FunctionType: 126 | return registry.register(fun) 127 | -------------------------------------------------------------------------------- /ipyspaghetti/handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import tornado 4 | from jupyter_server.base.handlers import APIHandler 5 | from jupyter_server.utils import url_path_join 6 | 7 | 8 | class RouteHandler(APIHandler): 9 | # The following decorator should be present on all verb methods (head, get, post, 10 | # patch, put, delete, options) to ensure only authorized user can request the 11 | # Jupyter server 12 | @tornado.web.authenticated 13 | def get(self): 14 | self.finish(json.dumps({"data": "This is /ipyspaghetti/get_example endpoint!"})) 15 | 16 | 17 | def setup_handlers(web_app): 18 | host_pattern = ".*$" 19 | 20 | base_url = web_app.settings["base_url"] 21 | route_pattern = url_path_join(base_url, "ipyspaghetti", "get_example") 22 | handlers = [(route_pattern, RouteHandler)] 23 | web_app.add_handlers(host_pattern, handlers) 24 | -------------------------------------------------------------------------------- /jupyter-config/node_editor.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "ipyspaghetti": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ipyspaghetti", 3 | "version": "0.1.0", 4 | "description": "Interactive node editor for python", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension" 9 | ], 10 | "homepage": "https://github.com/cphyc/ipyspaghetti", 11 | "bugs": { 12 | "url": "https://github.com/cphyc/ipyspaghetti/issues" 13 | }, 14 | "license": "BSD-3-Clause", 15 | "author": "Corentin Cadiou", 16 | "files": [ 17 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", 18 | "style/**/*.{css,.js,eot,gif,html,jpg,json,png,svg,woff2,ttf}" 19 | ], 20 | "main": "lib/index.js", 21 | "types": "lib/index.d.ts", 22 | "style": "style/index.css", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/cphyc/ipyspaghetti.git" 26 | }, 27 | "scripts": { 28 | "build": "jlpm run build:lib && jlpm run build:labextension:dev", 29 | "build:prod": "jlpm run build:lib && jlpm run build:labextension", 30 | "build:labextension": "jupyter labextension build .", 31 | "build:labextension:dev": "jupyter labextension build --development True .", 32 | "build:lib": "tsc", 33 | "clean": "jlpm run clean:lib", 34 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo", 35 | "clean:labextension": "rimraf ipyspaghetti/labextension", 36 | "clean:all": "jlpm run clean:lib && jlpm run clean:labextension", 37 | "eslint": "eslint . --ext .ts,.tsx --fix", 38 | "eslint:check": "eslint . --ext .ts,.tsx", 39 | "install:extension": "jupyter labextension develop --overwrite .", 40 | "prepare": "jlpm run clean && jlpm run build:prod", 41 | "watch": "run-p watch:src watch:labextension", 42 | "watch:src": "tsc -w", 43 | "watch:labextension": "jupyter labextension watch ." 44 | }, 45 | "dependencies": { 46 | "@jupyterlab/application": "^3.0.2", 47 | "@jupyterlab/cells": "^3.0.2", 48 | "@jupyterlab/codeeditor": "^3.0.2", 49 | "@jupyterlab/codemirror": "^3.0.2", 50 | "@jupyterlab/completer": "^3.0.2", 51 | "@jupyterlab/coreutils": "^5.0.0", 52 | "@jupyterlab/docregistry": "^3.0.3", 53 | "@jupyterlab/launcher": "^3.0.2", 54 | "@jupyterlab/mainmenu": "^3.0.2", 55 | "@jupyterlab/outputarea": "^3.0.2", 56 | "@jupyterlab/rendermime": "^3.0.2", 57 | "@jupyterlab/rendermime-interfaces": "^3.0.2", 58 | "@jupyterlab/services": "^6.0.0", 59 | "@lumino/coreutils": "^1.5.3", 60 | "@lumino/widgets": "^1.5.0", 61 | "@types/codemirror": "^0.0.106", 62 | "hsl-to-rgb-for-reals": "^1.1.1", 63 | "litegraph.js": "^0.7.9", 64 | "object-hash": "^2.1.1" 65 | }, 66 | "devDependencies": { 67 | "@jupyterlab/builder": "^3.0.0", 68 | "@typescript-eslint/eslint-plugin": "^2.27.0", 69 | "@typescript-eslint/parser": "^2.27.0", 70 | "eslint": "^7.5.0", 71 | "eslint-config-prettier": "^6.10.1", 72 | "eslint-plugin-prettier": "^3.1.2", 73 | "mkdirp": "^1.0.3", 74 | "npm-run-all": "^4.1.5", 75 | "prettier": "^1.19.0", 76 | "rimraf": "^3.0.2", 77 | "typescript": "~4.1.3" 78 | }, 79 | "sideEffects": [ 80 | "style/*.css", 81 | "style/index.js" 82 | ], 83 | "styleModule": "style/index.js", 84 | "jupyterlab": { 85 | "discovery": { 86 | "server": { 87 | "managers": [ 88 | "pip" 89 | ], 90 | "base": { 91 | "name": "ipyspaghetti" 92 | } 93 | } 94 | }, 95 | "extension": "lib/index.js", 96 | "mimeExtension": "lib/mime.js", 97 | "outputDir": "ipyspaghetti/labextension" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["jupyter_packaging~=0.7.9", "jupyterlab~=3.0", "setuptools>=40.8.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | # To be kept consistent with "Code Style" section in CONTRIBUTING.rst 6 | [tool.black] 7 | line-length = 88 8 | target-version = ['py36', 'py37', 'py38'] 9 | include = '\.pyi?$' 10 | exclude = ''' 11 | /( 12 | \.eggs 13 | | \.git 14 | | \.hg 15 | | \.mypy_cache 16 | | \.tox 17 | | \.venv 18 | | _build 19 | | buck-out 20 | | build 21 | | dist 22 | )/ 23 | ''' 24 | 25 | 26 | # To be kept consistent with "Import Formatting" section in CONTRIBUTING.rst 27 | [tool.isort] 28 | profile = "black" 29 | combine_as_imports = true 30 | known_third_party = [ 31 | "IPython", 32 | "nose", 33 | "numpy", 34 | "sympy", 35 | "matplotlib", 36 | "unyt", 37 | "git", 38 | "yaml", 39 | "dateutil", 40 | "requests", 41 | "coverage", 42 | "pytest", 43 | "pyx", 44 | "glue", 45 | ] 46 | known_first_party = ["ipyspaghetti"] 47 | sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 48 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | typing_utils>=0.0.2 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [egg_info] 2 | #tag_build = .dev 3 | #tag_svn_revision = 1 4 | 5 | 6 | # To be kept consistent with "Coding Style Guide" section in CONTRIBUTING.rst 7 | [flake8] 8 | # we exclude: 9 | # api.py, mods.py, _mpl_imports.py, and __init__.py files to avoid spurious 10 | # unused import errors 11 | # autogenerated __config__.py files 12 | # vendored libraries 13 | max-line-length=88 14 | 15 | ignore = E203, # Whitespace before ':' (black compatibility) 16 | E231, # Missing whitespace after ',', ';', or ':' 17 | E266, # Too many leading '#' for block comment 18 | E302, # Expected 2 blank lines, found 0 19 | E306, # Expected 1 blank line before a nested definition 20 | E741, # Do not use variables named 'I', 'O', or 'l' 21 | W503, # Line break occurred before a binary operator (black compatibility) 22 | W605, # Invalid escape sequence 'x' 23 | B302, # this is a python 3 compatibility warning, not relevant since don't support python 2 anymore 24 | 25 | jobs=8 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | ipyspaghetti setup 3 | """ 4 | import json 5 | import os 6 | 7 | from jupyter_packaging import ( 8 | create_cmdclass, install_npm, ensure_targets, 9 | combine_commands, skip_if_exists 10 | ) 11 | import setuptools 12 | 13 | HERE = os.path.abspath(os.path.dirname(__file__)) 14 | 15 | # The name of the project 16 | name = "ipyspaghetti" 17 | 18 | # Get our version 19 | with open(os.path.join(HERE, 'package.json')) as f: 20 | version = json.load(f)['version'] 21 | 22 | lab_path = os.path.join(HERE, name, "labextension") 23 | 24 | # Representative files that should exist after a successful build 25 | jstargets = [ 26 | os.path.join(lab_path, "package.json"), 27 | ] 28 | 29 | package_data_spec = { 30 | name: [ 31 | "*" 32 | ] 33 | } 34 | 35 | labext_name = "ipyspaghetti" 36 | 37 | data_files_spec = [ 38 | ("share/jupyter/labextensions/%s" % labext_name, lab_path, "**"), 39 | ("share/jupyter/labextensions/%s" % labext_name, HERE, "install.json"), 40 | ("etc/jupyter/jupyter_server_config.d", 41 | "jupyter-config", "ipyspaghetti.json"), 42 | 43 | ] 44 | 45 | cmdclass = create_cmdclass( 46 | "jsdeps", 47 | package_data_spec=package_data_spec, 48 | data_files_spec=data_files_spec 49 | ) 50 | 51 | js_command = combine_commands( 52 | install_npm(HERE, build_cmd="build:prod", npm=["jlpm"]), 53 | ensure_targets(jstargets), 54 | ) 55 | 56 | is_repo = os.path.exists(os.path.join(HERE, ".git")) 57 | if is_repo: 58 | cmdclass["jsdeps"] = js_command 59 | else: 60 | cmdclass["jsdeps"] = skip_if_exists(jstargets, js_command) 61 | 62 | with open("README.md", "r") as fh: 63 | long_description = fh.read() 64 | 65 | setup_args = dict( 66 | name=name, 67 | version=version, 68 | url="https://github.com/cphyc/ipyspaghetti", 69 | author="Corentin Cadiou", 70 | description="Interactive node editor for python", 71 | long_description= long_description, 72 | long_description_content_type="text/markdown", 73 | cmdclass= cmdclass, 74 | packages=setuptools.find_packages(), 75 | install_requires=[ 76 | "jupyterlab~=3.0", 77 | "typing-utils>=0.0.2" 78 | ], 79 | zip_safe=False, 80 | include_package_data=True, 81 | python_requires=">=3.6", 82 | license="BSD-3-Clause", 83 | platforms="Linux, Mac OS X, Windows", 84 | keywords=["Jupyter", "JupyterLab", "JupyterLab3"], 85 | classifiers=[ 86 | "License :: OSI Approved :: BSD License", 87 | "Programming Language :: Python", 88 | "Programming Language :: Python :: 3", 89 | "Programming Language :: Python :: 3.6", 90 | "Programming Language :: Python :: 3.7", 91 | "Programming Language :: Python :: 3.8", 92 | "Framework :: Jupyter", 93 | ], 94 | ) 95 | 96 | 97 | if __name__ == "__main__": 98 | setuptools.setup(**setup_args) 99 | -------------------------------------------------------------------------------- /src/graph.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SerializedLGraphNode, 3 | LiteGraph, 4 | LGraph, 5 | LGraphCanvas, 6 | LGraphNode, 7 | LGraphGroup, 8 | INodeOutputSlot, 9 | INodeInputSlot, 10 | INodeSlot, 11 | LLink 12 | } from 'litegraph.js'; 13 | 14 | import { IExecuteReplyMsg } from '@jupyterlab/services/lib/kernel/messages'; 15 | 16 | import { Panel } from '@lumino/widgets'; 17 | 18 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 19 | 20 | import { CodeCell } from '@jupyterlab/cells'; 21 | 22 | import { IFunctionSchema, INodeSchema, INodeSchemaIO } from './graph_api'; 23 | 24 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 25 | // @ts-ignore 26 | import hash from 'object-hash'; 27 | 28 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 29 | // @ts-ignore 30 | import converter from 'hsl-to-rgb-for-reals'; 31 | 32 | import { GraphAPI } from './graph_api'; 33 | 34 | const PYTHON_NODE = 1; 35 | export interface IOParameters { 36 | name: string; 37 | node_id: number; 38 | socket: number; 39 | data: any; 40 | } 41 | 42 | export interface IExecuteCellOptions { 43 | id: number; 44 | info: IFunctionSchema; 45 | parameters: IOParameters[]; 46 | cell: CodeCell; 47 | } 48 | 49 | export interface INodeCallback { 50 | (id: number, options: IExecuteCellOptions): Promise; 51 | } 52 | 53 | enum NodeState { 54 | CLEAN = 1, 55 | MISSING = 2, 56 | DIRTY = 4, 57 | RUNNING = 8, 58 | ERROR = 16 59 | } 60 | 61 | function configureSocket(id: string, optional: boolean): Partial { 62 | const h = hash(id); 63 | const maxVal = parseInt('f'.repeat(h.length), 16); 64 | const hue = Math.floor((parseInt(h, 16) / maxVal) * 360); 65 | const ret: Partial = { 66 | // eslint-disable-next-line @typescript-eslint/camelcase 67 | color_on: LiteGraph.num2hex(converter(hue, 1, 0.7)), 68 | // eslint-disable-next-line @typescript-eslint/camelcase 69 | color_off: LiteGraph.num2hex(converter(hue, 1, 0.65)) 70 | }; 71 | if (optional) { 72 | ret.shape = LiteGraph.BOX_SHAPE; 73 | } else { 74 | ret.shape = LiteGraph.CARD_SHAPE; 75 | } 76 | return ret; 77 | } 78 | 79 | class PyLGraphNode extends LGraphNode { 80 | mode = LiteGraph.ALWAYS; 81 | 82 | static type: string; 83 | static title: string; 84 | namespace: string; 85 | private schema: IFunctionSchema; 86 | 87 | private graphHandler: GraphHandler; 88 | 89 | constructor( 90 | title: string, 91 | node: IFunctionSchema, 92 | graphHandler: GraphHandler, 93 | namespace: string 94 | ) { 95 | super(title); 96 | 97 | this.schema = node; 98 | this.graphHandler = graphHandler; 99 | this.title = this.schema.name; 100 | this.namespace = namespace; 101 | 102 | for (const [name, infos] of Object.entries(this.schema.inputs)) { 103 | const ntype = this.graphHandler.normalizeType(infos.type); 104 | const extra = this.graphHandler.getSocketConfiguration( 105 | ntype, 106 | infos.optional 107 | ); 108 | this.addInput(name, ntype, extra); 109 | } 110 | 111 | for (const [name, infos] of Object.entries(this.schema.outputs)) { 112 | // TODO: cleaner 113 | const ntype = this.graphHandler.normalizeType(infos.type); 114 | this.addOutput( 115 | name, 116 | ntype, 117 | this.graphHandler.getSocketConfiguration(ntype, infos.optional) 118 | ); 119 | } 120 | this.setState(NodeState.DIRTY); 121 | this.setProperty('count', 0); 122 | this.setProperty('previous_input', []); 123 | this.setProperty('type', PYTHON_NODE); 124 | } 125 | 126 | setProperty(key: string, value: any): void { 127 | // Missing declaration in d.ts file 128 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 129 | // @ts-ignore 130 | super.setProperty(key, value); 131 | } 132 | 133 | setState(state: NodeState): void { 134 | this.setProperty('state', state); 135 | // di 136 | const bgColors = { 137 | 1: 'green', // Clean 138 | 2: '#880000', // Missing 139 | 4: 'purple', // Dirty 140 | 8: 'blue', // Running 141 | 16: '#ff0000' // Error 142 | }; 143 | this.boxcolor = bgColors[state]; 144 | // Redraw canvas 145 | this.setDirtyCanvas(false, true); 146 | } 147 | 148 | /** 149 | * returns whether the node is dirty, can run or is clean 150 | * @method getNodeState 151 | */ 152 | updateNodeState(): NodeState { 153 | let { state } = this.properties; 154 | 155 | // If any input was modified: mark as dirty 156 | // If any input is missing: mark as missing 157 | for (let i = 0; i < this.inputs.length; i++) { 158 | const orig = this.getInputNode(i) as PyLGraphNode; 159 | const input = this.inputs[i]; 160 | 161 | // Missing non-optional input 162 | if (!(this.schema.inputs[input.name].optional || orig)) { 163 | state = NodeState.MISSING; 164 | break; 165 | } 166 | if (!orig) { 167 | continue; 168 | } 169 | 170 | // Check upstream node was updated 171 | const prevInput = this.properties.previous_input[i]; 172 | const newInput = this.getInputData(i); 173 | if (JSON.stringify(prevInput) !== JSON.stringify(newInput)) { 174 | state = NodeState.DIRTY; 175 | } 176 | } 177 | this.setState(state); 178 | return state; 179 | } 180 | 181 | onExecute(): void { 182 | const state = this.updateNodeState(); 183 | if (state !== NodeState.DIRTY) { 184 | for (let iout = 0; iout < this.outputs.length; ++iout) { 185 | const val = this.getOutputData(iout) || 0; 186 | this.setOutputData(iout, val); 187 | } 188 | return; 189 | } 190 | 191 | this.setState(NodeState.RUNNING); 192 | 193 | this.graphHandler.graphAPI 194 | .executeNode(this.nodeSchema) 195 | .then(async value => { 196 | this.setState(NodeState.CLEAN); 197 | }) 198 | .catch(reason => { 199 | console.error( 200 | `Failed to run node ${this.id}. Failed with reason\n${reason}` 201 | ); 202 | }); 203 | 204 | console.log(`Executing ${this.getTitle()} #${this.id}`); 205 | 206 | // Set previous input data 207 | const inputData = this.inputs.map((_input, index) => { 208 | return this.getInputData(index); 209 | }); 210 | 211 | this.setProperty('previous_input', inputData); 212 | 213 | // We update the output *before* the node has run so that 214 | // nodes downstream also register to run. 215 | for (let iout = 0; iout < this.outputs.length; ++iout) { 216 | const val = this.getOutputData(iout) || 0; 217 | this.setOutputData(iout, val + 1); 218 | } 219 | } 220 | 221 | onRemoved(): void { 222 | this.graphHandler.graphAPI.removeNode(this.nodeSchema); 223 | } 224 | 225 | onAdded(): void { 226 | this.graphHandler.graphAPI.createNode(this.nodeSchema); 227 | } 228 | 229 | onAction(action: string, param: any): void { 230 | console.log(action); 231 | } 232 | 233 | onSelected(): void { 234 | this.graphHandler.graphAPI.selectFunction(this.schema); 235 | this.graphHandler.graphAPI.selectNode(this.nodeSchema); 236 | } 237 | 238 | onDeselected(): void { 239 | this.graphHandler.graphAPI.deselectFunction(); 240 | this.graphHandler.graphAPI.deselectNode(); 241 | } 242 | 243 | onConnectionsChange( 244 | type: number, 245 | slotIndex: number, 246 | isConnected: boolean, 247 | link: LLink, 248 | ioSlot: INodeOutputSlot | INodeInputSlot 249 | ): void { 250 | this.graphHandler.graphAPI.updateNode(this.nodeSchema); 251 | this.updateNodeState(); 252 | } 253 | 254 | onConfigure(o: SerializedLGraphNode): void { 255 | this.setState(NodeState.DIRTY); 256 | } 257 | 258 | /** Return a node schema without building the inputs node schemas */ 259 | buildNodeSchema(depth: number): INodeSchema { 260 | const inputs: { [paramName: string]: INodeSchemaIO } = {}; 261 | this.inputs.forEach((input, islot) => { 262 | const ancestor = this.getInputNode(islot); 263 | const optional = this.schema.inputs[input.name].optional; 264 | if (!ancestor) { 265 | inputs[input.name] = { type: 'value', input: null, optional }; 266 | return; 267 | } 268 | 269 | if (ancestor.properties['type'] === PYTHON_NODE) { 270 | let inputData; 271 | if (depth > 0) { 272 | inputData = (ancestor as PyLGraphNode).buildNodeSchema(depth - 1); 273 | } else { 274 | inputData = this.getInputData(islot); 275 | } 276 | inputs[input.name] = { 277 | type: 'node', 278 | input: inputData, 279 | optional 280 | }; 281 | } else { 282 | const inputData = this.getInputData(islot, true); 283 | inputs[input.name] = { 284 | type: 'value', 285 | input: inputData, 286 | optional 287 | }; 288 | } 289 | }); 290 | 291 | return { 292 | id: this.id, 293 | function: this.schema, 294 | inputs: inputs 295 | }; 296 | } 297 | 298 | onKeyUp(e: KeyboardEvent): void { 299 | switch (e.key) { 300 | case 'Delete': 301 | this.graph.remove(this); 302 | break; 303 | case 'ArrowRight': 304 | this.moveRight(); 305 | break; 306 | case 'ArrowLeft': 307 | this.moveLeft(); 308 | break; 309 | case 'ArrowUp': 310 | this.moveUp(); 311 | break; 312 | case 'ArrowDown': 313 | this.moveDown(); 314 | break; 315 | case 'Enter': 316 | if (e.shiftKey) { 317 | // Spawn new child node 318 | this.graphHandler.spawnNode(this); 319 | } 320 | } 321 | }; 322 | 323 | moveRight(): void { 324 | console.debug('Moving right'); 325 | const allLinks = this.outputs.filter(out => out.links?.length > 0); 326 | if (!allLinks.length) return; 327 | const ilink = allLinks[0].links[0]; 328 | const link = this.graph.links[ilink]; 329 | const node = this.graph.getNodeById(link.target_id); 330 | this.graphHandler.canvas.selectNode(node); 331 | } 332 | 333 | moveLeft(): void { 334 | console.debug('Moving left'); 335 | const allLinks = this.inputs.filter(inp => inp.link); 336 | if (!allLinks.length) return; 337 | const ilink = allLinks[0].link; 338 | const link = this.graph.links[ilink]; 339 | const node = this.graph.getNodeById(link.origin_id); 340 | this.graphHandler.canvas.selectNode(node); 341 | } 342 | 343 | moveUp(): void { 344 | console.log('Moving up'); 345 | } 346 | moveDown(): void { 347 | console.log('Moving down'); 348 | } 349 | 350 | get nodeSchema(): INodeSchema { 351 | return this.buildNodeSchema(1); 352 | } 353 | 354 | get nodeSchemaDeep(): INodeSchema { 355 | return this.buildNodeSchema(99999999); 356 | } 357 | } 358 | 359 | interface INodeCtor { 360 | new (title?: string): PyLGraphNode; 361 | } 362 | 363 | export function nodeFactory( 364 | gh: GraphHandler, 365 | node: IFunctionSchema 366 | ): INodeCtor { 367 | const type = `${node.namespace}/${node.name}`; 368 | class NewNode extends PyLGraphNode { 369 | namespace: string; 370 | constructor(title?: string) { 371 | super(title, node, gh, node.namespace); 372 | } 373 | static graphHandler = gh; 374 | static type = type; 375 | static title = node.name; 376 | } 377 | 378 | LiteGraph.registerNodeType(type, NewNode); 379 | return NewNode; 380 | } 381 | 382 | export class GraphHandler { 383 | private _graph: LGraph; 384 | 385 | private _canvas: LGraphCanvas; 386 | 387 | private socketConfiguration: { [id: string]: Partial }; 388 | 389 | private callbacks: { [id: string]: Array } = { 390 | loaded: [] 391 | }; 392 | 393 | private hasLoaded = false; 394 | 395 | /** The widget in which code cells will be included */ 396 | private _widget: Panel; 397 | 398 | private _rendermime: IRenderMimeRegistry; 399 | 400 | private known_types: { [id: string]: string | null } = { 401 | 'typing.Any': null, 402 | "": 'string', 403 | "": 'number', 404 | "": 'number', 405 | "": 'boolean' 406 | }; 407 | 408 | executeCell: INodeCallback; 409 | private _graphAPI: GraphAPI; 410 | 411 | constructor(id: string, graphAPI: GraphAPI) { 412 | this.setupGraph(); 413 | this.setupCanvas(id); 414 | 415 | this.socketConfiguration = {}; 416 | 417 | this._graphAPI = graphAPI; 418 | } 419 | 420 | setupGraph(): void { 421 | // Empty list of registered node types 422 | // LiteGraph.clearRegisteredTypes() 423 | 424 | // TODO: do not recreate a graph each time the widget is 425 | // detached, simply reattach to a new canvas 426 | this._graph = new LGraph(); 427 | 428 | // Reduce font size for groups 429 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 430 | // @ts-ignore 431 | const prevCtor = LGraphGroup.prototype._ctor; 432 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 433 | // @ts-ignore 434 | LGraphGroup.prototype._ctor = function(title): void { 435 | prevCtor.bind(this)(title); 436 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 437 | // @ts-ignore 438 | // eslint-disable-next-line @typescript-eslint/camelcase 439 | this.font_size = 14; 440 | }; 441 | 442 | // Add custom events 443 | const graph = this._graph; 444 | for (const nodeClass of Object.values(LiteGraph.Nodes)) { 445 | nodeClass.prototype.onKeyUp = function(e: KeyboardEvent): void { 446 | if (e.key === 'Delete') { 447 | graph.remove(this); 448 | } 449 | }; 450 | } 451 | } 452 | 453 | setupCanvas(containerId: string): void { 454 | this._canvas = new LGraphCanvas(containerId, this._graph); 455 | this._canvas.links_render_mode = LiteGraph.LINEAR_LINK; 456 | const font = getComputedStyle(document.documentElement).getPropertyValue( 457 | '--jp-ui-font-family' 458 | ); 459 | // eslint-disable-next-line @typescript-eslint/camelcase 460 | this._canvas.title_text_font = font; 461 | // eslint-disable-next-line @typescript-eslint/camelcase 462 | this._canvas.inner_text_font = font; 463 | } 464 | 465 | createFunction(schema: IFunctionSchema): void { 466 | // TODO: check the schema does not already exist 467 | nodeFactory(this, schema); 468 | } 469 | 470 | normalizeType(type: string): string { 471 | if (type in this.known_types) { 472 | return this.known_types[type]; 473 | } else { 474 | return this.graphAPI.getParentType(type); 475 | } 476 | } 477 | 478 | loadComponents(allNodes: Array): void { 479 | console.log(LiteGraph); 480 | for (const node of Object.values(allNodes)) { 481 | if (node.name in LiteGraph.Nodes) { 482 | // TODO: update schema 483 | // const lgNode = LiteGraph.Nodes[node.name]; 484 | } else { 485 | // New node 486 | nodeFactory(this, node); 487 | } 488 | } 489 | 490 | this.hasLoaded = true; 491 | while (this.callbacks.loaded.length > 0) { 492 | this.callbacks.loaded.pop()(); 493 | } 494 | } 495 | 496 | on(event: string, callback: Function): void { 497 | this.callbacks[event].push(callback); 498 | } 499 | 500 | getSocketConfiguration( 501 | socket: string, 502 | optional: boolean 503 | ): Partial { 504 | if (socket in this.socketConfiguration) { 505 | return this.socketConfiguration[socket]; 506 | } 507 | const config = configureSocket(socket, optional); 508 | this.socketConfiguration[socket] = config; 509 | return config; 510 | } 511 | 512 | save(): void { 513 | // TODO 514 | // let data = this.graph.serialize(); 515 | // graph.create(data); 516 | } 517 | 518 | load(name?: string): void { 519 | const loadNow = function(): void { 520 | // TODO 521 | // graph.index().then(reply => { 522 | // this.graph.configure(reply.data); 523 | // }); 524 | }; 525 | if (this.hasLoaded) { 526 | loadNow(); 527 | } else { 528 | this.on('loaded', loadNow); 529 | } 530 | } 531 | 532 | createComponents(data: string): void { 533 | const conf = JSON.parse(data); 534 | this.loadComponents(conf); 535 | } 536 | 537 | loadGraph(data: string): void { 538 | const conf = JSON.parse(data); 539 | this._graph.configure(conf); 540 | } 541 | 542 | spawnNode(node: PyLGraphNode): void { 543 | const inpType = node.outputs[0].type as string; 544 | const schema: IFunctionSchema = { 545 | inputs: { 546 | input1: { 547 | type: inpType, 548 | optional: false 549 | } 550 | }, 551 | outputs: {}, 552 | name: 'new node', 553 | source: `@register_node\ndef new_node(input1: ${node.outputs[0].type}) -> None:\npass`, 554 | namespace: node.namespace 555 | }; 556 | 557 | const NodeClass = nodeFactory(this, schema); 558 | 559 | NodeClass.prototype.onConnectInput = function( 560 | inputIndex: number, 561 | outputType: string | -1, 562 | outputSlot: INodeOutputSlot, 563 | outputNode: LGraphNode, 564 | outputIndex: number 565 | ): boolean { 566 | // Count number of unconnected input sockets 567 | this.addInput('prout', null); 568 | this.schema.inputs['prout'] = { 569 | type: 'typing.Any', 570 | optional: true 571 | } 572 | return true; 573 | }; 574 | 575 | const newNode = new NodeClass('new_node'); 576 | this._graph.add(newNode); 577 | node.connect(0, newNode, 0); 578 | } 579 | 580 | get graph(): LGraph { 581 | return this._graph; 582 | } 583 | 584 | get canvas(): LGraphCanvas { 585 | return this._canvas; 586 | } 587 | 588 | get widget(): Panel { 589 | return this._widget; 590 | } 591 | 592 | get rendermime(): IRenderMimeRegistry { 593 | return this._rendermime; 594 | } 595 | 596 | get graphAPI(): GraphAPI { 597 | return this._graphAPI; 598 | } 599 | } 600 | -------------------------------------------------------------------------------- /src/graph_api.ts: -------------------------------------------------------------------------------- 1 | import { SessionContext } from '@jupyterlab/apputils'; 2 | 3 | import { CodeCell, CodeCellModel } from '@jupyterlab/cells'; 4 | 5 | import { OutputArea, OutputAreaModel } from '@jupyterlab/outputarea'; 6 | 7 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 8 | 9 | import { IExecuteReplyMsg } from '@jupyterlab/services/lib/kernel/messages'; 10 | 11 | import { JSONObject } from '@lumino/coreutils'; 12 | 13 | import { Panel } from '@lumino/widgets'; 14 | 15 | import { Signal, ISignal } from '@lumino/signaling'; 16 | 17 | import { GraphEditor } from './graph_widget'; 18 | 19 | import { OutputAreaInteractRegistry } from './utils'; 20 | 21 | import { nodeFactory } from './graph'; 22 | 23 | import { IMyManager } from './manager'; 24 | 25 | /** Inputs/outputs of functions */ 26 | export interface IFunctionSchemaIO { 27 | type: string; 28 | optional: boolean; 29 | } 30 | 31 | /** Schema of a function */ 32 | export interface IFunctionSchema { 33 | inputs: { [id: string]: IFunctionSchemaIO }; 34 | outputs: { [id: string]: IFunctionSchemaIO }; 35 | name: string; 36 | source: string; 37 | namespace: string; 38 | } 39 | 40 | /** Schema of a graph node cell */ 41 | export interface INodeSchemaIO { 42 | type: 'node' | 'value'; 43 | input: INodeSchema | any; 44 | optional: boolean; 45 | } 46 | export interface INodeSchema { 47 | id: number; 48 | function: IFunctionSchema; 49 | inputs: { [paramName: string]: INodeSchemaIO }; 50 | } 51 | 52 | export const GLOBAL_NAMESPACE_FUNCTION_NAME = 'Global namespace'; 53 | 54 | export const NODE_VIEWER_CLASS = 'jp-node-viewer'; 55 | 56 | export const FUNCTION_EDITOR_CLASS = 'jp-function-editor'; 57 | 58 | // TODO: automatically infer this 59 | const DEFAULT_MIME_TYPE = 'text/x-ipython'; 60 | 61 | export class GraphAPI { 62 | private _graphWidget: GraphEditor; 63 | private _funContainer: Panel; 64 | private _nodeContainer: Panel; 65 | 66 | private _rendermime: IRenderMimeRegistry; 67 | private _sessionContext: SessionContext; 68 | 69 | private _globalCodeCell: FunctionEditor; 70 | private _nodeCodeCell: FunctionEditor; 71 | private _registryOutput: OutputAreaInteractRegistry; 72 | private _graphData: object; 73 | private _typeInheritance: { [from: string]: string }; 74 | 75 | private _currentNode: NodeViewer; 76 | private _currentFunction: FunctionEditor; 77 | 78 | private _currentNodeChanged = new Signal(this); 79 | private _currentFunctionChanged = new Signal(this); 80 | 81 | constructor( 82 | sessionContext: SessionContext, 83 | rendermime: IRenderMimeRegistry, 84 | manager: IMyManager 85 | ) { 86 | this._sessionContext = sessionContext; 87 | this._rendermime = rendermime; 88 | this._currentFunctionChanged.connect((_sender, fun) => { 89 | manager.currentFunction = fun; 90 | manager.currentContext = sessionContext; 91 | }); 92 | this._currentNodeChanged.connect((_sender, node) => { 93 | manager.currentNode = node; 94 | manager.currentContext = sessionContext; 95 | }); 96 | } 97 | 98 | setWidgets( 99 | graphWidget: GraphEditor, 100 | funContainer: Panel, 101 | nodeContainer: Panel 102 | ): void { 103 | this._graphWidget = graphWidget; 104 | this._funContainer = funContainer; 105 | this._nodeContainer = nodeContainer; 106 | const functionEditorFactory = (): FunctionEditor => { 107 | const model = new CodeCellModel({}); 108 | return new FunctionEditor( 109 | { 110 | inputs: {}, 111 | outputs: {}, 112 | name: GLOBAL_NAMESPACE_FUNCTION_NAME, 113 | source: '', 114 | namespace: '' 115 | }, 116 | { 117 | model, 118 | rendermime: this._rendermime 119 | } 120 | ); 121 | }; 122 | this._globalCodeCell = functionEditorFactory(); 123 | this._funContainer.addWidget(this._globalCodeCell); 124 | this._globalCodeCell.show(); 125 | 126 | // To evaluate the nodes 127 | this._nodeCodeCell = functionEditorFactory(); 128 | 129 | this._registryOutput = new OutputAreaInteractRegistry({ 130 | model: new OutputAreaModel({}), 131 | rendermime: this._rendermime 132 | }); 133 | 134 | // Set the current node and function 135 | this.currentFunction = this._globalCodeCell; 136 | } 137 | 138 | /**-------------------------------------------------------- 139 | * Handle interactions with registry 140 | */ 141 | async loadFunctionList(): Promise { 142 | // TODO: less ugly solution! 143 | await OutputArea.execute( 144 | 'from ipyspaghetti.graph import registry; print(registry.get_nodes_as_json())', 145 | this._registryOutput, 146 | this._sessionContext 147 | ); 148 | const nodeSchemas = JSON.parse(this._registryOutput.IOPubStream); 149 | 150 | // Now we extract the node schemas 151 | Object.values(nodeSchemas).forEach(schemaRaw => { 152 | const schema = schemaRaw as IFunctionSchema; 153 | this.createFunction(schema); 154 | this._graphWidget.graphHandler.createFunction(schema); 155 | }); 156 | } 157 | 158 | async loadTypeInheritance(): Promise { 159 | // TODO: less ugly solution! 160 | await OutputArea.execute( 161 | 'from ipyspaghetti.graph import registry; print(registry.get_parent_types_as_json())', 162 | this._registryOutput, 163 | this._sessionContext 164 | ); 165 | this._typeInheritance = JSON.parse(this._registryOutput.IOPubStream); 166 | } 167 | 168 | getParentType(baseType: string): string { 169 | if (baseType in this._typeInheritance) { 170 | return this._typeInheritance[baseType]; 171 | } else { 172 | return baseType; 173 | } 174 | } 175 | 176 | /**-------------------------------------------------------- 177 | * Handle the single global cell 178 | */ 179 | /** Set the value of the global imports */ 180 | setupGlobals(source: string): void { 181 | if (!this._globalCodeCell) { 182 | console.error('Missing global code cell'); 183 | return; 184 | } 185 | console.debug('Setting global code value'); 186 | this._globalCodeCell.model.value.text = source; 187 | } 188 | 189 | /** Execute global imports */ 190 | async executeGlobals(): Promise { 191 | if (!this._globalCodeCell) { 192 | console.error('Missing global code cell'); 193 | return; 194 | } 195 | console.debug('Executing global code value'); 196 | return this._globalCodeCell.execute(this._sessionContext); 197 | } 198 | 199 | setupNodeSource(source: string): void { 200 | if (!this._nodeCodeCell) { 201 | console.error('Missing global node cell'); 202 | return; 203 | } 204 | console.debug('Setting global node value'); 205 | this._nodeCodeCell.model.value.text = source; 206 | } 207 | 208 | /** Execute initial node source */ 209 | async executeNodeSource(): Promise { 210 | if (!this._nodeCodeCell) { 211 | console.error('Missing global node cell'); 212 | return; 213 | } 214 | console.debug('Executing global code value'); 215 | return this._nodeCodeCell.execute(this._sessionContext); 216 | } 217 | 218 | set graphData(graphData: object) { 219 | this._graphData = graphData; 220 | } 221 | 222 | get graphData(): object { 223 | if (!this._graphData) { 224 | return {}; 225 | } else { 226 | return this._graphData; 227 | } 228 | } 229 | 230 | // TODO: should call this every time the graph changes. Use signal? 231 | updateGraphData(): object { 232 | this._graphData = this._graphWidget.graphHandler.graph.serialize(); 233 | return this.graphData; 234 | } 235 | 236 | dataAsString(): string { 237 | const globals = this._globalCodeCell.model.value.text; 238 | 239 | const nodes = this._funContainer.widgets 240 | .filter(w => { 241 | const w2 = w as FunctionEditor; 242 | return w2.schema.name !== GLOBAL_NAMESPACE_FUNCTION_NAME; 243 | }) 244 | .map(w => { 245 | const w2 = w as FunctionEditor; 246 | return w2.model.value.text; 247 | }) 248 | .join('\n\n'); 249 | 250 | // TODO: this should be done automatically whenever the graph changes; leaving this for now. 251 | const graphData = this.updateGraphData(); 252 | const graph = JSON.stringify(graphData, null, 2); 253 | 254 | const data = GraphAPI.buildData({ globals, nodes, graph }); 255 | 256 | return data; 257 | } 258 | 259 | /** Set up the graph */ 260 | setupGraph(): boolean | undefined { 261 | return this._graphWidget.graphHandler.graph.configure(this.graphData); 262 | } 263 | 264 | /**-------------------------------------------------------- 265 | * Handle functions 266 | */ 267 | createFunction(schema: IFunctionSchema): void { 268 | // Create the editor zone 269 | const model = new CodeCellModel({}); 270 | model.mimeType = DEFAULT_MIME_TYPE; 271 | model.value.text = schema.source; 272 | const editor = new FunctionEditor(schema, { 273 | model, 274 | rendermime: this._rendermime 275 | }).initializeState(); 276 | editor.hide(); 277 | this._funContainer.addWidget(editor); 278 | 279 | // TODO: Add the function to the graph 280 | nodeFactory; 281 | this._graphWidget.graphHandler.loadComponents; 282 | } 283 | 284 | updateFunction(schema: IFunctionSchema): void { 285 | // Update the widget schema 286 | this._funContainer.widgets.forEach(w => { 287 | const w2 = w as FunctionEditor; 288 | if (w2.schema.name === schema.name) { 289 | w2.model.value.text = schema.source; 290 | } 291 | }); 292 | 293 | // TODO: Update the function in the graph 294 | } 295 | 296 | removeFunction(schema: IFunctionSchema): void { 297 | this._funContainer.widgets.forEach(w => { 298 | const w2 = w as FunctionEditor; 299 | if (w2.schema.name === schema.name) { 300 | w.dispose(); 301 | } 302 | }); 303 | 304 | // TODO: remove function from the graph 305 | } 306 | 307 | executeFunction(schema: IFunctionSchema): Promise { 308 | const tmp = this._funContainer.widgets.filter(w => { 309 | const w2 = w as FunctionEditor; 310 | return w2.schema.name === schema.name; 311 | }); 312 | 313 | if (tmp.length !== 1) { 314 | throw `Expected 1 widget, got ${tmp.length}.`; 315 | } 316 | const widget = tmp[0] as FunctionEditor; 317 | return widget.execute(this._sessionContext); 318 | // TODO: mark function as correctly executed (or not) 319 | } 320 | 321 | selectFunction(schema: IFunctionSchema): void { 322 | // Reveal the function editor widget 323 | this._funContainer.widgets.forEach(w => { 324 | const w2 = w as FunctionEditor; 325 | if (w2.schema.name === schema.name) { 326 | w.show(); 327 | this.currentFunction = w2; 328 | } else { 329 | w.hide(); 330 | } 331 | }); 332 | } 333 | 334 | deselectFunction(): void { 335 | this._funContainer.widgets.forEach(w => { 336 | const w2 = w as FunctionEditor; 337 | if (w2.schema.name === GLOBAL_NAMESPACE_FUNCTION_NAME) { 338 | w.show(); 339 | this.currentFunction = w2; 340 | } else { 341 | w.hide(); 342 | } 343 | }); 344 | } 345 | 346 | /**-------------------------------------------------------- 347 | * Handle nodes 348 | */ 349 | createNode(schema: INodeSchema): void { 350 | // Create the editor zone 351 | const model = new CodeCellModel({}); 352 | model.mimeType = DEFAULT_MIME_TYPE; 353 | model.value.text = NodeViewer.createCode(schema); 354 | const viewer = new NodeViewer(schema, { 355 | model, 356 | rendermime: this._rendermime 357 | }).initializeState(); 358 | viewer.hide(); 359 | this._nodeContainer.addWidget(viewer); 360 | 361 | // TODO: Add the function to the graph 362 | } 363 | 364 | updateNode(schema: INodeSchema): void { 365 | // Update the widget schema 366 | this._nodeContainer.widgets.forEach(w => { 367 | const w2 = w as NodeViewer; 368 | if (w2.schema.id === schema.id) { 369 | w2.model.value.text = NodeViewer.createCode(schema); 370 | } 371 | }); 372 | 373 | // TODO: Update the cell in the graph 374 | } 375 | 376 | removeNode(schema: INodeSchema): void { 377 | this._nodeContainer.widgets.forEach(w => { 378 | const w2 = w as NodeViewer; 379 | if (w2.schema.id === schema.id) { 380 | w.dispose(); 381 | } 382 | }); 383 | } 384 | 385 | executeNode(schema: INodeSchema): Promise { 386 | const tmp = this._nodeContainer.widgets.filter(w => { 387 | const w2 = w as NodeViewer; 388 | return w2.schema.id === schema.id; 389 | }); 390 | 391 | if (tmp.length !== 1) { 392 | throw `Expected 1 widget, got ${tmp.length}.`; 393 | } 394 | const widget = tmp[0] as NodeViewer; 395 | return widget.execute(this._sessionContext); 396 | 397 | // TODO: mark cell as correctly executed 398 | } 399 | 400 | selectNode(schema: INodeSchema): void { 401 | // this._selectedNode = schema.id; 402 | // Reveal the function editor widget 403 | this._nodeContainer.widgets.forEach(w => { 404 | const w2 = w as NodeViewer; 405 | if (w2.schema.id === schema.id) { 406 | w.show(); 407 | this.currentNode = w2; 408 | } else { 409 | w.hide(); 410 | } 411 | }); 412 | } 413 | 414 | deselectNode(): void { 415 | this._nodeContainer.widgets.forEach(w => w.hide()); 416 | this.currentNode = null; 417 | } 418 | 419 | // Signal for function/cell setting 420 | set currentFunction(fun: FunctionEditor) { 421 | this._currentFunction = fun; 422 | this._currentFunctionChanged.emit(fun); 423 | } 424 | 425 | get currentFunction(): FunctionEditor { 426 | return this._currentFunction; 427 | } 428 | 429 | get currentFunctionChanged(): ISignal { 430 | return this._currentFunctionChanged; 431 | } 432 | 433 | set currentNode(node: NodeViewer) { 434 | this._currentNode = node; 435 | this._currentNodeChanged.emit(node); 436 | } 437 | 438 | get currentNode(): NodeViewer { 439 | return this._currentNode; 440 | } 441 | 442 | get currentNodeChanged(): ISignal { 443 | return this._currentNodeChanged; 444 | } 445 | 446 | } 447 | 448 | export namespace GraphAPI { 449 | export const GLOBALS_MAGIC = '# % IPYS: Globals'; 450 | export const NODES_MAGIC = '# % IPYS: Nodes'; 451 | export const GRAPH_MAGIC = '# % IPYS: Graph'; 452 | export const GRAPH_VARIABLE = '___GRAPH'; 453 | 454 | export interface IGraphDataSchema { 455 | globals: string; 456 | nodes: string; 457 | graph: string; 458 | } 459 | 460 | export function splitData(data: string): IGraphDataSchema { 461 | const globalsStart = data.indexOf(GLOBALS_MAGIC) + GLOBALS_MAGIC.length + 1; 462 | const globalsEnd = data.indexOf(NODES_MAGIC); 463 | const nodesStart = globalsEnd + NODES_MAGIC.length + 1; 464 | const nodesEnd = data.indexOf(GRAPH_MAGIC); 465 | 466 | const magic = `${GRAPH_VARIABLE} = """`; 467 | const graphStart = data.indexOf(magic, nodesEnd) + magic.length; 468 | const graphEnd = data.lastIndexOf('"""'); 469 | const graph = data.substring(graphStart, graphEnd); 470 | 471 | return { 472 | globals: data.substring(globalsStart, globalsEnd), 473 | nodes: data.substring(nodesStart, nodesEnd), 474 | graph: graph 475 | }; 476 | } 477 | 478 | export function buildData(data: IGraphDataSchema): string { 479 | const graph = `${GRAPH_VARIABLE} = """${data.graph}"""`; 480 | return ( 481 | `${GraphAPI.GLOBALS_MAGIC}\n${data.globals.trim()}\n\n\n` + 482 | `${GraphAPI.NODES_MAGIC}\n${data.nodes.trim()}\n\n\n` + 483 | `${GraphAPI.GRAPH_MAGIC}\n${graph}\n` 484 | ); 485 | } 486 | } 487 | 488 | abstract class GenericCodeCell extends CodeCell { 489 | private _schema: T; 490 | 491 | constructor(schema: T, options: CodeCell.IOptions) { 492 | super(options); 493 | this._schema = schema; 494 | 495 | const { editor } = this; 496 | editor.setOption('codeFolding', true); 497 | editor.setOption('lineNumbers', true); 498 | } 499 | 500 | execute( 501 | sessionContext: SessionContext, 502 | metadata?: JSONObject 503 | ): Promise { 504 | return CodeCell.execute(this, sessionContext, metadata); 505 | } 506 | 507 | get schema(): T { 508 | return this._schema; 509 | } 510 | } 511 | 512 | /** Edit a function */ 513 | export class FunctionEditor extends GenericCodeCell { 514 | constructor(schema: IFunctionSchema, options: CodeCell.IOptions) { 515 | super(schema, options); 516 | this.addClass(FUNCTION_EDITOR_CLASS); 517 | } 518 | } 519 | 520 | /** Show a node */ 521 | export class NodeViewer extends GenericCodeCell { 522 | constructor(schema: INodeSchema, options: CodeCell.IOptions) { 523 | super(schema, options); 524 | this.addClass(NODE_VIEWER_CLASS); 525 | this.readOnly = true; 526 | } 527 | } 528 | 529 | export namespace NodeViewer { 530 | export function createCode(schema: INodeSchema): string { 531 | const args = Object.entries(schema.inputs) 532 | .filter(([_paramName, input]) => { 533 | // Discard empty optional values 534 | return !(input.optional && input.input === null); 535 | }) 536 | .map(([paramName, input]) => { 537 | let code = `${paramName}=`; 538 | if (input.type === 'node') { 539 | code += `__out_${(input.input as INodeSchema).id}`; 540 | } else { 541 | const val: any = input.input; 542 | if (typeof val === 'boolean') { 543 | code += val ? 'True' : 'False'; 544 | } else if (val !== null) { 545 | code += JSON.stringify(val); 546 | } else { 547 | code += 'MISSING'; 548 | } 549 | } 550 | return code; 551 | }) 552 | .join(', '); 553 | let code = `__out_${schema.id} = registry.nodes["${schema.function.name}"](${args})\n`; 554 | code += `__out_${schema.id}`; 555 | return code; 556 | } 557 | } 558 | -------------------------------------------------------------------------------- /src/graph_panel.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Toolbar, 3 | SessionContext, 4 | MainAreaWidget, 5 | sessionContextDialogs, 6 | ToolbarButton 7 | } from '@jupyterlab/apputils'; 8 | 9 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 10 | 11 | import { BoxPanel, SplitPanel } from '@lumino/widgets'; 12 | 13 | import { IMyPublicAPI } from './mime'; 14 | 15 | import { GraphEditor } from './graph_widget'; 16 | 17 | import { GraphAPI } from './graph_api'; 18 | 19 | import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; 20 | 21 | import { CodeMirrorMimeTypeService } from '@jupyterlab/codemirror'; 22 | 23 | import { CodeCell } from '@jupyterlab/cells'; 24 | 25 | /** Inputs/outputs of functions */ 26 | export interface IFunctionSchemaIO { 27 | type: string; 28 | optional: boolean; 29 | } 30 | 31 | /** Schema of a function */ 32 | export interface IFunctionSchema { 33 | inputs: { [id: string]: IFunctionSchemaIO }; 34 | outputs: { [id: string]: IFunctionSchemaIO }; 35 | name: string; 36 | source: string; 37 | } 38 | 39 | /** Schema of a graph node cell */ 40 | export interface IGraphNodeSchema { 41 | id: number; 42 | function: IFunctionSchema; 43 | } 44 | 45 | const EDITOR_CLASS_NAME = 'mimerenderer-ipygraph-editor'; 46 | 47 | /** 48 | * The class name added to the extension. 49 | */ 50 | const CLASS_NAME = 'mimerenderer-ipygraph'; 51 | export class GraphEditionPanel extends MainAreaWidget 52 | implements IRenderMime.IRenderer { 53 | private _sessionContext: SessionContext; 54 | private _mimeRendererOptions: IRenderMime.IRendererOptions; 55 | private _graphAPI: GraphAPI; 56 | private _context: DocumentRegistry.Context; 57 | 58 | // private _mimeRendererOptions: IRenderMime.IRendererOptions; 59 | constructor( 60 | api: IMyPublicAPI, 61 | options?: GraphEditionPanel.IOptions, 62 | mimeRendererOptions?: IRenderMime.IRendererOptions 63 | ) { 64 | const { context, ...otherOptions } = options; 65 | const content = new SplitPanel(otherOptions); 66 | super({ content }); 67 | 68 | this._context = context; 69 | const { sessions, kernelspecs } = api.manager.manager; 70 | 71 | const sessionContext = new SessionContext({ 72 | sessionManager: sessions, 73 | specsManager: kernelspecs, 74 | name: 'IPyGraph Kernel' 75 | }); 76 | 77 | // this._mimeType = options.mimeType; 78 | this.addClass(CLASS_NAME); 79 | 80 | // Initialize the API 81 | const { rendermime } = api.manager; 82 | const graphAPI = new GraphAPI(sessionContext, rendermime, api.manager); 83 | 84 | const graphEditor = new GraphEditor(graphAPI); 85 | const functionEditorBox = new BoxPanel({}); 86 | const nodeViewerBox = new BoxPanel({}); 87 | [functionEditorBox, nodeViewerBox].forEach(widget => { 88 | widget.addClass(EDITOR_CLASS_NAME); 89 | }); 90 | 91 | // Attach the widget now that the kernel is ready 92 | graphAPI.setWidgets(graphEditor, functionEditorBox, nodeViewerBox); 93 | 94 | // Setup code box 95 | const codeBox = new SplitPanel({}); 96 | SplitPanel.setStretch(functionEditorBox, 1); 97 | SplitPanel.setStretch(nodeViewerBox, 1); 98 | codeBox.addWidget(functionEditorBox); 99 | codeBox.addWidget(nodeViewerBox); 100 | 101 | // Setup content 102 | SplitPanel.setStretch(graphEditor, 1); 103 | SplitPanel.setStretch(codeBox, 1); 104 | content.addWidget(graphEditor); 105 | content.addWidget(codeBox); 106 | 107 | // Data from file has been red 108 | options.context?.ready.then(() => { 109 | this.loadData(); 110 | }); 111 | 112 | // Change the mime type of cells when the kernel has loaded 113 | const mimeService = new CodeMirrorMimeTypeService(); 114 | sessionContext.kernelChanged.connect(() => { 115 | sessionContext.session?.kernel?.info.then(async info => { 116 | const lang = info.language_info; 117 | const mimeType = mimeService.getMimeTypeByLanguage(lang); 118 | for (const box of [nodeViewerBox, functionEditorBox]) { 119 | box.widgets.forEach(w => ((w as CodeCell).model.mimeType = mimeType)); 120 | } 121 | // Execute globals cell... 122 | await graphAPI.executeGlobals(); 123 | // Execute node code source... 124 | await graphAPI.executeNodeSource(); 125 | // Gather list of loadable nodes... 126 | await graphAPI.loadFunctionList(); 127 | // Gather type inheritance to match sockets... 128 | await graphAPI.loadTypeInheritance(); 129 | // and finally setup the graph 130 | await graphAPI.setupGraph(); 131 | }); 132 | }); 133 | 134 | this._sessionContext = sessionContext; 135 | this._mimeRendererOptions = mimeRendererOptions; 136 | this._graphAPI = graphAPI; 137 | 138 | // Query a kernel 139 | sessionContext 140 | .initialize() 141 | .then(async value => { 142 | if (value) { 143 | await sessionContextDialogs.selectKernel(sessionContext); 144 | } 145 | }) 146 | .catch(reason => { 147 | console.error( 148 | `Failed to initialize the session in ExamplePanel.\n${reason}` 149 | ); 150 | }); 151 | this._sessionContext; 152 | 153 | const runGraph = new ToolbarButton({ 154 | className: 'jp-DebuggerBugButton', 155 | label: 'Run Graph', 156 | onClick: (): void => { 157 | graphEditor?.graphHandler?.graph?.runStep(); 158 | } 159 | }); 160 | this.toolbar.addItem('ipygraph:run-graph', runGraph); 161 | 162 | const save = new ToolbarButton({ 163 | className: 'jp-DebuggerBugButton', 164 | label: 'Save', 165 | onClick: async (): Promise => { 166 | return this.save(); 167 | } 168 | }); 169 | this.toolbar.addItem('ipygraph:save', save); 170 | 171 | populateGraphToolbar(this.toolbar, sessionContext); 172 | } 173 | 174 | protected save(): Promise { 175 | const data = this._graphAPI.dataAsString(); 176 | this._context.model.fromString(data); 177 | return this._context.save(); 178 | } 179 | 180 | protected load(data: string): void { 181 | const { globals, nodes, graph } = GraphAPI.splitData(data); 182 | 183 | this._graphAPI.setupGlobals(globals); 184 | this._graphAPI.setupNodeSource(nodes); 185 | this._graphAPI.graphData = JSON.parse(graph); 186 | } 187 | 188 | /** 189 | * Render data when new widget is created. 190 | */ 191 | async renderModel(model: IRenderMime.IMimeModel): Promise { 192 | const mimeType = this._mimeRendererOptions.mimeType; 193 | const data = model.data[mimeType] as string; 194 | this.load(data); 195 | } 196 | 197 | /** 198 | * Render data when loaded from disk. 199 | */ 200 | protected loadData(): void { 201 | const data = this._context.model.toString(); 202 | this.load(data); 203 | } 204 | } 205 | 206 | function populateGraphToolbar( 207 | toolbar: Toolbar, 208 | sessionContext: SessionContext 209 | ): void { 210 | toolbar.addItem('spacer', Toolbar.createSpacerItem()); 211 | toolbar.addItem('interrupt', Toolbar.createInterruptButton(sessionContext)); 212 | toolbar.addItem('restart', Toolbar.createRestartButton(sessionContext)); 213 | toolbar.addItem('name', Toolbar.createKernelNameItem(sessionContext)); 214 | toolbar.addItem('status', Toolbar.createKernelStatusItem(sessionContext)); 215 | } 216 | 217 | export namespace GraphEditionPanel { 218 | /** 219 | * Instantiation options for CSV widgets. 220 | */ 221 | export interface IOptions extends SplitPanel.IOptions { 222 | /** 223 | * The document context for the CSV being rendered by the widget. 224 | */ 225 | context?: DocumentRegistry.Context; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/graph_widget.tsx: -------------------------------------------------------------------------------- 1 | import { ReactWidget } from '@jupyterlab/apputils'; 2 | 3 | import React, { ReactNode } from 'react'; 4 | 5 | import { GraphHandler } from './graph'; 6 | import { GraphAPI } from './graph_api'; 7 | 8 | interface IGraphComponentProps { 9 | setGraph: (gh: GraphHandler) => void; 10 | width: number; 11 | height: number; 12 | graphId: string; 13 | graphAPI: GraphAPI; 14 | } 15 | 16 | let currentId = 0; 17 | // TODO: for some reason, eslint doesn't notice GraphComponent *is* used. 18 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 19 | class GraphComponent extends React.Component { 20 | constructor(props: IGraphComponentProps) { 21 | super(props); 22 | } 23 | 24 | componentDidMount(): void { 25 | // We need to wait for the element to be added in the DOM before 26 | // initializing the graph. 27 | // TODO: fix 28 | const graph = new GraphHandler( 29 | `#${this.props.graphId}`, 30 | this.props.graphAPI 31 | ); 32 | this.props.setGraph(graph); 33 | } 34 | 35 | render(): ReactNode { 36 | return ( 37 | 42 | ); 43 | } 44 | } 45 | 46 | /**A Lumino widget that displays a the graph as a react component. */ 47 | export class GraphEditor extends ReactWidget { 48 | private _graphId: string; 49 | private _width: number; 50 | private _height: number; 51 | private _graphAPI: GraphAPI; 52 | private _graphHandler: GraphHandler; 53 | 54 | constructor(graphApi: GraphAPI) { 55 | super(); 56 | currentId++; 57 | this._graphId = `graph-${currentId.toString()}`; 58 | this._width = window.outerWidth; 59 | this._height = window.outerHeight; 60 | this._graphAPI = graphApi; 61 | } 62 | 63 | render(): JSX.Element { 64 | return ( 65 | 73 | ); 74 | } 75 | 76 | setGraph = (gh: GraphHandler): void => { 77 | this._graphHandler = gh; 78 | }; 79 | 80 | get graphHandler(): GraphHandler { 81 | return this._graphHandler; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ILayoutRestorer, 3 | JupyterFrontEnd, 4 | JupyterFrontEndPlugin 5 | } from '@jupyterlab/application'; 6 | 7 | import { ICommandPalette, WidgetTracker } from '@jupyterlab/apputils'; 8 | 9 | import { ILauncher } from '@jupyterlab/launcher'; 10 | 11 | import { IMainMenu } from '@jupyterlab/mainmenu'; 12 | 13 | import { ITranslator } from '@jupyterlab/translation'; 14 | 15 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 16 | 17 | import { ICompletionManager } from '@jupyterlab/completer'; 18 | 19 | import { Menu } from '@lumino/widgets'; 20 | 21 | import { MyPublicAPI } from './mime'; 22 | 23 | import { IMyManager, MyManager } from './manager'; 24 | 25 | import { GraphEditionPanel } from './graph_panel'; 26 | 27 | import { IPygViewerFactory } from './widget'; 28 | 29 | /** 30 | * The command IDs used by the console plugin. 31 | */ 32 | namespace CommandIDs { 33 | export const create = 'kernel-output:create'; 34 | export const executeCurrentNode = 'spaghetti:node-execute'; 35 | export const executeCurrentFunction = 'spaghetti:function-execute'; 36 | } 37 | 38 | /** 39 | * Initialization data for the ipyspaghetti extension. 40 | */ 41 | const extension: JupyterFrontEndPlugin = { 42 | id: 'ipyspaghetti:plugin', 43 | autoStart: true, 44 | provides: IMyManager, 45 | optional: [ILauncher], 46 | requires: [ 47 | ICommandPalette, 48 | IMainMenu, 49 | IRenderMimeRegistry, 50 | ITranslator, 51 | ILayoutRestorer, 52 | ICompletionManager 53 | ], 54 | activate 55 | }; 56 | 57 | /** 58 | * Activate the JupyterLab extension. 59 | * 60 | * @param app Jupyter Front End 61 | * @param palette Jupyter Commands Palette 62 | * @param mainMenu Jupyter Menu 63 | * @param rendermime Jupyter Render Mime Registry 64 | * @param translator Jupyter Translator 65 | * @param restorer Jupyter Restorer 66 | * @param completionManager Jupyter Completion Manager 67 | * @param launcher [optional] Jupyter Launcher 68 | */ 69 | function activate( 70 | app: JupyterFrontEnd, 71 | palette: ICommandPalette, 72 | mainMenu: IMainMenu, 73 | rendermime: IRenderMimeRegistry, 74 | translator: ITranslator, 75 | restorer: ILayoutRestorer, 76 | completionManager: ICompletionManager, 77 | launcher: ILauncher | null 78 | ): IMyManager { 79 | console.log('JupyterLab extension ipyspaghetti is activated!'); 80 | 81 | const factory = new IPygViewerFactory({ 82 | name: 'IPygraph viewer', 83 | fileTypes: ['ipygraph', 'text'], 84 | defaultFor: ['ipygraph'], 85 | readOnly: false, 86 | translator 87 | }); 88 | 89 | app.docRegistry.addWidgetFactory(factory); 90 | 91 | const manager = app.serviceManager; 92 | const { commands } = app; 93 | const category = 'Extension Examples'; 94 | const trans = translator.load('jupyterlab'); 95 | // let widget: MainAreaWidget; 96 | const mgr = new MyManager(manager, rendermime, completionManager); 97 | MyPublicAPI.manager = mgr; 98 | 99 | function createGraph(): void { 100 | console.debug('No-op'); 101 | } 102 | 103 | // add menu tab 104 | const exampleMenu = new Menu({ commands }); 105 | exampleMenu.title.label = trans.__('Kernel Output'); 106 | mainMenu.addMenu(exampleMenu); 107 | 108 | // add commands to registry 109 | commands.addCommand(CommandIDs.create, { 110 | label: trans.__('Open the Node Editor Panel'), 111 | caption: trans.__('Open the Node Editor Panel'), 112 | execute: createGraph 113 | }); 114 | 115 | commands.addCommand(CommandIDs.executeCurrentNode, { 116 | label: trans.__('Execute current node'), 117 | caption: trans.__('Execute current node'), 118 | execute: () => { 119 | console.log('Executing current node'); 120 | return mgr.currentNode?.execute(mgr.currentContext); 121 | } 122 | }); 123 | 124 | commands.addCommand(CommandIDs.executeCurrentFunction, { 125 | label: trans.__('Execute current function'), 126 | caption: trans.__('Execute current function'), 127 | execute: () => { 128 | console.log('Executing current function'); 129 | return mgr.currentFunction?.execute(mgr.currentContext); 130 | } 131 | }); 132 | 133 | commands.addKeyBinding({ 134 | command: CommandIDs.executeCurrentNode, 135 | keys: ['Shift Enter'], 136 | selector: '.jp-node-viewer .jp-InputArea-editor' 137 | }); 138 | 139 | commands.addKeyBinding({ 140 | command: CommandIDs.executeCurrentFunction, 141 | keys: ['Shift Enter'], 142 | selector: '.jp-function-editor .jp-InputArea-editor' 143 | }); 144 | 145 | // add items in command palette and menu 146 | [CommandIDs.create].forEach(command => { 147 | palette.addItem({ command, category }); 148 | exampleMenu.addItem({ command }); 149 | }); 150 | 151 | // Add launcher 152 | if (launcher) { 153 | launcher.add({ 154 | command: CommandIDs.create, 155 | category 156 | }); 157 | } 158 | 159 | const tracker = new WidgetTracker({ 160 | namespace: 'ipyspaghetti' 161 | }); 162 | 163 | restorer.restore(tracker, { 164 | command: CommandIDs.create, 165 | name: () => 'ipyspaghetti' 166 | }); 167 | 168 | return MyPublicAPI.manager; 169 | } 170 | 171 | export default extension; 172 | -------------------------------------------------------------------------------- /src/manager.ts: -------------------------------------------------------------------------------- 1 | import { SessionContext } from '@jupyterlab/apputils'; 2 | 3 | import { ICompletionManager } from '@jupyterlab/completer'; 4 | 5 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 6 | 7 | import { ServiceManager } from '@jupyterlab/services'; 8 | 9 | import { Token } from '@lumino/coreutils'; 10 | 11 | import { NodeViewer, FunctionEditor } from './graph_api'; 12 | 13 | export const IMyManager = new Token('node_manager:IMyManager'); 14 | 15 | export interface IMyManager { 16 | manager: ServiceManager.IManager; 17 | rendermime: IRenderMimeRegistry; 18 | completionManager: ICompletionManager; 19 | 20 | currentFunction: FunctionEditor; 21 | currentNode: NodeViewer; 22 | currentContext: SessionContext; 23 | } 24 | 25 | export class MyManager implements IMyManager { 26 | private _currentNode: NodeViewer; 27 | private _currentFunction: FunctionEditor; 28 | currentContext: SessionContext; 29 | 30 | constructor( 31 | manager: ServiceManager.IManager, 32 | rendermime: IRenderMimeRegistry, 33 | completionManager: ICompletionManager 34 | ) { 35 | this._manager = manager; 36 | this._rendermime = rendermime; 37 | this._completionManager = completionManager; 38 | } 39 | 40 | get manager(): ServiceManager.IManager { 41 | return this._manager; 42 | } 43 | 44 | get rendermime(): IRenderMimeRegistry { 45 | return this._rendermime; 46 | } 47 | 48 | get completionManager(): ICompletionManager { 49 | return this._completionManager; 50 | } 51 | 52 | set currentFunction(fun: FunctionEditor) { 53 | this._currentFunction = fun; 54 | } 55 | 56 | get currentFunction(): FunctionEditor { 57 | return this._currentFunction; 58 | } 59 | 60 | set currentNode(node: NodeViewer) { 61 | this._currentNode = node; 62 | } 63 | 64 | get currentNode(): NodeViewer { 65 | return this._currentNode; 66 | } 67 | 68 | private _manager: ServiceManager.IManager; 69 | 70 | private _rendermime: IRenderMimeRegistry; 71 | 72 | private _completionManager: ICompletionManager; 73 | } 74 | -------------------------------------------------------------------------------- /src/mime.ts: -------------------------------------------------------------------------------- 1 | import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; 2 | 3 | import { IMyManager } from './manager'; 4 | 5 | import { GraphEditionPanel } from './graph_panel'; 6 | 7 | /** 8 | * The default mime type for the extension. 9 | */ 10 | const MIME_TYPE = 'application/vnd.ipython.graph+json'; 11 | 12 | export interface IMyPublicAPI { 13 | manager: IMyManager; 14 | } 15 | 16 | /** 17 | * A public API to communicate with the graph mime handler 18 | */ 19 | export const MyPublicAPI: IMyPublicAPI = { 20 | manager: null 21 | }; 22 | 23 | /** 24 | * A mime renderer factory for ipygraph data. 25 | */ 26 | export const rendererFactory: IRenderMime.IRendererFactory = { 27 | safe: true, 28 | mimeTypes: [MIME_TYPE], 29 | createRenderer: (options: IRenderMime.IRendererOptions) => { 30 | return new GraphEditionPanel( 31 | MyPublicAPI, 32 | { orientation: 'vertical' }, 33 | options 34 | ); 35 | } 36 | }; 37 | 38 | /** 39 | * Extension definition. 40 | */ 41 | const extension: IRenderMime.IExtension = { 42 | id: '@ipyspaghetti/mime:plugin', 43 | rendererFactory, 44 | rank: 0, 45 | dataType: 'string', 46 | fileTypes: [ 47 | { 48 | name: 'ipygraph', 49 | mimeTypes: [MIME_TYPE], 50 | extensions: ['.ipyg'] 51 | } 52 | ], 53 | documentWidgetFactoryOptions: { 54 | name: 'IPython Graph Viewer', 55 | primaryFileType: 'ipygraph', 56 | fileTypes: ['ipygraph'], 57 | defaultFor: ['ipygraph'] 58 | } 59 | }; 60 | 61 | export default extension; 62 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { JSONObject } from '@lumino/coreutils'; 2 | 3 | import { OutputArea, SimplifiedOutputArea } from '@jupyterlab/outputarea'; 4 | 5 | import { ISessionContext } from '@jupyterlab/apputils'; 6 | 7 | import { Kernel, KernelMessage } from '@jupyterlab/services'; 8 | 9 | /** 10 | * Execute code on an output area. 11 | */ 12 | export async function execute( 13 | code: string, 14 | output: OutputArea, 15 | sessionContext: ISessionContext, 16 | metadata?: JSONObject 17 | ): Promise { 18 | // Override the default for `stop_on_error`. 19 | let stopOnError = true; 20 | if ( 21 | metadata && 22 | Array.isArray(metadata.tags) && 23 | metadata.tags.indexOf('raises-exception') !== -1 24 | ) { 25 | stopOnError = false; 26 | } 27 | const content: KernelMessage.IExecuteRequestMsg['content'] = { 28 | code, 29 | // eslint-disable-next-line @typescript-eslint/camelcase 30 | stop_on_error: stopOnError 31 | }; 32 | 33 | const kernel = sessionContext.session?.kernel; 34 | if (!kernel) { 35 | throw new Error('Session has no kernel.'); 36 | } 37 | const future = kernel.requestExecute(content, false, metadata); 38 | output.future = future; 39 | return future.done; 40 | } 41 | 42 | export class OutputAreaInteractRegistry extends SimplifiedOutputArea { 43 | private _IOPubStream: string; 44 | 45 | set future( 46 | value: Kernel.IShellFuture< 47 | KernelMessage.IExecuteRequestMsg, 48 | KernelMessage.IExecuteReplyMsg 49 | > 50 | ) { 51 | super.future = value; 52 | const prevOnIOPub = value.onIOPub; 53 | this._IOPubStream = ''; 54 | value.onIOPub = (msg): void => { 55 | const msgType = msg.header.msg_type; 56 | if (msgType === 'stream') { 57 | const ret: any = msg.content; 58 | this._IOPubStream += ret.text; 59 | } 60 | prevOnIOPub(msg); 61 | }; 62 | } 63 | 64 | get IOPubStream(): any { 65 | return this._IOPubStream; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/widget.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ABCWidgetFactory, 3 | DocumentRegistry, 4 | IDocumentWidget, 5 | DocumentWidget 6 | } from '@jupyterlab/docregistry'; 7 | 8 | import { GraphEditionPanel } from './graph_panel'; 9 | 10 | import { MyPublicAPI } from './mime'; 11 | 12 | /** 13 | * A document widget for CSV content widgets. 14 | */ 15 | export class GraphDocument extends DocumentWidget { 16 | constructor(options: DocumentWidget.IOptions) { 17 | super(options); 18 | } 19 | setFragment(fragment: string): void { 20 | console.log('Fragment?', fragment); 21 | } 22 | } 23 | 24 | /** 25 | * A widget factory for IPyg widgets. 26 | */ 27 | export class IPygViewerFactory extends ABCWidgetFactory< 28 | IDocumentWidget 29 | > { 30 | constructor( 31 | options: DocumentRegistry.IWidgetFactoryOptions< 32 | IDocumentWidget 33 | > 34 | ) { 35 | super(options); 36 | } 37 | 38 | /** 39 | * Create a new widget given a context. 40 | */ 41 | protected createNewWidget( 42 | context: DocumentRegistry.Context 43 | ): IDocumentWidget { 44 | const content = new GraphEditionPanel(MyPublicAPI, { 45 | orientation: 'vertical', 46 | context: context 47 | }); 48 | return new GraphDocument({ content, context }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /style/base.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cphyc/ipyspaghetti/5d0559380d16a794fa9dff22e5af5ebbf4dbe0c1/style/base.css -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | @import url('base.css'); 2 | 3 | .jp-graphContainerWidget canvas { 4 | position: absolute; 5 | top: 0; 6 | left: 0; 7 | } 8 | 9 | .jp-node-viewer { 10 | overflow-y: scroll; 11 | } 12 | 13 | .jp-function-editor { 14 | overflow-y: scroll; 15 | } -------------------------------------------------------------------------------- /style/index.js: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | import './litegraph-editor.css'; 3 | import './litegraph.css'; 4 | -------------------------------------------------------------------------------- /style/litegraph-editor.css: -------------------------------------------------------------------------------- 1 | /* this CSS contains only the basic CSS needed to run the app and use it */ 2 | 3 | .lgraphcanvas { 4 | /*cursor: crosshair;*/ 5 | user-select: none; 6 | -moz-user-select: none; 7 | -webkit-user-select: none; 8 | outline: none; 9 | } 10 | 11 | .litegraph.litecontextmenu { 12 | font-family: Tahoma, sans-serif; 13 | position: fixed; 14 | top: 100px; 15 | left: 100px; 16 | min-width: 100px; 17 | color: #aaf; 18 | padding: 0; 19 | box-shadow: 0 0 10px black !important; 20 | background-color: #2e2e2e !important; 21 | z-index: 10; 22 | } 23 | 24 | .litegraph.litecontextmenu.dark { 25 | background-color: #000 !important; 26 | } 27 | 28 | .litegraph.litecontextmenu .litemenu-title img { 29 | margin-top: 2px; 30 | margin-left: 2px; 31 | margin-right: 4px; 32 | } 33 | 34 | .litegraph.litecontextmenu .litemenu-entry { 35 | margin: 2px; 36 | padding: 2px; 37 | } 38 | 39 | .litegraph.litecontextmenu .litemenu-entry.submenu { 40 | background-color: #2e2e2e !important; 41 | } 42 | 43 | .litegraph.litecontextmenu.dark .litemenu-entry.submenu { 44 | background-color: #000 !important; 45 | } 46 | 47 | .litegraph .litemenubar ul { 48 | font-family: Tahoma, sans-serif; 49 | margin: 0; 50 | padding: 0; 51 | } 52 | 53 | .litegraph .litemenubar li { 54 | font-size: 14px; 55 | color: #999; 56 | display: inline-block; 57 | min-width: 50px; 58 | padding-left: 10px; 59 | padding-right: 10px; 60 | user-select: none; 61 | -moz-user-select: none; 62 | -webkit-user-select: none; 63 | cursor: pointer; 64 | } 65 | 66 | .litegraph .litemenubar li:hover { 67 | background-color: #777; 68 | color: #eee; 69 | } 70 | 71 | .litegraph .litegraph .litemenubar-panel { 72 | position: absolute; 73 | top: 5px; 74 | left: 5px; 75 | min-width: 100px; 76 | background-color: #444; 77 | box-shadow: 0 0 3px black; 78 | padding: 4px; 79 | border-bottom: 2px solid #aaf; 80 | z-index: 10; 81 | } 82 | 83 | .litegraph .litemenu-entry, 84 | .litemenu-title { 85 | font-size: 12px; 86 | color: #aaa; 87 | padding: 0 0 0 4px; 88 | margin: 2px; 89 | padding-left: 2px; 90 | -moz-user-select: none; 91 | -webkit-user-select: none; 92 | user-select: none; 93 | cursor: pointer; 94 | } 95 | 96 | .litegraph .litemenu-entry .icon { 97 | display: inline-block; 98 | width: 12px; 99 | height: 12px; 100 | margin: 2px; 101 | vertical-align: top; 102 | } 103 | 104 | .litegraph .litemenu-entry.checked .icon { 105 | background-color: #aaf; 106 | } 107 | 108 | .litegraph .litemenu-entry .more { 109 | float: right; 110 | padding-right: 5px; 111 | } 112 | 113 | .litegraph .litemenu-entry.disabled { 114 | opacity: 0.5; 115 | cursor: default; 116 | } 117 | 118 | .litegraph .litemenu-entry.separator { 119 | display: block; 120 | border-top: 1px solid #333; 121 | border-bottom: 1px solid #666; 122 | width: 100%; 123 | height: 0px; 124 | margin: 3px 0 2px 0; 125 | background-color: transparent; 126 | padding: 0 !important; 127 | cursor: default !important; 128 | } 129 | 130 | .litegraph .litemenu-entry.has_submenu { 131 | border-right: 2px solid cyan; 132 | } 133 | 134 | .litegraph .litemenu-title { 135 | color: #dde; 136 | background-color: #111; 137 | margin: 0; 138 | padding: 2px; 139 | cursor: default; 140 | } 141 | 142 | .litegraph .litemenu-entry:hover:not(.disabled):not(.separator) { 143 | background-color: #444 !important; 144 | color: #eee; 145 | transition: all 0.2s; 146 | } 147 | 148 | .litegraph .litemenu-entry .property_name { 149 | display: inline-block; 150 | text-align: left; 151 | min-width: 80px; 152 | min-height: 1.2em; 153 | } 154 | 155 | .litegraph .litemenu-entry .property_value { 156 | display: inline-block; 157 | background-color: rgba(0, 0, 0, 0.5); 158 | text-align: right; 159 | min-width: 80px; 160 | min-height: 1.2em; 161 | vertical-align: middle; 162 | padding-right: 10px; 163 | } 164 | 165 | .litegraph.litesearchbox { 166 | font-family: Tahoma, sans-serif; 167 | position: absolute; 168 | background-color: rgba(0, 0, 0, 0.5); 169 | padding-top: 4px; 170 | } 171 | 172 | .litegraph.litesearchbox input, 173 | .litegraph.litesearchbox select { 174 | margin-top: 3px; 175 | min-width: 60px; 176 | min-height: 1.5em; 177 | background-color: black; 178 | border: 0; 179 | color: white; 180 | padding-left: 10px; 181 | margin-right: 5px; 182 | } 183 | 184 | .litegraph.litesearchbox .name { 185 | display: inline-block; 186 | min-width: 60px; 187 | min-height: 1.5em; 188 | padding-left: 10px; 189 | } 190 | 191 | .litegraph.litesearchbox .helper { 192 | overflow: auto; 193 | max-height: 200px; 194 | margin-top: 2px; 195 | } 196 | 197 | .litegraph.lite-search-item { 198 | font-family: Tahoma, sans-serif; 199 | background-color: rgba(0, 0, 0, 0.5); 200 | color: white; 201 | padding-top: 2px; 202 | } 203 | 204 | .litegraph.lite-search-item:hover, 205 | .litegraph.lite-search-item.selected { 206 | cursor: pointer; 207 | background-color: white; 208 | color: black; 209 | } 210 | 211 | /* DIALOGs ******/ 212 | 213 | .litegraph .dialog { 214 | position: absolute; 215 | top: 50%; 216 | left: 50%; 217 | margin-top: -150px; 218 | margin-left: -200px; 219 | 220 | background-color: #2A2A2A; 221 | 222 | min-width: 400px; 223 | min-height: 200px; 224 | box-shadow: 0 0 4px #111; 225 | border-radius: 6px; 226 | } 227 | 228 | .litegraph .dialog.settings { 229 | left: 10px; 230 | top: 10px; 231 | height: calc( 100% - 20px ); 232 | margin: auto; 233 | } 234 | 235 | .litegraph .dialog .close { 236 | float: right; 237 | margin: 4px; 238 | margin-right: 10px; 239 | cursor: pointer; 240 | font-size: 1.4em; 241 | } 242 | 243 | .litegraph .dialog .close:hover { 244 | color: white; 245 | } 246 | 247 | .litegraph .dialog .dialog-header { 248 | color: #AAA; 249 | border-bottom: 1px solid #161616; 250 | } 251 | 252 | .litegraph .dialog .dialog-header { height: 40px; } 253 | .litegraph .dialog .dialog-footer { height: 50px; padding: 10px; border-top: 1px solid #1a1a1a;} 254 | 255 | .litegraph .dialog .dialog-header .dialog-title { 256 | font: 20px "Arial"; 257 | margin: 4px; 258 | padding: 4px 10px; 259 | display: inline-block; 260 | } 261 | 262 | .litegraph .dialog .dialog-content { 263 | height: calc(100% - 90px); 264 | width: 100%; 265 | min-height: 100px; 266 | display: inline-block; 267 | color: #AAA; 268 | /*background-color: black;*/ 269 | } 270 | 271 | .litegraph .dialog .dialog-content h3 { 272 | margin: 10px; 273 | } 274 | 275 | .litegraph .dialog .dialog-content .connections { 276 | flex-direction: row; 277 | } 278 | 279 | .litegraph .dialog .dialog-content .connections .connections_side { 280 | width: calc(50% - 5px); 281 | min-height: 100px; 282 | background-color: black; 283 | display: flex; 284 | } 285 | 286 | .litegraph .dialog .node_type { 287 | font-size: 1.2em; 288 | display: block; 289 | margin: 10px; 290 | } 291 | 292 | .litegraph .dialog .node_desc { 293 | opacity: 0.5; 294 | display: block; 295 | margin: 10px; 296 | } 297 | 298 | .litegraph .dialog .separator { 299 | display: block; 300 | width: calc( 100% - 4px ); 301 | height: 1px; 302 | border-top: 1px solid #000; 303 | border-bottom: 1px solid #333; 304 | margin: 10px 2px; 305 | padding: 0; 306 | } 307 | 308 | .litegraph .dialog .property { 309 | margin-bottom: 2px; 310 | padding: 4px; 311 | } 312 | 313 | .litegraph .dialog .property_name { 314 | color: #737373; 315 | display: inline-block; 316 | text-align: left; 317 | vertical-align: top; 318 | width: 120px; 319 | padding-left: 4px; 320 | overflow: hidden; 321 | } 322 | 323 | .litegraph .dialog .property_value { 324 | display: inline-block; 325 | text-align: right; 326 | color: #AAA; 327 | background-color: #1A1A1A; 328 | width: calc( 100% - 122px ); 329 | max-height: 300px; 330 | padding: 4px; 331 | padding-right: 12px; 332 | overflow: hidden; 333 | cursor: pointer; 334 | border-radius: 3px; 335 | } 336 | 337 | .litegraph .dialog .property_value:hover { 338 | color: white; 339 | } 340 | 341 | .litegraph .dialog .property.boolean .property_value { 342 | padding-right: 30px; 343 | } 344 | 345 | .litegraph .dialog .btn { 346 | border-radius: 4px; 347 | padding: 4px 20px; 348 | margin-left: 0px; 349 | background-color: #060606; 350 | color: #8e8e8e; 351 | } 352 | 353 | .litegraph .dialog .btn:hover { 354 | background-color: #111; 355 | color: #FFF; 356 | } 357 | 358 | .litegraph .dialog .btn.delete:hover { 359 | background-color: #F33; 360 | color: black; 361 | } 362 | 363 | .litegraph .subgraph_property { 364 | padding: 4px; 365 | } 366 | 367 | .litegraph .subgraph_property:hover { 368 | background-color: #333; 369 | } 370 | 371 | .litegraph .subgraph_property.extra { 372 | margin-top: 8px; 373 | } 374 | 375 | .litegraph .subgraph_property span.name { 376 | font-size: 1.3em; 377 | padding-left: 4px; 378 | } 379 | 380 | .litegraph .subgraph_property span.type { 381 | opacity: 0.5; 382 | margin-right: 20px; 383 | padding-left: 4px; 384 | } 385 | 386 | .litegraph .subgraph_property span.label { 387 | display: inline-block; 388 | width: 60px; 389 | padding: 0px 10px; 390 | } 391 | 392 | .litegraph .subgraph_property input { 393 | width: 140px; 394 | color: #999; 395 | background-color: #1A1A1A; 396 | border-radius: 4px; 397 | border: 0; 398 | margin-right: 10px; 399 | padding: 4px; 400 | padding-left: 10px; 401 | } 402 | 403 | .litegraph .subgraph_property button { 404 | background-color: #1c1c1c; 405 | color: #aaa; 406 | border: 0; 407 | border-radius: 2px; 408 | padding: 4px 10px; 409 | cursor: pointer; 410 | } 411 | 412 | .litegraph .subgraph_property.extra { 413 | color: #ccc; 414 | } 415 | 416 | .litegraph .subgraph_property.extra input { 417 | background-color: #111; 418 | } 419 | 420 | .litegraph .bullet_icon { 421 | margin-left: 10px; 422 | border-radius: 10px; 423 | width: 12px; 424 | height: 12px; 425 | background-color: #666; 426 | display: inline-block; 427 | margin-top: 2px; 428 | margin-right: 4px; 429 | transition: background-color 0.1s ease 0s; 430 | -moz-transition: background-color 0.1s ease 0s; 431 | } 432 | 433 | .litegraph .bullet_icon:hover { 434 | background-color: #698; 435 | cursor: pointer; 436 | } 437 | 438 | /* OLD */ 439 | 440 | .graphcontextmenu { 441 | padding: 4px; 442 | min-width: 100px; 443 | } 444 | 445 | .graphcontextmenu-title { 446 | color: #dde; 447 | background-color: #222; 448 | margin: 0; 449 | padding: 2px; 450 | cursor: default; 451 | } 452 | 453 | .graphmenu-entry { 454 | box-sizing: border-box; 455 | margin: 2px; 456 | padding-left: 20px; 457 | user-select: none; 458 | -moz-user-select: none; 459 | -webkit-user-select: none; 460 | transition: all linear 0.3s; 461 | } 462 | 463 | .graphmenu-entry.event, 464 | .litemenu-entry.event { 465 | border-left: 8px solid orange; 466 | padding-left: 12px; 467 | } 468 | 469 | .graphmenu-entry.disabled { 470 | opacity: 0.3; 471 | } 472 | 473 | .graphmenu-entry.submenu { 474 | border-right: 2px solid #eee; 475 | } 476 | 477 | .graphmenu-entry:hover { 478 | background-color: #555; 479 | } 480 | 481 | .graphmenu-entry.separator { 482 | background-color: #111; 483 | border-bottom: 1px solid #666; 484 | height: 1px; 485 | width: calc(100% - 20px); 486 | -moz-width: calc(100% - 20px); 487 | -webkit-width: calc(100% - 20px); 488 | } 489 | 490 | .graphmenu-entry .property_name { 491 | display: inline-block; 492 | text-align: left; 493 | min-width: 80px; 494 | min-height: 1.2em; 495 | } 496 | 497 | .graphmenu-entry .property_value, 498 | .litemenu-entry .property_value { 499 | display: inline-block; 500 | background-color: rgba(0, 0, 0, 0.5); 501 | text-align: right; 502 | min-width: 80px; 503 | min-height: 1.2em; 504 | vertical-align: middle; 505 | padding-right: 10px; 506 | } 507 | 508 | .graphdialog { 509 | position: absolute; 510 | top: 10px; 511 | left: 10px; 512 | /*min-height: 2em;*/ 513 | background-color: #333; 514 | font-size: 1.2em; 515 | box-shadow: 0 0 10px black !important; 516 | z-index: 10; 517 | } 518 | 519 | .graphdialog.rounded { 520 | border-radius: 12px; 521 | padding-right: 2px; 522 | } 523 | 524 | .graphdialog .name { 525 | display: inline-block; 526 | min-width: 60px; 527 | min-height: 1.5em; 528 | padding-left: 10px; 529 | } 530 | 531 | .graphdialog input, 532 | .graphdialog textarea, 533 | .graphdialog select { 534 | margin: 3px; 535 | min-width: 60px; 536 | min-height: 1.5em; 537 | background-color: black; 538 | border: 0; 539 | color: white; 540 | padding-left: 10px; 541 | outline: none; 542 | } 543 | 544 | .graphdialog textarea { 545 | min-height: 150px; 546 | } 547 | 548 | .graphdialog button { 549 | margin-top: 3px; 550 | vertical-align: top; 551 | background-color: #999; 552 | border: 0; 553 | } 554 | 555 | .graphdialog button.rounded, 556 | .graphdialog input.rounded { 557 | border-radius: 0 12px 12px 0; 558 | } 559 | 560 | .graphdialog .helper { 561 | overflow: auto; 562 | max-height: 200px; 563 | } 564 | 565 | .graphdialog .help-item { 566 | padding-left: 10px; 567 | } 568 | 569 | .graphdialog .help-item:hover, 570 | .graphdialog .help-item.selected { 571 | cursor: pointer; 572 | background-color: white; 573 | color: black; 574 | } 575 | -------------------------------------------------------------------------------- /style/litegraph.css: -------------------------------------------------------------------------------- 1 | /* this CSS contains only the basic CSS needed to run the app and use it */ 2 | 3 | .lgraphcanvas { 4 | /*cursor: crosshair;*/ 5 | user-select: none; 6 | -moz-user-select: none; 7 | -webkit-user-select: none; 8 | outline: none; 9 | } 10 | 11 | .litegraph.litecontextmenu { 12 | font-family: var(--jp-ui-font-family); 13 | position: fixed; 14 | top: 100px; 15 | left: 100px; 16 | min-width: 100px; 17 | color: #aaf; 18 | padding: 0; 19 | box-shadow: 0 0 10px black !important; 20 | background-color: #2e2e2e !important; 21 | z-index: 10; 22 | } 23 | 24 | .litegraph.litecontextmenu.dark { 25 | background-color: #000 !important; 26 | } 27 | 28 | .litegraph.litecontextmenu .litemenu-title img { 29 | margin-top: 2px; 30 | margin-left: 2px; 31 | margin-right: 4px; 32 | } 33 | 34 | .litegraph.litecontextmenu .litemenu-entry { 35 | margin: 2px; 36 | padding: 2px; 37 | } 38 | 39 | .litegraph.litecontextmenu .litemenu-entry.submenu { 40 | background-color: #2e2e2e !important; 41 | } 42 | 43 | .litegraph.litecontextmenu.dark .litemenu-entry.submenu { 44 | background-color: #000 !important; 45 | } 46 | 47 | .litegraph .litemenubar ul { 48 | font-family: var(--jp-ui-font-family); 49 | margin: 0; 50 | padding: 0; 51 | } 52 | 53 | .litegraph .litemenubar li { 54 | font-size: 14px; 55 | color: #999; 56 | display: inline-block; 57 | min-width: 50px; 58 | padding-left: 10px; 59 | padding-right: 10px; 60 | user-select: none; 61 | -moz-user-select: none; 62 | -webkit-user-select: none; 63 | cursor: pointer; 64 | } 65 | 66 | .litegraph .litemenubar li:hover { 67 | background-color: #777; 68 | color: #eee; 69 | } 70 | 71 | .litegraph .litegraph .litemenubar-panel { 72 | position: absolute; 73 | top: 5px; 74 | left: 5px; 75 | min-width: 100px; 76 | background-color: #444; 77 | box-shadow: 0 0 3px black; 78 | padding: 4px; 79 | border-bottom: 2px solid #aaf; 80 | z-index: 10; 81 | } 82 | 83 | .litegraph .litemenu-entry, 84 | .litemenu-title { 85 | font-size: 12px; 86 | color: #aaa; 87 | padding: 0 0 0 4px; 88 | margin: 2px; 89 | padding-left: 2px; 90 | -moz-user-select: none; 91 | -webkit-user-select: none; 92 | user-select: none; 93 | cursor: pointer; 94 | } 95 | 96 | .litegraph .litemenu-entry .icon { 97 | display: inline-block; 98 | width: 12px; 99 | height: 12px; 100 | margin: 2px; 101 | vertical-align: top; 102 | } 103 | 104 | .litegraph .litemenu-entry.checked .icon { 105 | background-color: #aaf; 106 | } 107 | 108 | .litegraph .litemenu-entry .more { 109 | float: right; 110 | padding-right: 5px; 111 | } 112 | 113 | .litegraph .litemenu-entry.disabled { 114 | opacity: 0.5; 115 | cursor: default; 116 | } 117 | 118 | .litegraph .litemenu-entry.separator { 119 | display: block; 120 | border-top: 1px solid #333; 121 | border-bottom: 1px solid #666; 122 | width: 100%; 123 | height: 0px; 124 | margin: 3px 0 2px 0; 125 | background-color: transparent; 126 | padding: 0 !important; 127 | cursor: default !important; 128 | } 129 | 130 | .litegraph .litemenu-entry.has_submenu { 131 | border-right: 2px solid cyan; 132 | } 133 | 134 | .litegraph .litemenu-title { 135 | color: #dde; 136 | background-color: #111; 137 | margin: 0; 138 | padding: 2px; 139 | cursor: default; 140 | } 141 | 142 | .litegraph .litemenu-entry:hover:not(.disabled):not(.separator) { 143 | background-color: #444 !important; 144 | color: #eee; 145 | transition: all 0.2s; 146 | } 147 | 148 | .litegraph .litemenu-entry .property_name { 149 | display: inline-block; 150 | text-align: left; 151 | min-width: 80px; 152 | min-height: 1.2em; 153 | } 154 | 155 | .litegraph .litemenu-entry .property_value { 156 | display: inline-block; 157 | background-color: rgba(0, 0, 0, 0.5); 158 | text-align: right; 159 | min-width: 80px; 160 | min-height: 1.2em; 161 | vertical-align: middle; 162 | padding-right: 10px; 163 | } 164 | 165 | .litegraph.litesearchbox { 166 | font-family: var(--jp-ui-font-family); 167 | position: absolute; 168 | background-color: rgba(0, 0, 0, 0.5); 169 | padding-top: 4px; 170 | } 171 | 172 | .litegraph.litesearchbox input, 173 | .litegraph.litesearchbox select { 174 | margin-top: 3px; 175 | min-width: 60px; 176 | min-height: 1.5em; 177 | background-color: black; 178 | border: 0; 179 | color: white; 180 | padding-left: 10px; 181 | margin-right: 5px; 182 | } 183 | 184 | .litegraph.litesearchbox .name { 185 | display: inline-block; 186 | min-width: 60px; 187 | min-height: 1.5em; 188 | padding-left: 10px; 189 | } 190 | 191 | .litegraph.litesearchbox .helper { 192 | overflow: auto; 193 | max-height: 200px; 194 | margin-top: 2px; 195 | } 196 | 197 | .litegraph.lite-search-item { 198 | font-family: var(--jp-ui-font-family); 199 | background-color: rgba(0, 0, 0, 0.5); 200 | color: white; 201 | padding-top: 2px; 202 | } 203 | 204 | .litegraph.lite-search-item:hover, 205 | .litegraph.lite-search-item.selected { 206 | cursor: pointer; 207 | background-color: white; 208 | color: black; 209 | } 210 | 211 | /* DIALOGs ******/ 212 | 213 | .litegraph.dialog { 214 | position: absolute; 215 | top: 50%; 216 | left: 50%; 217 | margin-top: -150px; 218 | margin-left: -200px; 219 | 220 | background-color: #2A2A2A; 221 | 222 | min-width: 400px; 223 | min-height: 200px; 224 | box-shadow: 0 0 4px #111; 225 | border-radius: 6px; 226 | } 227 | 228 | .litegraph.dialog.settings { 229 | left: 10px; 230 | top: 10px; 231 | height: calc( 100% - 20px ); 232 | margin: auto; 233 | } 234 | 235 | .litegraph.dialog .close { 236 | float: right; 237 | margin: 4px; 238 | margin-right: 10px; 239 | cursor: pointer; 240 | font-size: 1.4em; 241 | } 242 | 243 | .litegraph.dialog .close:hover { 244 | color: white; 245 | } 246 | 247 | .litegraph.dialog .dialog-header { 248 | color: #AAA; 249 | border-bottom: 1px solid #161616; 250 | } 251 | 252 | .litegraph.dialog .dialog-header { height: 40px; } 253 | .litegraph.dialog .dialog-footer { height: 50px; padding: 10px; border-top: 1px solid #1a1a1a;} 254 | 255 | .litegraph.dialog .dialog-header .dialog-title { 256 | font: 20px "Arial"; 257 | margin: 4px; 258 | padding: 4px 10px; 259 | display: inline-block; 260 | } 261 | 262 | .litegraph.dialog .dialog-content { 263 | height: calc(100% - 90px); 264 | width: 100%; 265 | min-height: 100px; 266 | display: inline-block; 267 | color: #AAA; 268 | /*background-color: black;*/ 269 | } 270 | 271 | .litegraph.dialog .dialog-content h3 { 272 | margin: 10px; 273 | } 274 | 275 | .litegraph.dialog .dialog-content .connections { 276 | flex-direction: row; 277 | } 278 | 279 | .litegraph.dialog .dialog-content .connections .connections_side { 280 | width: calc(50% - 5px); 281 | min-height: 100px; 282 | background-color: black; 283 | display: flex; 284 | } 285 | 286 | .litegraph.dialog .node_type { 287 | font-size: 1.2em; 288 | display: block; 289 | margin: 10px; 290 | } 291 | 292 | .litegraph.dialog .node_desc { 293 | opacity: 0.5; 294 | display: block; 295 | margin: 10px; 296 | } 297 | 298 | .litegraph.dialog .separator { 299 | display: block; 300 | width: calc( 100% - 4px ); 301 | height: 1px; 302 | border-top: 1px solid #000; 303 | border-bottom: 1px solid #333; 304 | margin: 10px 2px; 305 | padding: 0; 306 | } 307 | 308 | .litegraph.dialog .property { 309 | margin-bottom: 2px; 310 | padding: 4px; 311 | } 312 | 313 | .litegraph.dialog .property_name { 314 | color: #737373; 315 | display: inline-block; 316 | text-align: left; 317 | vertical-align: top; 318 | width: 120px; 319 | padding-left: 4px; 320 | overflow: hidden; 321 | } 322 | 323 | .litegraph.dialog .property_value { 324 | display: inline-block; 325 | text-align: right; 326 | color: #AAA; 327 | background-color: #1A1A1A; 328 | width: calc( 100% - 122px ); 329 | max-height: 300px; 330 | padding: 4px; 331 | padding-right: 12px; 332 | overflow: hidden; 333 | cursor: pointer; 334 | border-radius: 3px; 335 | } 336 | 337 | .litegraph.dialog .property_value:hover { 338 | color: white; 339 | } 340 | 341 | .litegraph.dialog .property.boolean .property_value { 342 | padding-right: 30px; 343 | } 344 | 345 | .litegraph.dialog .btn { 346 | border-radius: 4px; 347 | padding: 4px 20px; 348 | margin-left: 0px; 349 | background-color: #060606; 350 | color: #8e8e8e; 351 | } 352 | 353 | .litegraph.dialog .btn:hover { 354 | background-color: #111; 355 | color: #FFF; 356 | } 357 | 358 | .litegraph.dialog .btn.delete:hover { 359 | background-color: #F33; 360 | color: black; 361 | } 362 | 363 | .litegraph .subgraph_property { 364 | padding: 4px; 365 | } 366 | 367 | .litegraph .subgraph_property:hover { 368 | background-color: #333; 369 | } 370 | 371 | .litegraph .subgraph_property.extra { 372 | margin-top: 8px; 373 | } 374 | 375 | .litegraph .subgraph_property span.name { 376 | font-size: 1.3em; 377 | padding-left: 4px; 378 | } 379 | 380 | .litegraph .subgraph_property span.type { 381 | opacity: 0.5; 382 | margin-right: 20px; 383 | padding-left: 4px; 384 | } 385 | 386 | .litegraph .subgraph_property span.label { 387 | display: inline-block; 388 | width: 60px; 389 | padding: 0px 10px; 390 | } 391 | 392 | .litegraph .subgraph_property input { 393 | width: 140px; 394 | color: #999; 395 | background-color: #1A1A1A; 396 | border-radius: 4px; 397 | border: 0; 398 | margin-right: 10px; 399 | padding: 4px; 400 | padding-left: 10px; 401 | } 402 | 403 | .litegraph .subgraph_property button { 404 | background-color: #1c1c1c; 405 | color: #aaa; 406 | border: 0; 407 | border-radius: 2px; 408 | padding: 4px 10px; 409 | cursor: pointer; 410 | } 411 | 412 | .litegraph .subgraph_property.extra { 413 | color: #ccc; 414 | } 415 | 416 | .litegraph .subgraph_property.extra input { 417 | background-color: #111; 418 | } 419 | 420 | .litegraph .bullet_icon { 421 | margin-left: 10px; 422 | border-radius: 10px; 423 | width: 12px; 424 | height: 12px; 425 | background-color: #666; 426 | display: inline-block; 427 | margin-top: 2px; 428 | margin-right: 4px; 429 | transition: background-color 0.1s ease 0s; 430 | -moz-transition: background-color 0.1s ease 0s; 431 | } 432 | 433 | .litegraph .bullet_icon:hover { 434 | background-color: #698; 435 | cursor: pointer; 436 | } 437 | 438 | /* OLD */ 439 | 440 | .graphcontextmenu { 441 | padding: 4px; 442 | min-width: 100px; 443 | } 444 | 445 | .graphcontextmenu-title { 446 | color: #dde; 447 | background-color: #222; 448 | margin: 0; 449 | padding: 2px; 450 | cursor: default; 451 | } 452 | 453 | .graphmenu-entry { 454 | box-sizing: border-box; 455 | margin: 2px; 456 | padding-left: 20px; 457 | user-select: none; 458 | -moz-user-select: none; 459 | -webkit-user-select: none; 460 | transition: all linear 0.3s; 461 | } 462 | 463 | .graphmenu-entry.event, 464 | .litemenu-entry.event { 465 | border-left: 8px solid orange; 466 | padding-left: 12px; 467 | } 468 | 469 | .graphmenu-entry.disabled { 470 | opacity: 0.3; 471 | } 472 | 473 | .graphmenu-entry.submenu { 474 | border-right: 2px solid #eee; 475 | } 476 | 477 | .graphmenu-entry:hover { 478 | background-color: #555; 479 | } 480 | 481 | .graphmenu-entry.separator { 482 | background-color: #111; 483 | border-bottom: 1px solid #666; 484 | height: 1px; 485 | width: calc(100% - 20px); 486 | -moz-width: calc(100% - 20px); 487 | -webkit-width: calc(100% - 20px); 488 | } 489 | 490 | .graphmenu-entry .property_name { 491 | display: inline-block; 492 | text-align: left; 493 | min-width: 80px; 494 | min-height: 1.2em; 495 | } 496 | 497 | .graphmenu-entry .property_value, 498 | .litemenu-entry .property_value { 499 | display: inline-block; 500 | background-color: rgba(0, 0, 0, 0.5); 501 | text-align: right; 502 | min-width: 80px; 503 | min-height: 1.2em; 504 | vertical-align: middle; 505 | padding-right: 10px; 506 | } 507 | 508 | .graphdialog { 509 | position: absolute; 510 | top: 10px; 511 | left: 10px; 512 | /*min-height: 2em;*/ 513 | background-color: #333; 514 | font-size: 1.2em; 515 | box-shadow: 0 0 10px black !important; 516 | z-index: 10; 517 | } 518 | 519 | .graphdialog.rounded { 520 | border-radius: 12px; 521 | padding-right: 2px; 522 | } 523 | 524 | .graphdialog .name { 525 | display: inline-block; 526 | min-width: 60px; 527 | min-height: 1.5em; 528 | padding-left: 10px; 529 | } 530 | 531 | .graphdialog input, 532 | .graphdialog textarea, 533 | .graphdialog select { 534 | margin: 3px; 535 | min-width: 60px; 536 | min-height: 1.5em; 537 | background-color: black; 538 | border: 0; 539 | color: white; 540 | padding-left: 10px; 541 | outline: none; 542 | } 543 | 544 | .graphdialog textarea { 545 | min-height: 150px; 546 | } 547 | 548 | .graphdialog button { 549 | margin-top: 3px; 550 | vertical-align: top; 551 | background-color: #999; 552 | border: 0; 553 | } 554 | 555 | .graphdialog button.rounded, 556 | .graphdialog input.rounded { 557 | border-radius: 0 12px 12px 0; 558 | } 559 | 560 | .graphdialog .helper { 561 | overflow: auto; 562 | max-height: 200px; 563 | } 564 | 565 | .graphdialog .help-item { 566 | padding-left: 10px; 567 | } 568 | 569 | .graphdialog .help-item:hover, 570 | .graphdialog .help-item.selected { 571 | cursor: pointer; 572 | background-color: white; 573 | color: black; 574 | } 575 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "jsx": "react", 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "preserveWatchOutput": true, 15 | "resolveJsonModule": true, 16 | "outDir": "lib", 17 | "rootDir": "src", 18 | "strict": true, 19 | "strictNullChecks": false, 20 | "target": "es2017", 21 | "types": [] 22 | }, 23 | "include": ["src/*"] 24 | } 25 | --------------------------------------------------------------------------------