├── .envrc ├── .gitignore ├── .stylua.toml ├── pyproject.toml ├── rplugin └── python3 │ └── molten │ ├── runtime_state.py │ ├── utils.py │ ├── code_cell.py │ ├── position.py │ ├── info_window.py │ ├── options.py │ ├── jupyter_server_api.py │ ├── save_load.py │ ├── ipynb.py │ ├── runtime.py │ ├── images.py │ ├── outputchunks.py │ ├── moltenbuffer.py │ └── outputbuffer.py ├── lua ├── hl_utils.lua ├── output_window.lua ├── molten │ ├── status │ │ └── init.lua │ └── health.lua ├── remove_comments.lua ├── load_snacks_nvim.lua ├── load_image_nvim.lua ├── prompt.lua └── load_wezterm_nvim.lua ├── .github ├── ISSUE_TEMPLATE │ ├── help.md │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── release-please.yaml ├── flake.nix ├── docs ├── Probably-Too-Quick-Start-Guide.md ├── Initialization.md ├── Toggleterm.md ├── Virtual-Environments.md ├── minimal.lua ├── NixOS.md ├── Advanced-Functionality.md ├── Not-So-Quick-Start-Guide.md ├── Windows.md └── Notebook-Setup.md ├── flake.lock ├── CONTRIBUTING.md └── CHANGELOG.md /.envrc: -------------------------------------------------------------------------------- 1 | use_flake 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | __pycache__/ 4 | .venv/ 5 | .direnv/ 6 | .aider* 7 | -------------------------------------------------------------------------------- /.stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 100 2 | indent_type = "Spaces" 3 | indent_width = 2 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 100 3 | 4 | [tool.pyright] 5 | extraPaths = ["rplugin/python3"] 6 | 7 | [tool.ruff] 8 | line-length = 100 9 | -------------------------------------------------------------------------------- /rplugin/python3/molten/runtime_state.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class RuntimeState(Enum): 5 | STARTING = 0 6 | IDLE = 1 7 | RUNNING = 2 8 | -------------------------------------------------------------------------------- /lua/hl_utils.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | M.set_default_highlights = function(values) 4 | for group, value in pairs(values) do 5 | if vim.fn.hlexists(group) == 0 then 6 | vim.api.nvim_set_hl(0, group, { default = false, link = value }) 7 | end 8 | end 9 | end 10 | 11 | return M 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Help 3 | about: Ask for help with using/configuring/external dependencies 4 | title: "[Help] " 5 | assignees: '' 6 | --- 7 | 8 | please include: 9 | - _what you're trying to do_ 10 | - _what you've tried (if anything)_ 11 | - _questions you'd like answered_ 12 | 13 | If you haven't already, please read the README and browse the `docs/` folder 14 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | name: release-please 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: google-github-actions/release-please-action@v3 17 | with: 18 | release-type: simple 19 | package-name: molten-nvim 20 | -------------------------------------------------------------------------------- /lua/output_window.lua: -------------------------------------------------------------------------------- 1 | 2 | local M = {} 3 | 4 | ---Calculate the y position of the output window 5 | ---@param buf_line number 6 | ---@return number 7 | M.calculate_window_position = function(buf_line) 8 | local win = vim.api.nvim_get_current_win() 9 | local num_lines = vim.fn.line("$") 10 | local pos = vim.fn.screenpos(win, math.min(num_lines, buf_line), 0) 11 | local win_off = vim.fn.getwininfo(win)[1].winrow 12 | 13 | return pos.row - (win_off - 1) 14 | end 15 | 16 | return M 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature Request] " 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please include: 11 | - _A clear and concise description of what feature you'd like, and why_ 12 | - _reasons for this request ("I can't do xyz with this plugin", "it would be cool", etc.)_ 13 | - _alternatives/workarounds that you've tried in order to achieve this or similar functionality_ 14 | -------------------------------------------------------------------------------- /lua/molten/status/init.lua: -------------------------------------------------------------------------------- 1 | 2 | -- Molten status line functions. Just wrappers around vim functions exposed by the python plugin 3 | local M = {} 4 | 5 | ---Display a string when the plugin is initialized, and "" otherwise 6 | ---@return string 7 | M.initialized = function() 8 | return vim.fn.MoltenStatusLineInit() 9 | end 10 | 11 | ---Display the running kernels attached to the current buffer 12 | ---@return string 13 | M.kernels = function() 14 | return vim.fn.MoltenStatusLineKernels(true) 15 | end 16 | 17 | ---Display all running kernels 18 | ---@return string 19 | M.all_kernels = function() 20 | return vim.fn.MoltenStatusLineKernels() 21 | end 22 | 23 | return M 24 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | # I'm very new to nix, so this is probably really ugly. If you want to contribute to improve this, 2 | # please do 3 | { 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = {nixpkgs, flake-utils, ...}: 10 | flake-utils.lib.eachDefaultSystem (system: 11 | let 12 | pkgs = nixpkgs.legacyPackages.${system}; 13 | python = pkgs.python310; 14 | in { 15 | devShells.default = pkgs.mkShell { 16 | packages = with pkgs; [ 17 | (python.withPackages (ps: with ps; [ 18 | numpy 19 | pynvim 20 | jupyter_client 21 | ipykernel 22 | black 23 | pyperclip 24 | pnglatex 25 | cairosvg 26 | plotly 27 | matplotlib 28 | nbformat 29 | svgwrite 30 | sympy 31 | tqdm 32 | ])) 33 | 34 | nodePackages.pyright 35 | ]; 36 | }; 37 | } 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /rplugin/python3/molten/utils.py: -------------------------------------------------------------------------------- 1 | from pynvim import Nvim 2 | 3 | 4 | class MoltenException(Exception): 5 | pass 6 | 7 | 8 | def nvimui(func): # type: ignore 9 | def inner(self, *args, **kwargs): # type: ignore 10 | try: 11 | func(self, *args, **kwargs) 12 | except MoltenException as err: 13 | self.nvim.err_write("[Molten] " + str(err) + "\n") 14 | 15 | return inner 16 | 17 | 18 | def _notify(nvim: Nvim, msg: str, log_level: str) -> None: 19 | lua = f""" 20 | vim.schedule_wrap(function() 21 | vim.notify([[[Molten] {msg}]], vim.log.levels.{log_level}, {{}}) 22 | end)() 23 | """ 24 | nvim.exec_lua(lua) 25 | 26 | 27 | def notify_info(nvim: Nvim, msg: str) -> None: 28 | """Use the vim.notify API to display an info message.""" 29 | _notify(nvim, msg, "INFO") 30 | 31 | 32 | def notify_warn(nvim: Nvim, msg: str) -> None: 33 | """Use the vim.notify API to display a warning message.""" 34 | _notify(nvim, msg, "WARN") 35 | 36 | 37 | def notify_error(nvim: Nvim, msg: str) -> None: 38 | """Use the vim.notify API to display an error message.""" 39 | _notify(nvim, msg, "ERROR") 40 | -------------------------------------------------------------------------------- /lua/remove_comments.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | 4 | ---remove comments from the given string of code using treesitter 5 | ---@param str string code to remove comments from 6 | ---@param lang string language of the code 7 | ---@return string 8 | M.remove_comments = function(str, lang) 9 | local parser = vim.treesitter.get_string_parser(str, lang) 10 | local tree = parser:parse() 11 | if not tree then 12 | -- Return the same string if the parse cannot be found 13 | return str 14 | end 15 | local root = tree[1]:root() 16 | -- create comment query 17 | local query = vim.treesitter.query.parse(lang, [[((comment) @c (#offset! @c 0 0 0 -1))]]) 18 | -- split content lines 19 | local lines = vim.split(str, '\n') 20 | -- iterate over query match metadata 21 | for _, _, metadata in query:iter_matches(root, str, root:start(), root:end_(), {}) do 22 | local region = metadata[1].range 23 | if region then 24 | local line = region[1] + 1 25 | local col_start = region[2] 26 | -- remove comment by extracting the text before 27 | lines[line] = string.sub(lines[line], 1, col_start) 28 | end 29 | end 30 | -- remove blank lines 31 | lines = vim.tbl_filter(function(line) return line ~= '' end, lines) 32 | -- join lines 33 | local result = vim.fn.join(lines, '\n') 34 | return result 35 | end 36 | 37 | return M 38 | -------------------------------------------------------------------------------- /docs/Probably-Too-Quick-Start-Guide.md: -------------------------------------------------------------------------------- 1 | # Too Quick State Guide 2 | 3 | In stark contrast to the other guide, this one will be really quick (I promise). 4 | 5 | ## Install Python deps: 6 | - [`pynvim`](https://github.com/neovim/pynvim) (for the Remote Plugin API) 7 | - [`jupyter_client`](https://github.com/jupyter/jupyter_client) (for interacting with Jupyter) 8 | 9 | Make sure that neovim can find these (refer to [venv guide](./Virtual-Environments.md) if you have 10 | trouble) 11 | 12 | ## Install the plugin (lazy.nvim example) 13 | ```lua 14 | { 15 | "benlubas/molten-nvim", 16 | version = "^1.0.0", -- use version <2.0.0 to avoid breaking changes 17 | build = ":UpdateRemotePlugins", 18 | init = function() 19 | -- this is an example, not a default. Please see the readme for more configuration options 20 | vim.g.molten_output_win_max_height = 12 21 | end, 22 | }, 23 | ``` 24 | 25 | ## Simple usage 26 | 27 | - Make sure you have a jupyter kernel available 28 | - Open a file (ie. python file if you have a python jupyter kernel) 29 | - `:MoltenInit` 30 | - `:MoltenEvaluateLine` 31 | 32 | Congrats! You've run some code with Molten! 33 | 34 | See the README for more information about how to configure and use the plugin. See the [venv 35 | guide](./Virtual-Environments.md) if you don't want to install python packages globally, and see the [not 36 | so quick start guide](./Not-So-Quick-Start-Guide.md) for information about setting up image rendering. 37 | 38 | > [!WARNING] 39 | > Windows users see [the windows page](./Windows.md) 40 | -------------------------------------------------------------------------------- /docs/Initialization.md: -------------------------------------------------------------------------------- 1 | # Initialization 2 | 3 | Two points. The _plugin_ is initialized on the fist action that you take that requires it. This 4 | fetches all of the options that molten defines, and caches their values. From this point onward, 5 | setting `vim.g.molten_...` will not work, and you need to use the `MoltenUpdateOption` function. 6 | 7 | This is different to what I'll call "kernel initialization", where a jupyter kernel is associated to 8 | the current buffer. This is done by the MoltenInit command. 9 | 10 | ## :MoltenInit 11 | 12 | `:MoltenInit ["shared"] [kernel]` is the command that you run if you'd like to "initialize" 13 | a kernel. This associates the kernel to the current buffer. 14 | 15 | When run with no arguments, the command will list kernels that are not running, followed by kernels 16 | that _are_ running in other buffers. The latter are prefixed with the text `(shared)`. Selecting 17 | a kernel that looks like `(shared) python3` is the same as running the command `:MoltenInit shared 18 | python3`. 19 | 20 | ## Auto Initialization 21 | 22 | Some commands require a kernel attached to the buffer to work. Of these commands, some will auto 23 | initialize (prompt the users as if `:MoltenInit` had been called, on selection, will initialize the 24 | kernel, and then after the kernel is initialized, run the original command with the new kernel). 25 | Here's a list of commands that will auto initialize: 26 | 27 | - `MoltenEvaluateLine` 28 | - `MoltenEvaluateVisual` 29 | - `MoltenEvaluateOperator` 30 | - `MoltenEvaluateArgument` 31 | - `MoltenImportOutput` 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[Bug] " 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | _make sure to do the following:_ 10 | _read the README_ 11 | _check existing issues (the `config problem` tag is helpful)_ 12 | _try with the latest version of molten and image.nvim, latest releases and then main/master branches_ 13 | _run `:UpdateRemotePlugins`_ 14 | 15 | - OS: 16 | - NeoVim Version: 17 | - Python Version: 18 | - Python is installed with: 19 | - Health checks 20 | 21 |
22 | `:checkhealth molten` 23 | 24 | _text or screenshot_ 25 | 26 |
27 | 28 |
29 | `:checkhealth provider` (the python parts) 30 | 31 | _text or screenshot_ 32 | 33 |
34 | 35 | ## Description 36 | _A clear and concise description of what the bug is._ 37 | _Screenshots can help explain the problem as well_ 38 | 39 | ## Reproduction Steps 40 | _Steps to reproduce the behavior._ 41 | _ie. open this file, run this code and wait for the output window to open, then do x_ 42 | 43 | _Optionally you can include a minimal config to reproduce the issue. This will help me figure things out much more quickly. You can find a sample minimal config [here](https://github.com/benlubas/molten-nvim/blob/main/docs/minimal.lua). If you include one, please also include the output of `pip freeze` from the python3 host program that you specify in the config._ 44 | 45 | ## Expected Behavior 46 | _A clear and concise description of what you expected to happen._ 47 | 48 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1694529238, 9 | "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1698611440, 24 | "narHash": "sha256-jPjHjrerhYDy3q9+s5EAsuhyhuknNfowY6yt6pjn9pc=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "0cbe9f69c234a7700596e943bfae7ef27a31b735", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /lua/molten/health.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local has_py_mod = function(mod) 4 | vim.cmd("python3 import pkgutil") 5 | vim.cmd("python3 import importlib") 6 | return vim.fn.py3eval("importlib.util.find_spec('" .. mod .. "') is not None") 7 | end 8 | 9 | local py_mod_check = function(mod, pip, required) 10 | if has_py_mod(mod) then 11 | vim.health.ok("Python module " .. pip .. " found") 12 | elseif required then 13 | vim.health.error("Required python module " .. pip .. " not found", "pip install " .. pip) 14 | else 15 | vim.health.warn("Optional python module " .. pip .. " not found", "pip install " .. pip) 16 | end 17 | end 18 | 19 | M.check = function() 20 | vim.health.start("molten-nvim") 21 | 22 | if vim.fn.has("nvim-0.9") == 1 then 23 | vim.health.ok("NeoVim >=0.9") 24 | else 25 | vim.health.error("molten-nvim requires NeoVim >=0.9") 26 | end 27 | 28 | if vim.fn.has("python3") == 0 then 29 | vim.health.error("molten-nvim requires a Python provider to be configured!") 30 | return 31 | end 32 | 33 | vim.cmd("python3 import sys") 34 | if vim.fn.py3eval("sys.version_info.major == 3 and sys.version_info.minor >= 10") then 35 | vim.health.ok("Python >=3.10") 36 | else 37 | vim.health.error("molten-nvim requires Python >=3.10") 38 | end 39 | 40 | py_mod_check("pynvim", "pynvim", true) 41 | py_mod_check("jupyter_client", "jupyter-client", true) 42 | py_mod_check("cairosvg", "cairosvg", false) 43 | py_mod_check("pnglatex", "pnglatex", false) 44 | py_mod_check("plotly", "plotly", false) 45 | py_mod_check("kaleido", "kaleido", false) 46 | py_mod_check("pyperclip", "pyperclip", false) 47 | py_mod_check("nbformat", "nbformat", false) 48 | py_mod_check("PIL", "pillow", false) 49 | end 50 | 51 | return M 52 | -------------------------------------------------------------------------------- /lua/load_snacks_nvim.lua: -------------------------------------------------------------------------------- 1 | -- loads the snacks.nvim plugin and exposes methods to the python remote plugin 2 | local ok, snacks = pcall(require, "snacks") 3 | 4 | if not ok then 5 | vim.api.nvim_echo({ { "[Molten] `snacks.nvim` not found" } }, true, { err = true }) 6 | return 7 | end 8 | 9 | local snacks_api = {} 10 | local images = {} 11 | 12 | snacks_api.from_file = function(path, opts) 13 | opts.opts = { 14 | inline = true, 15 | pos = { opts.y, opts.x }, 16 | -- Control max size by snacks config 17 | -- FIX check if config has been set by user, use default instead 18 | max_width = snacks.config.image.doc and snacks.config.image.doc.max_width or 80, 19 | max_height = snacks.config.image.doc and snacks.config.image.doc.max_height or 40, 20 | } 21 | opts.placement = nil 22 | 23 | images[path] = opts 24 | return path 25 | end 26 | 27 | snacks_api.render = function(identifier) 28 | local img = images[identifier] 29 | 30 | if img.placement == nil then 31 | img.placement = Snacks.image.placement.new(img.buffer, identifier, img.opts) 32 | end 33 | end 34 | 35 | snacks_api.clear = function(identifier) 36 | local img = images[identifier] 37 | if img and img.placement then 38 | img.placement:close() 39 | img.placement = nil 40 | end 41 | end 42 | 43 | snacks_api.clear_all = function() 44 | for _, img in pairs(images) do 45 | snacks_api.clear(img) 46 | end 47 | end 48 | 49 | --- try to estimate actual size based on raw image and snack opts. Actual rendered size is somewhat random but we cannot get it due to it not being available until after render() 50 | snacks_api.image_size = function(identifier) 51 | local img = images[identifier] 52 | local size = 53 | snacks.image.util.fit(identifier, { width = img.opts.max_width, height = img.opts.max_height }) 54 | return size 55 | end 56 | 57 | return { snacks_api = snacks_api } 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are more than welcome. Please keep the following in mind: 4 | 5 | - If your contribution is large, please open an issue to discuss it *before* doing the work. 6 | - Nothing breaking (unless it's necessary) 7 | - The code is old and rather messy in some places 8 | - This is a remote plugin. So it's written mostly in python using `pynvim` (which is not the best 9 | documented thing out there), if you have questions about how to do something, I've probably done it 10 | in this plugin, so just poke around, grep around, and you'll probably find it, or open an issue here 11 | if you still have trouble. 12 | 13 | ## Dev Environment 14 | 15 | This project is configured for the pyright language server and black code formatter. 16 | 17 | ### With nix 18 | 19 | You can make use of the flake.nix in conjunction with direnv to easily build an environment with all 20 | the dependencies. You will need the flakes experimental feature enabled. 21 | 22 | ### Without nix 23 | 24 | You're kinda on your own. I've listed all the python requirements here. you can install them with 25 | pip into a venv. 26 | 27 | ```bash 28 | pip install plotly pnglatex pynvim pyperclip svgwrite sympy tqdm cairosvg ipykernel jupyter_client kaleido matplotlib 29 | ``` 30 | 31 | ## Code Style 32 | 33 | - `snake_case` names everywhere (except for user commands and vim functions) 34 | - Black code formatting, please format your code 35 | - try to avoid introducing new really long functions, there are already too many of those that 36 | I need to refactor 37 | - If you're going to notify the user via the notify api, there are utility functions for that 38 | 39 | ## Testing 40 | 41 | There are no automated tests. Instead, when you've made a change, please test that you haven't 42 | broken any of the examples in the 43 | [test file](https://gist.github.com/benlubas/f145b6fe91a9eed5ee6bee9d3e100466) before you open a PR. 44 | -------------------------------------------------------------------------------- /lua/load_image_nvim.lua: -------------------------------------------------------------------------------- 1 | -- loads the image.nvim plugin and exposes methods to the python remote plugin 2 | local ok, image = pcall(require, "image") 3 | 4 | if not ok then 5 | vim.api.nvim_echo({ { "[Molten] `image.nvim` not found" } }, true, { err = true }) 6 | return 7 | end 8 | 9 | local utils = require("image.utils") 10 | 11 | local image_api = {} 12 | local images = {} 13 | 14 | image_api.from_file = function(path, opts) 15 | if opts.window and opts.window == vim.NIL then 16 | opts.window = nil 17 | end 18 | images[path] = image.from_file(path, opts or {}) 19 | return path 20 | end 21 | 22 | image_api.render = function(identifier, geometry) 23 | geometry = geometry or {} 24 | local img = images[identifier] 25 | 26 | -- a way to render images in windows when only their buffer is set 27 | if img.buffer and not img.window then 28 | local buf_win = vim.fn.getbufinfo(img.buffer)[1].windows 29 | if #buf_win > 0 then 30 | img.window = buf_win[1] 31 | end 32 | end 33 | 34 | -- only render when the window is visible 35 | if not img.window or not vim.api.nvim_win_is_valid(img.window) then 36 | img.window = nil 37 | end 38 | 39 | if img.window then 40 | img:render(geometry) 41 | end 42 | end 43 | 44 | image_api.clear = function(identifier) 45 | images[identifier]:clear() 46 | end 47 | 48 | image_api.clear_all = function() 49 | for _, img in pairs(images) do 50 | img:clear() 51 | end 52 | end 53 | 54 | image_api.move = function(identifier, x, y) 55 | images[identifier]:move(x, y) 56 | end 57 | 58 | ---returns the max height this image can be displayed at considering the image size and user's max 59 | ---width/height settings. Does not consider max width/height percent values. 60 | image_api.image_size = function(identifier) 61 | local img = images[identifier] 62 | local term_size = require("image.utils.term").get_size() 63 | local gopts = img.global_state.options 64 | local true_size = { 65 | width = math.min(img.image_width / term_size.cell_width, gopts.max_width or math.huge), 66 | height = math.min(img.image_height / term_size.cell_height, gopts.max_height or math.huge), 67 | } 68 | local width, height = utils.math.adjust_to_aspect_ratio( 69 | term_size, 70 | img.image_width, 71 | img.image_height, 72 | true_size.width, 73 | true_size.height 74 | ) 75 | return { width = math.ceil(width), height = math.ceil(height) } 76 | end 77 | 78 | return { image_api = image_api } 79 | -------------------------------------------------------------------------------- /rplugin/python3/molten/code_cell.py: -------------------------------------------------------------------------------- 1 | from functools import total_ordering 2 | from typing import List, Union 3 | 4 | from pynvim import Nvim 5 | from molten.position import DynamicPosition, Position 6 | 7 | 8 | @total_ordering 9 | class CodeCell: 10 | nvim: Nvim 11 | begin: Union[Position, DynamicPosition] 12 | end: Union[Position, DynamicPosition] 13 | bufno: int 14 | 15 | def __init__( 16 | self, 17 | nvim: Nvim, 18 | begin: Union[Position, DynamicPosition], 19 | end: Union[Position, DynamicPosition], 20 | ): 21 | self.nvim = nvim 22 | self.begin = begin 23 | self.end = end 24 | assert self.begin.bufno == self.end.bufno 25 | self.bufno = self.begin.bufno 26 | 27 | def __contains__(self, pos: Union[Position, DynamicPosition]) -> bool: 28 | return self.bufno == pos.bufno and self.begin <= pos and pos < self.end 29 | 30 | def __lt__(self, other: "CodeCell") -> bool: 31 | return self.begin < other.begin 32 | 33 | def __gt__(self, other: "CodeCell") -> bool: 34 | return self.begin > other.begin 35 | 36 | def overlaps(self, other: "CodeCell") -> bool: 37 | return self.bufno == other.bufno and self.begin < other.end and other.begin < self.end 38 | 39 | def __str__(self) -> str: 40 | return f"CodeCell({self.begin}, {self.end})" 41 | 42 | def __repr__(self) -> str: 43 | return f"CodeCell(begin={self.begin}, end={self.end})" 44 | 45 | def clear_interface(self, highlight_namespace): 46 | """Clear the highlight of the code cell""" 47 | self.nvim.funcs.nvim_buf_clear_namespace( 48 | self.bufno, 49 | highlight_namespace, 50 | self.begin.lineno, 51 | self.end.lineno + 1, 52 | ) 53 | 54 | def empty(self) -> bool: 55 | return self.end <= self.begin 56 | 57 | def get_text(self, nvim: Nvim) -> str: 58 | assert self.begin.bufno == self.end.bufno 59 | 60 | lines: List[str] = nvim.funcs.nvim_buf_get_lines( 61 | self.bufno, self.begin.lineno, self.end.lineno + 1, False 62 | ) 63 | 64 | if len(lines) == 0: 65 | return "" # apparently this can happen... 66 | if len(lines) == 1: 67 | return lines[0][self.begin.colno : self.end.colno] 68 | else: 69 | return "\n".join( 70 | [lines[0][self.begin.colno :]] + lines[1:-1] + [lines[-1][: self.end.colno]] 71 | ) 72 | -------------------------------------------------------------------------------- /rplugin/python3/molten/position.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pynvim import Nvim 3 | 4 | 5 | class Position: 6 | bufno: int 7 | lineno: int 8 | colno: int 9 | 10 | def __init__(self, bufno: int, lineno: int, colno: int): 11 | self.bufno = bufno 12 | self.lineno = lineno 13 | self.colno = colno 14 | 15 | def __lt__(self, other: "Position") -> bool: 16 | return (self.lineno, self.colno) < (other.lineno, other.colno) 17 | 18 | def __le__(self, other: "Position") -> bool: 19 | return (self.lineno, self.colno) <= (other.lineno, other.colno) 20 | 21 | 22 | class DynamicPosition(Position): 23 | nvim: Nvim 24 | extmark_namespace: int 25 | bufno: int 26 | 27 | extmark_id: int 28 | 29 | def __init__( 30 | self, 31 | nvim: Nvim, 32 | extmark_namespace: int, 33 | bufno: int, 34 | lineno: int, 35 | colno: int, 36 | right_gravity: bool = False, 37 | ): 38 | self.nvim = nvim 39 | self.extmark_namespace = extmark_namespace 40 | 41 | self.bufno = bufno 42 | self.extmark_id = self.nvim.funcs.nvim_buf_set_extmark( 43 | self.bufno, 44 | extmark_namespace, 45 | lineno, 46 | colno, 47 | {"right_gravity": right_gravity, "strict": False}, 48 | ) 49 | 50 | def set_height(self, height: int) -> None: 51 | self.nvim.funcs.nvim_buf_set_extmark( 52 | self.bufno, 53 | self.extmark_namespace, 54 | self.lineno, 55 | self.colno, 56 | {"id": self.extmark_id, "virt_lines": [[("", "Normal")] for _ in range(height)]}, 57 | ) 58 | 59 | def __del__(self) -> None: 60 | # Note, this will not fail if the extmark doesn't exist 61 | self.nvim.funcs.nvim_buf_del_extmark(self.bufno, self.extmark_namespace, self.extmark_id) 62 | 63 | def __str__(self) -> str: 64 | return f"DynamicPosition({self.bufno}, {self.lineno}, {self.colno})" 65 | 66 | def __repr__(self) -> str: 67 | return f"DynamicPosition(bufno={self.bufno}, lineno={self.lineno}, colno={self.colno})" 68 | 69 | def _get_pos(self) -> List[int]: 70 | out = self.nvim.funcs.nvim_buf_get_extmark_by_id( 71 | self.bufno, self.extmark_namespace, self.extmark_id, {} 72 | ) 73 | assert isinstance(out, list) and all(isinstance(x, int) for x in out) 74 | return out 75 | 76 | @property 77 | def lineno(self) -> int: # type: ignore 78 | return self._get_pos()[0] 79 | 80 | @property 81 | def colno(self) -> int: # type: ignore 82 | return self._get_pos()[1] 83 | -------------------------------------------------------------------------------- /lua/prompt.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local format_shared = function(item) 4 | if item[2] then 5 | return "(shared) " .. item[1] 6 | else 7 | return item[1] 8 | end 9 | end 10 | 11 | ---show the MoltenInit prompt with the given kernels 12 | ---started as shared kernels. 13 | ---@param kernels table list of tuples of (str, bool) 14 | ---@param prompt string 15 | M.prompt_init = function(kernels, prompt) 16 | vim.schedule_wrap(function() 17 | vim.ui.select(kernels, { 18 | prompt = prompt, 19 | format_item = format_shared, 20 | }, function(choice) 21 | if choice == nil then 22 | return 23 | end 24 | vim.schedule_wrap(function() 25 | if choice[2] then 26 | vim.cmd("MoltenInit shared " .. choice[1]) 27 | else 28 | vim.cmd("MoltenInit " .. choice[1]) 29 | end 30 | end)() 31 | end) 32 | end)() 33 | end 34 | 35 | ---show the MoltenInit prompt with the given kernels 36 | ---started as shared kernels. 37 | ---@param kernels table
list of tuples of (str, bool) 38 | ---@param prompt string 39 | ---@param command string command, with %k substituted for the selected kernel name 40 | M.prompt_init_and_run = function(kernels, prompt, command) 41 | vim.schedule_wrap(function() 42 | vim.ui.select(kernels, { 43 | prompt = prompt, 44 | format_item = format_shared, 45 | }, function(choice) 46 | if choice == nil then 47 | return 48 | end 49 | if choice[2] then 50 | vim.schedule_wrap(function() 51 | vim.cmd("MoltenInit shared " .. choice[1]) 52 | vim.cmd(command:gsub("%%k", choice[1])) 53 | end)() 54 | else 55 | vim.api.nvim_create_autocmd("User", { 56 | pattern = "MoltenKernelReady", 57 | once = true, 58 | callback = function(e) 59 | vim.cmd(command:gsub("%%k", e.data.kernel_id)) 60 | end, 61 | }) 62 | vim.schedule_wrap(function() 63 | vim.cmd("MoltenInit " .. choice[1]) 64 | end)() 65 | end 66 | end) -- ui select 67 | end)() -- vim.schedule_wrap 68 | end 69 | 70 | ---prompt the user for a kernel, and then run the command with that kernel. %k in the command means 71 | ---the kernel name will be substituted in. 72 | ---@param kernels table list of kernels 73 | ---@param prompt string 74 | ---@param command string command, with %k substituted for the selected kernel name 75 | M.select_and_run = function(kernels, prompt, command) 76 | vim.schedule_wrap(function() 77 | vim.ui.select(kernels, { 78 | prompt = prompt, 79 | }, function(choice) 80 | if choice ~= nil then 81 | vim.schedule_wrap(function() 82 | vim.cmd(command:gsub("%%k", choice)) 83 | end)() 84 | end 85 | end) 86 | end)() 87 | end 88 | 89 | M.prompt_stdin = function(kernel_id, prompt) 90 | vim.ui.input({ prompt = prompt }, function(input) 91 | vim.schedule(function() 92 | vim.fn.MoltenSendStdin(kernel_id, input) 93 | end) 94 | end) 95 | end 96 | 97 | return M 98 | -------------------------------------------------------------------------------- /docs/Toggleterm.md: -------------------------------------------------------------------------------- 1 | # Toggleterm Guide: Integrating Python with Quarto & Molten in Neovim 2 | 3 | https://github.com/benlubas/molten-nvim/assets/14924440/34b66959-0f10-4d74-a15a-e38dfd6aaa0d 4 | 5 | For developers accustomed to using toggleterm with Python3 during development, extending this convenience to a notebook buffer offers enhanced functionality for specific use-cases. This guide demonstrates how to configure a toggleterm in Neovim to interact with a Quarto notebook using the Molten plugin. While the example focuses on a specific setup using Quarto & Molten with the Lazy plugin manager, the principles can be adapted to various setups. 6 | 7 | ## Setting Up a Python3 Toggle Term 8 | 9 | First, let's look at how to set up a standard Python3 toggle term: 10 | 11 | ```lua 12 | local python_term = require("toggleterm.terminal").Terminal:new({ cmd = 'python3', hidden = true, direction = 'float'}) 13 | vim.keymap.set('n', '', function() python_term:toggle() end, { noremap = true, silent = true }) 14 | ``` 15 | 16 | ## Configuring a Neovim Buffer for Quarto Notebooks 17 | 18 | To enable a similar setup for a nvim buffer, a few additional steps are required. We need to launch a new neovim instance, create an empty virtual buffer, insert template code, and initialize Quarto and Molten. 19 | 20 | ```lua 21 | local function markdown_codeblock(language, content) 22 | return '\\`\\`\\`{' .. language .. '}\n' .. content .. '\n\\`\\`\\`' 23 | end 24 | 25 | local quarto_notebook_cmd = 'nvim -c enew -c "set filetype=quarto"' .. 26 | ' -c "norm GO## IPython\nThis is Quarto IPython notebook. Syntax is the same as in markdown\n\n' .. markdown_codeblock('python', '# enter code here\n') .. '"' .. 27 | ' -c "norm Gkk"' .. 28 | -- This line needed because QuartoActivate and MoltenInit commands must be accessible; should be adjusted depending on plugin manager 29 | " -c \"lua require('lazy.core.loader').load({'molten-nvim', 'quarto-nvim'}, {cmd = 'Lazy load'})\"" .. 30 | ' -c "MoltenInit python3" -c QuartoActivate -c startinsert' 31 | ``` 32 | 33 | Breakdown of Commands (passed with `-c`): 34 | 35 | 1. Open a new empty virtual buffer. 36 | 2. Set filetype to quarto 37 | 3. Insert a template code snippet 38 | 4. Adjust the cursor position within the newly created cell. 39 | 5. Ensure Molten and Quarto plugins are loaded 40 | 6. Initialize Molten and Quarto for the current buffer 41 | 7. Switch to insert mode 42 | 43 | Last step slightly optimizes the workflow, allowing you to start typing code in the terminal immediately, similar to a regular IPython instance. 44 | 45 | During the initial launch, there may be a slight delay due to steps 5 and 6. Subsequent uses will be much quicker. 46 | 47 | ## Keymapping for Nested Neovim Instances 48 | 49 | To prevent nested terminals and ensure proper toggling functionality, modify the key mapping for terminal mode: 50 | 51 | ```lua 52 | local molten_term = require("toggleterm.terminal").Terminal:new({ cmd = quarto_notebook_cmd, hidden = true, direction = 'float'}) 53 | vim.keymap.set('n', '', function () molten_term:toggle() end, { noremap = true, silent = true }) 54 | vim.keymap.set('t', '', function () 55 | vim.cmd 'stopinsert' 56 | molten_term:toggle() 57 | end, { noremap = true, silent = true }) 58 | ``` 59 | 60 | With this setup, Ctrl+P will toggle terminal with quarto notebook & molten, preloaded with following template: 61 | 62 | ````markdown 63 | ## IPython 64 | This is Quarto IPython notebook. Syntax is the same as in markdown 65 | 66 | ```{python} 67 | # enter code here 68 | 69 | ``` 70 | ```` 71 | -------------------------------------------------------------------------------- /docs/Virtual-Environments.md: -------------------------------------------------------------------------------- 1 | # Virtual Environments 2 | 3 | Installing python packages globally isn't recommended. Instead, you should install Molten python 4 | dependencies in a virtual environment using [venv](https://docs.python.org/3/library/venv.html). 5 | 6 | The main reason is that working without a virtual environment gets really messy really quickly, and 7 | can just be impossible if you work on multiple python projects that have different dependency 8 | version requirements. If you want to use this plugin with venv, you shouldn't have to add all of 9 | Molten's dependencies to your project's virtual environment, that's what this guide is for. 10 | 11 | To facilitate the creation and activation of 'global' virtual environments, I use a [venv 12 | wrapper](https://gist.github.com/benlubas/5b5e38ae27d9bb8b5c756d8371e238e6). I would definitely 13 | recommend a wrapper script of some kind if you are a python dev. If you're just installing these 14 | deps to use Molten with a non-python kernel, you can skip the wrapper without much worry. 15 | 16 | ## Create a Virtual Environment 17 | 18 | We'll create a virtual environment called `neovim` that will contain all of our Molten (and other 19 | remote plugin dependencies). 20 | 21 | Using the wrapper: 22 | ```bash 23 | mkvenv neovim # create a new venv called neovim 24 | venv neovim # activate the virtual environment 25 | ``` 26 | 27 | Not using the wrapper 28 | ```bash 29 | mkdir ~/.virtualenvs 30 | python -m venv ~/.virtualenvs/neovim # create a new venv 31 | # note, activate is a bash/zsh script, use activate.fish for fish shell 32 | source ~/.virtualenvs/neovim/bin/activate # activate the venv 33 | ``` 34 | 35 | ## Install Dependencies 36 | 37 | Make sure your venv is active (you can test with `echo $VIRTUAL_ENV`) then you can install the 38 | python packages that relate to the types of output you want to render. Remember, `pynvim` and 39 | `jupyter_client` are 100% necessary, everything else is optional. You can see what each package does 40 | in the readme. 41 | 42 | ```bash 43 | pip install pynvim jupyter_client cairosvg plotly kaleido pnglatex pyperclip 44 | ``` 45 | 46 | ## Point Neovim at this Virtual Environment 47 | 48 | add this to your neovim configuration 49 | ```lua 50 | vim.g.python3_host_prog=vim.fn.expand("~/.virtualenvs/neovim/bin/python3") 51 | ``` 52 | 53 | ## Install The Kernel In a Project Virtual Environment 54 | 55 | In your project virtual environment (here named "project_name"), we need to run: 56 | 57 | ```bash 58 | venv project_name # activate the project venv 59 | pip install ipykernel 60 | python -m ipykernel install --user --name project_name 61 | ``` 62 | 63 | Now, launch Neovim with the project venv active. You should be able to run `:MoltenInit 64 | project_name` to start a Kernel for your project virtual environment. 65 | 66 | ### Automatically launch the correct Kernel 67 | 68 | Assuming you followed the steps above, you may now have multiple python kernels all with names that 69 | match their corresponding virtual environment. Calling `:MoltenInit` and selecting an option all the 70 | time is kinda annoying, instead, we can add this mapping, which allows you to automatically 71 | initialize the correct kernel. 72 | 73 | ```lua 74 | vim.keymap.set("n", "ip", function() 75 | local venv = os.getenv("VIRTUAL_ENV") or os.getenv("CONDA_PREFIX") 76 | if venv ~= nil then 77 | -- in the form of /home/benlubas/.virtualenvs/VENV_NAME 78 | venv = string.match(venv, "/.+/(.+)") 79 | vim.cmd(("MoltenInit %s"):format(venv)) 80 | else 81 | vim.cmd("MoltenInit python3") 82 | end 83 | end, { desc = "Initialize Molten for python3", silent = true }) 84 | ``` 85 | -------------------------------------------------------------------------------- /docs/minimal.lua: -------------------------------------------------------------------------------- 1 | -- Example for configuring Neovim to load user-installed installed Lua rocks: 2 | package.path = package.path .. ";" .. vim.fn.expand("$HOME") .. "/.luarocks/share/lua/5.1/?/init.lua" 3 | package.path = package.path .. ";" .. vim.fn.expand("$HOME") .. "/.luarocks/share/lua/5.1/?.lua" 4 | 5 | -- You should specify your python3 path here \/. 6 | vim.g.python3_host_prog = vim.fn.expand("$HOME") .. "/.virtualenvs/neovim/bin/python3" 7 | 8 | local root = vim.fn.fnamemodify("./.repro", ":p") 9 | 10 | -- set stdpaths to use .repro 11 | for _, name in ipairs({ "config", "data", "state", "cache" }) do 12 | vim.env[("XDG_%s_HOME"):format(name:upper())] = root .. "/" .. name 13 | end 14 | 15 | -- bootstrap lazy 16 | local lazypath = root .. "/plugins/lazy.nvim" 17 | if not vim.loop.fs_stat(lazypath) then 18 | vim.fn.system({ 19 | "git", 20 | "clone", 21 | "--filter=blob:none", 22 | "--single-branch", 23 | "https://github.com/folke/lazy.nvim.git", 24 | lazypath, 25 | }) 26 | end 27 | vim.opt.runtimepath:prepend(lazypath) 28 | 29 | -- install plugins 30 | local plugins = { 31 | { 32 | "bluz71/vim-moonfly-colors", 33 | lazy = false, 34 | priority = 1000, 35 | config = function() 36 | vim.cmd.syntax("enable") 37 | vim.cmd.colorscheme("moonfly") 38 | 39 | vim.api.nvim_set_hl(0, "MoltenOutputBorder", { link = "Normal" }) 40 | vim.api.nvim_set_hl(0, "MoltenOutputBorderFail", { link = "MoonflyCrimson" }) 41 | vim.api.nvim_set_hl(0, "MoltenOutputBorderSuccess", { link = "MoonflyBlue" }) 42 | end, 43 | }, 44 | { 45 | "benlubas/molten-nvim", 46 | dependencies = { "3rd/image.nvim" }, 47 | build = ":UpdateRemotePlugins", 48 | init = function() 49 | vim.g.molten_image_provider = "image.nvim" 50 | vim.g.molten_use_border_highlights = true 51 | -- add a few new things 52 | 53 | -- don't change the mappings (unless it's related to your bug) 54 | vim.keymap.set("n", "mi", ":MoltenInit") 55 | vim.keymap.set("n", "e", ":MoltenEvaluateOperator") 56 | vim.keymap.set("n", "rr", ":MoltenReevaluateCell") 57 | vim.keymap.set("v", "r", ":MoltenEvaluateVisualgv") 58 | vim.keymap.set("n", "os", ":noautocmd MoltenEnterOutput") 59 | vim.keymap.set("n", "oh", ":MoltenHideOutput") 60 | vim.keymap.set("n", "md", ":MoltenDelete") 61 | end, 62 | }, 63 | { 64 | "3rd/image.nvim", 65 | opts = { 66 | backend = "kitty", 67 | integrations = {}, 68 | max_width = 100, 69 | max_height = 12, 70 | max_height_window_percentage = math.huge, 71 | max_width_window_percentage = math.huge, 72 | window_overlap_clear_enabled = true, 73 | window_overlap_clear_ft_ignore = { "cmp_menu", "cmp_docs", "" }, 74 | }, 75 | version = "1.1.0", -- or comment out for latest 76 | }, 77 | { 78 | "nvim-treesitter/nvim-treesitter", 79 | build = ":TSUpdate", 80 | config = function() 81 | require("nvim-treesitter.configs").setup({ 82 | ensure_installed = { 83 | "markdown", 84 | "markdown_inline", 85 | "python", 86 | }, 87 | highlight = { 88 | enable = true, 89 | additional_vim_regex_highlighing = false, 90 | }, 91 | }) 92 | end, 93 | }, 94 | -- add any additional plugins here 95 | } 96 | 97 | require("lazy").setup(plugins, { 98 | root = root .. "/plugins", 99 | }) 100 | -------------------------------------------------------------------------------- /lua/load_wezterm_nvim.lua: -------------------------------------------------------------------------------- 1 | -- loads the wezterm.nvim plugin and exposes methods to the python remote plugin 2 | local ok, wezterm = pcall(require, "wezterm") 3 | if not ok then 4 | vim.api.nvim_echo({ { "[Molten] `wezterm.nvim` not found" } }, true, { err = true }) 5 | return 6 | end 7 | 8 | local wezterm_api = {} 9 | 10 | wezterm_api.get_pane_id = function() 11 | local current_pane_id = wezterm.get_current_pane() 12 | return current_pane_id 13 | end 14 | 15 | --- Validate the split direction 16 | --- type function 17 | --- @param direction string the direction to validate 18 | --- @return string validated direction if valid 19 | local validate_split_dir = function(direction) 20 | local accepted_dirs = { "top", "bottom", "left", "right" } 21 | --if direction not in accepted_dirs, return "bottom" else return direction 22 | if not vim.tbl_contains(accepted_dirs, direction) then 23 | vim.notify( 24 | "[Molten] 'molten_split_dir' must be one of 'top', 'bottom', 'left', or 'right', defaulting to 'right'", 25 | vim.log.levels.WARN 26 | ) 27 | return "right" 28 | end 29 | return direction 30 | end 31 | 32 | --- Validate the split size 33 | --- type function 34 | --- @param size number the size to validate 35 | --- @return number validated size if valid 36 | local validate_split_size = function(size) 37 | if size == nil or size < 0 or size > 100 then 38 | vim.notify( 39 | "[Molten] 'molten_split_size' must be a number between 0 and 100, defaulting to a 40% split.", 40 | vim.log.levels.WARN 41 | ) 42 | return 40 43 | end 44 | return size 45 | end 46 | 47 | -- Split the current pane and return the new pane id 48 | --- type function 49 | --- @param initial_pane_id number, the pane id to split 50 | --- @param direction string, direction to split the pane 51 | --- @param size number, size of the new pane 52 | --- @return number image_pane_id the new pane id 53 | wezterm_api.wezterm_molten_init = function(initial_pane_id, direction, size) 54 | direction = "--" .. validate_split_dir(direction) 55 | size = validate_split_size(size) 56 | 57 | wezterm.exec_sync({ "cli", "split-pane", direction, "--percent", tostring(size) }) 58 | wezterm.exec_sync({ "cli", "activate-pane", "--pane-id", tostring(initial_pane_id) }) 59 | local _, image_pane_id = wezterm.exec_sync({ "cli", "get-pane-direction", "Prev" }) 60 | return tonumber(image_pane_id, 10) 61 | end 62 | 63 | -- Send an image to the image pane (terminal split) 64 | --- type function 65 | --- @param path string, path to the image 66 | --- @param image_pane_id number, the pane id of the image pane 67 | --- @param initial_pane_id number, the pane id of the initial pane 68 | --- @return nil 69 | wezterm_api.send_image = function(path, image_pane_id, initial_pane_id) 70 | local placeholder = "wezterm imgcat --tmux-passthru detect %s \r" 71 | local image = string.format(placeholder, path) 72 | wezterm.exec_sync({ "cli", "activate-pane", "--pane-id", tostring(image_pane_id) }) 73 | wezterm.exec_sync({ 74 | "cli", 75 | "send-text", 76 | "--pane-id", 77 | tostring(image_pane_id), 78 | "--no-paste", 79 | image, 80 | }) 81 | wezterm.exec_sync({ "cli", "activate-pane", "--pane-id", tostring(initial_pane_id) }) 82 | end 83 | 84 | -- Close the image pane 85 | --- type function 86 | --- @param image_pane_id number, the pane id of the image pane 87 | --- @return nil 88 | wezterm_api.close_image_pane = function(image_pane_id) 89 | wezterm.exec_sync({ 90 | "cli", 91 | "send-text", 92 | "--pane-id", 93 | tostring(image_pane_id), 94 | "--no-paste", 95 | "wezterm cli kill-pane --pane-id " .. image_pane_id .. "\r", 96 | }) 97 | end 98 | 99 | return { wezterm_api = wezterm_api } 100 | -------------------------------------------------------------------------------- /rplugin/python3/molten/info_window.py: -------------------------------------------------------------------------------- 1 | import math 2 | import jupyter_client 3 | 4 | 5 | def create_info_window(nvim, molten_kernels, buffers, initialized): 6 | buf = nvim.current.buffer.number 7 | info_buf = nvim.api.create_buf(False, True) 8 | kernel_info = jupyter_client.kernelspec.KernelSpecManager().get_all_specs() # type: ignore 9 | 10 | info_buf[0] = " press q or to close this window" 11 | info_buf.api.add_highlight(-1, "Comment", 0, 0, -1) 12 | info_buf.append(["", " Molten Info"]) 13 | info_buf.api.add_highlight(-1, "Title", len(info_buf) - 1, 0, -1) 14 | 15 | # Status 16 | if initialized: 17 | info_buf.append(" Initialized: true") 18 | info_buf.api.add_highlight(-1, "String", len(info_buf) - 1, 14, -1) 19 | else: 20 | info_buf.append(" Initialized: false") 21 | info_buf.api.add_highlight(-1, "Error", len(info_buf) - 1, 14, -1) 22 | 23 | info_buf.append("") 24 | 25 | # Kernel Information 26 | buf_kernels = buffers[buf] if buf in buffers else [] 27 | other_buf_kernels = set(molten_kernels.keys()) - set(map(lambda x: x.kernel_id, buf_kernels)) 28 | other_kernels = set(kernel_info.keys()) - set(molten_kernels.keys()) 29 | 30 | if len(buf_kernels) > 0: 31 | info_buf.append([f" {len(buf_kernels)} active kernel(s), attached to current buffer:", ""]) 32 | for m_kernel in buf_kernels: 33 | running_buffers = map(lambda x: str(x.number), m_kernel.buffers) 34 | running = f"(running, bufnr: [{', '.join(running_buffers)}])" 35 | spec = m_kernel.runtime.kernel_manager.kernel_spec 36 | draw_kernel_info( 37 | info_buf, running, m_kernel.kernel_id, spec.language, spec.argv, spec.resource_dir 38 | ) 39 | 40 | if len(other_buf_kernels) > 0: 41 | info_buf.append( 42 | [f" {len(other_buf_kernels)} active kernels(s), not attached to this buffer:", ""] 43 | ) 44 | for kernel_id in other_buf_kernels: 45 | m_kernel = molten_kernels[kernel_id] 46 | running_buffers = map(lambda x: str(x.number), m_kernel.buffers) 47 | running = f"(running, bufnr: [{', '.join(running_buffers)}])" 48 | spec = m_kernel.runtime.kernel_manager.kernel_spec 49 | draw_kernel_info( 50 | info_buf, running, m_kernel.kernel_id, spec.language, spec.argv, spec.resource_dir 51 | ) 52 | 53 | if len(other_kernels) > 0: 54 | info_buf.append([f" {len(other_kernels)} inactive kernel(s):", ""]) 55 | for kernel, spec in filter(lambda x: x[0] in other_kernels, kernel_info.items()): 56 | draw_kernel_info( 57 | info_buf, 58 | "", 59 | kernel, 60 | spec["spec"]["language"], 61 | spec["spec"]["argv"], 62 | spec["resource_dir"], 63 | ) 64 | 65 | nvim_width = nvim.api.get_option("columns") 66 | nvim_height = nvim.api.get_option("lines") 67 | height = math.floor(nvim_height * 0.75) 68 | width = math.floor(nvim_width * 0.80) 69 | 70 | win_opts = { 71 | "relative": "editor", 72 | "row": nvim_height / 2 - (height / 2), 73 | "col": nvim_width / 2 - (width / 2), 74 | "width": width, 75 | "height": height, 76 | "focusable": True, 77 | "style": "minimal", 78 | } 79 | 80 | # set keymaps 81 | info_buf.api.set_keymap("n", "q", ":q", {"silent": True}) 82 | info_buf.api.set_keymap("n", "", ":q", {"silent": True}) 83 | 84 | # open the window 85 | nvim.api.open_win( 86 | info_buf.number, 87 | True, 88 | win_opts, 89 | ) 90 | 91 | 92 | def draw_kernel_info(buf, running, kernel_name, language, argv, resource_dir): 93 | buf.append(f" Kernel: {kernel_name} {running}") 94 | buf.api.add_highlight(-1, "Title", len(buf) - 1, 8, 9 + len(kernel_name)) 95 | buf.append(f" language: {language}") 96 | buf.api.add_highlight(-1, "LspInfoFiletype", len(buf) - 1, 16, -1) 97 | buf.append(f" cmd: {' '.join(argv)}") 98 | buf.api.add_highlight(-1, "String", len(buf) - 1, 16, -1) 99 | buf.append([f" resource_dir: {resource_dir}", ""]) 100 | -------------------------------------------------------------------------------- /rplugin/python3/molten/options.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from pynvim import Nvim 4 | from typing import Literal, Optional, Union, List 5 | from dataclasses import dataclass 6 | 7 | from molten.utils import notify_error 8 | 9 | 10 | @dataclass 11 | class HL: 12 | border_norm = "MoltenOutputBorder" 13 | border_fail = "MoltenOutputBorderFail" 14 | border_succ = "MoltenOutputBorderSuccess" 15 | win = "MoltenOutputWin" 16 | win_nc = "MoltenOutputWinNC" 17 | foot = "MoltenOutputFooter" 18 | cell = "MoltenCell" 19 | virtual_text = "MoltenVirtualText" 20 | 21 | defaults = { 22 | border_norm: "FloatBorder", 23 | border_succ: border_norm, 24 | border_fail: border_norm, 25 | win: "NormalFloat", 26 | win_nc: win, 27 | foot: "FloatFooter", 28 | cell: "CursorLine", 29 | virtual_text: "Comment", 30 | } 31 | 32 | 33 | class MoltenOptions: 34 | auto_image_popup: bool 35 | auto_init_behavior: str 36 | auto_open_html_in_browser: bool 37 | auto_open_output: bool 38 | cover_empty_lines: bool 39 | cover_lines_starting_with: List[str] 40 | copy_output: bool 41 | enter_output_behavior: str 42 | image_location: str 43 | image_provider: str 44 | limit_output_chars: int 45 | open_cmd: Optional[str] 46 | output_crop_border: bool 47 | output_show_exec_time: bool 48 | output_show_more: bool 49 | output_virt_lines: bool 50 | output_win_border: Union[str, List[str]] 51 | output_win_cover_gutter: bool 52 | output_win_hide_on_leave: bool 53 | output_win_max_height: int 54 | output_win_max_width: int 55 | output_win_style: Optional[str] 56 | output_win_zindex: Optional[str] 57 | save_path: str 58 | split_direction: str | None 59 | split_size: int | None 60 | show_mimetype_debug: bool 61 | tick_rate: int 62 | use_border_highlights: bool 63 | virt_lines_off_by_1: bool 64 | virt_text_max_lines: int 65 | virt_text_output: bool 66 | virt_text_truncate: Literal["top", "bottom"] 67 | wrap_output: bool 68 | nvim: Nvim 69 | hl: HL 70 | floating_window_focus: Literal["top", "bottom"] 71 | 72 | def __init__(self, nvim: Nvim): 73 | self.nvim = nvim 74 | self.hl = HL() 75 | # fmt: off 76 | CONFIG_VARS = [ 77 | ("molten_auto_image_popup", False), 78 | ("molten_auto_init_behavior", "init"), # "raise" or "init" 79 | ("molten_auto_open_html_in_browser", False), 80 | ("molten_auto_open_output", True), 81 | ("molten_cover_empty_lines", False), 82 | ("molten_cover_lines_starting_with", []), 83 | ("molten_copy_output", False), 84 | ("molten_enter_output_behavior", "open_then_enter"), 85 | ("molten_image_location", "both"), # "both", "float", "virt" 86 | ("molten_image_provider", "none"), 87 | ("molten_open_cmd", None), 88 | ("molten_output_crop_border", True), 89 | ("molten_output_show_exec_time", True), 90 | ("molten_output_show_more", False), 91 | ("molten_output_virt_lines", False), 92 | ("molten_output_win_border", [ "", "━", "", "" ]), 93 | ("molten_output_win_cover_gutter", True), 94 | ("molten_limit_output_chars", 1000000), 95 | ("molten_output_win_hide_on_leave", True), 96 | ("molten_output_win_max_height", 999999), 97 | ("molten_output_win_max_width", 999999), 98 | ("molten_output_win_style", False), 99 | ("molten_save_path", os.path.join(nvim.funcs.stdpath("data"), "molten")), 100 | ("molten_split_direction", "right"), 101 | ("molten_split_size", 40), 102 | ("molten_show_mimetype_debug", False), 103 | ("molten_tick_rate", 500), 104 | ("molten_use_border_highlights", False), 105 | ("molten_virt_lines_off_by_1", False), 106 | ("molten_virt_text_max_lines", 12), 107 | ("molten_virt_text_output", False), 108 | ("molten_wrap_output", False), 109 | ("molten_output_win_zindex", 50), 110 | ("molten_virt_text_truncate", "bottom"), 111 | ("molten_floating_window_focus", "top"), 112 | ] 113 | # fmt: on 114 | 115 | for name, default in CONFIG_VARS: 116 | setattr(self, name[7:], nvim.vars.get(name, default)) 117 | 118 | def update_option(self, option: str, value): 119 | if option.startswith("molten_"): 120 | option = option[7:] 121 | if hasattr(self, option): 122 | setattr(self, option, value) 123 | else: 124 | notify_error(self.nvim, f"Invalid option passed to MoltenUpdateOption: {option}") 125 | -------------------------------------------------------------------------------- /docs/NixOS.md: -------------------------------------------------------------------------------- 1 | # NixOS Installation 2 | 3 | There are several ways to get Molten working on NixOS. If you would like to install Molten with nix, 4 | the Home Manager instructions provide an example of how to do that. It's also possible to manage 5 | your neovim plugins with lazy. 6 | 7 | These setups include setup for `image.nvim`. If you don't need image rendering, you can exclude the 8 | lines marked `# for image rendering`. 9 | 10 | ## Nixvim Installation 11 | 12 | If you manage your Neovim plugins through [Nixvim](https://nix-community.github.io/nixvim/), you can easily configure [molten.nvim](https://nix-community.github.io/nixvim/plugins/molten/index.html#molten) by adding it to your setup as shown below: 13 | 14 | ```nix 15 | programs.nixvim = { 16 | plugins.molten = { 17 | enable = true; 18 | 19 | # Configuration settings for molten.nvim. More examples at https://github.com/nix-community/nixvim/blob/main/plugins/by-name/molten/default.nix#L191 20 | settings = { 21 | auto_image_popup = false; 22 | auto_init_behavior = "init"; 23 | auto_open_html_in_browser = false; 24 | auto_open_output = true; 25 | cover_empty_lines = false; 26 | copy_output = false; 27 | enter_output_behavior = "open_then_enter"; 28 | image_provider = "none"; 29 | output_crop_border = true; 30 | output_virt_lines = false; 31 | output_win_border = [ "" "━" "" "" ]; 32 | output_win_hide_on_leave = true; 33 | output_win_max_height = 15; 34 | output_win_max_width = 80; 35 | save_path.__raw = "vim.fn.stdpath('data')..'/molten'"; 36 | tick_rate = 500; 37 | use_border_highlights = false; 38 | limit_output_chars = 10000; 39 | wrap_output = false; 40 | }; 41 | }; 42 | }; 43 | 44 | ``` 45 | 46 | ## NixOS Home Manager Installation 47 | 48 | If you use home manager and have configure Neovim through it, you can set up the dependencies like 49 | so: 50 | 51 | ```nix 52 | # home.nix or wherever you configure neovim 53 | { pkgs, ... }: 54 | # ... other config 55 | programs.neovim = { 56 | # whatever other neovim configuration you have 57 | plugins = with pkgs.vimPlugins; [ 58 | # ... other plugins 59 | image-nvim # for image rendering 60 | molten-nvim 61 | ]; 62 | extraPackages = with pkgs; [ 63 | # ... other packages 64 | imagemagick # for image rendering 65 | ]; 66 | extraLuaPackages = ps: with ps; [ 67 | # ... other lua packages 68 | magick # for image rendering 69 | ]; 70 | extraPython3Packages = ps: with ps; [ 71 | # ... other python packages 72 | pynvim 73 | jupyter-client 74 | cairosvg # for image rendering 75 | pnglatex # for image rendering 76 | plotly # for image rendering 77 | pyperclip 78 | ]; 79 | }; 80 | } 81 | ``` 82 | 83 | There are multiple ways to manage your Lua configuration so follow the instructions for setting up 84 | `Image.nvim` and `molten-nvim` for your specific setup. 85 | 86 | ## Vanilla NixOS + lazy.nvim 87 | 88 | This is an example setup with no home manager, and installing neovim plugins with lazy nvim. You 89 | might want to do this if you want to keep a neovim configuration that works on systems that don't 90 | have nix installed. 91 | 92 | Just create a file and import it into `configuration.nix`. 93 | 94 | ```nix 95 | { lib, pkgs, neovimUtils, wrapNeovimUnstable, ... }: 96 | 97 | let 98 | config = pkgs.neovimUtils.makeNeovimConfig { 99 | extraLuaPackages = p: with p; [ 100 | # ... other lua packages 101 | p.magick # for image rendering 102 | ]; 103 | extraPython3Packages = p: with p; [ 104 | pynvim 105 | jupyter-client 106 | cairosvg # for image rendering 107 | ipython 108 | nbformat 109 | # ... other python packages 110 | ]; 111 | extraPackages = p: with p; [ 112 | imageMagick # for image rendering 113 | # ... other packages 114 | ]; 115 | withNodeJs = true; 116 | withRuby = true; 117 | withPython3 = true; 118 | # https://github.com/NixOS/nixpkgs/issues/211998 119 | customRC = "luafile ~/.config/nvim/init.lua"; 120 | }; 121 | in { 122 | nixpkgs.overlays = [ 123 | (_: super: { 124 | neovim-custom = pkgs.wrapNeovimUnstable 125 | (super.neovim-unwrapped.overrideAttrs (oldAttrs: { 126 | buildInputs = oldAttrs.buildInputs ++ [ super.tree-sitter ]; 127 | })) config; 128 | }) 129 | ]; 130 | 131 | environment.systemPackages = with pkgs; [ 132 | neovim-custom 133 | 134 | # Can't install this with the rest of the python packages b/c this needs to be in path 135 | python3Packages.jupytext # if you want to use vim-jupytext or similar 136 | 137 | # ... other system packages 138 | ]; 139 | } 140 | ``` 141 | -------------------------------------------------------------------------------- /rplugin/python3/molten/jupyter_server_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import uuid 4 | from queue import Empty as EmptyQueueException 5 | from queue import Queue 6 | from threading import Thread 7 | from typing import Any, Dict 8 | from urllib.parse import parse_qs, urlparse 9 | 10 | from molten.runtime_state import RuntimeState 11 | 12 | 13 | class JupyterAPIClient: 14 | def __init__(self, 15 | url: str, 16 | kernel_info: Dict[str, Any], 17 | headers: Dict[str, str]): 18 | self._base_url = url 19 | self._kernel_info = kernel_info 20 | self._headers = headers 21 | 22 | self._recv_queue: Queue[Dict[str, Any]] = Queue() 23 | 24 | import requests 25 | self.requests = requests 26 | 27 | def get_stdin_msg(self, **kwargs): 28 | return None 29 | 30 | def wait_for_ready(self, timeout: float = 0.): 31 | start = time.time() 32 | while True: 33 | response = self.requests.get(self._kernel_api_base, 34 | headers=self._headers) 35 | response = json.loads(response.text) 36 | 37 | if response["execution_state"] != "idle" and time.time() - start > timeout: 38 | raise RuntimeError 39 | 40 | # Discard unnecessary messages. 41 | while True: 42 | try: 43 | response = self.get_iopub_msg() 44 | except EmptyQueueException: 45 | return 46 | 47 | 48 | def start_channels(self) -> None: 49 | import websocket 50 | 51 | parsed_url = urlparse(self._base_url) 52 | self._socket = websocket.create_connection(f"ws://{parsed_url.hostname}:{parsed_url.port}" 53 | f"/api/kernels/{self._kernel_info['id']}/channels", 54 | header=self._headers, 55 | ) 56 | self._kernel_api_base = f"{self._base_url}/api/kernels/{self._kernel_info['id']}" 57 | 58 | self._iopub_recv_thread = Thread(target=self._recv_message) 59 | self._iopub_recv_thread.start() 60 | 61 | def _recv_message(self) -> None: 62 | while True: 63 | response = json.loads(self._socket.recv()) 64 | self._recv_queue.put(response) 65 | 66 | def get_iopub_msg(self, **kwargs): 67 | if self._recv_queue.empty(): 68 | raise EmptyQueueException 69 | 70 | response = self._recv_queue.get() 71 | 72 | return response 73 | 74 | def execute(self, code: str): 75 | header = { 76 | 'msg_type': 'execute_request', 77 | 'msg_id': uuid.uuid1().hex, 78 | 'session': uuid.uuid1().hex 79 | } 80 | 81 | message = json.dumps({ 82 | 'header': header, 83 | 'parent_header': header, 84 | 'metadata': {}, 85 | 'content': { 86 | 'code': code, 87 | 'silent': False 88 | } 89 | }) 90 | self._socket.send(message) 91 | 92 | def shutdown(self): 93 | self.requests.delete(self._kernel_api_base, 94 | headers=self._headers) 95 | 96 | def cleanup_connection_file(self): 97 | pass 98 | 99 | class JupyterAPIManager: 100 | def __init__(self, 101 | url: str, 102 | ): 103 | parsed_url = urlparse(url) 104 | self._base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" 105 | 106 | token = parse_qs(parsed_url.query).get("token") 107 | if token: 108 | self._headers = {'Authorization': f'token {token[0]}'} 109 | else: 110 | # Run notebook with --NotebookApp.disable_check_xsrf="True". 111 | self._headers = {} 112 | 113 | import requests 114 | self.requests = requests 115 | 116 | def start_kernel(self) -> None: 117 | url = f"{self._base_url}/api/kernels" 118 | response = self.requests.post(url, 119 | headers=self._headers) 120 | self._kernel_info = json.loads(response.text) 121 | assert "id" in self._kernel_info, "Could not connect to Jupyter Server API. The URL specified may be incorrect." 122 | self._kernel_api_base = f"{url}/{self._kernel_info['id']}" 123 | 124 | def client(self) -> JupyterAPIClient: 125 | return JupyterAPIClient(url=self._base_url, 126 | kernel_info=self._kernel_info, 127 | headers=self._headers) 128 | 129 | def interrupt_kernel(self) -> None: 130 | self.requests.post(f"{self._kernel_api_base}/interrupt", 131 | headers=self._headers) 132 | 133 | def restart_kernel(self) -> None: 134 | self.state = RuntimeState.STARTING 135 | self.requests.post(f"{self._kernel_api_base}/restart", 136 | headers=self._headers) 137 | -------------------------------------------------------------------------------- /rplugin/python3/molten/save_load.py: -------------------------------------------------------------------------------- 1 | from typing import Type, Optional, Dict, Any 2 | import os 3 | from pynvim import Nvim 4 | 5 | from pynvim.api import Buffer 6 | from molten.code_cell import CodeCell 7 | from molten.position import DynamicPosition 8 | 9 | from molten.utils import MoltenException 10 | from molten.options import MoltenOptions 11 | from molten.outputchunks import OutputStatus, Output, to_outputchunk 12 | from molten.outputbuffer import OutputBuffer 13 | from molten.moltenbuffer import MoltenKernel 14 | 15 | 16 | class MoltenIOError(Exception): 17 | @classmethod 18 | def assert_has_key( 19 | cls, data: Dict[str, Any], key: str, type_: Optional[Type[Any]] = None 20 | ) -> Any: 21 | if key not in data: 22 | raise cls(f"Missing key: {key}") 23 | value = data[key] 24 | if type_ is not None and not isinstance(value, type_): 25 | raise cls( 26 | f"Incorrect type for key '{key}': expected {type_.__name__}, \ 27 | got {type(value).__name__}" 28 | ) 29 | return value 30 | 31 | 32 | def get_default_save_file(options: MoltenOptions, buffer: Buffer) -> str: 33 | # XXX: this is string containment checking. Beware. 34 | if "nofile" in buffer.options["buftype"]: 35 | raise MoltenException("Buffer does not correspond to a file") 36 | 37 | mangled_name = buffer.name.replace("%", "%%").replace("/", "%") 38 | 39 | return os.path.join(options.save_path, mangled_name + ".json") 40 | 41 | 42 | def load(nvim: Nvim, moltenbuffer: MoltenKernel, nvim_buffer: Buffer, data: Dict[str, Any]) -> None: 43 | MoltenIOError.assert_has_key(data, "content_checksum", str) 44 | 45 | if moltenbuffer._get_content_checksum() != data["content_checksum"]: 46 | raise MoltenIOError("Buffer contents' checksum does not match!") 47 | 48 | MoltenIOError.assert_has_key(data, "cells", list) 49 | for cell in data["cells"]: 50 | MoltenIOError.assert_has_key(cell, "span", dict) 51 | MoltenIOError.assert_has_key(cell["span"], "begin", dict) 52 | MoltenIOError.assert_has_key(cell["span"]["begin"], "lineno", int) 53 | MoltenIOError.assert_has_key(cell["span"]["begin"], "colno", int) 54 | MoltenIOError.assert_has_key(cell["span"], "end", dict) 55 | MoltenIOError.assert_has_key(cell["span"]["end"], "lineno", int) 56 | MoltenIOError.assert_has_key(cell["span"]["end"], "colno", int) 57 | begin_position = DynamicPosition( 58 | moltenbuffer.nvim, 59 | moltenbuffer.extmark_namespace, 60 | nvim_buffer.number, 61 | cell["span"]["begin"]["lineno"], 62 | cell["span"]["begin"]["colno"], 63 | ) 64 | end_position = DynamicPosition( 65 | moltenbuffer.nvim, 66 | moltenbuffer.extmark_namespace, 67 | nvim_buffer.number, 68 | cell["span"]["end"]["lineno"], 69 | cell["span"]["end"]["colno"], 70 | right_gravity=True, 71 | ) 72 | span = CodeCell(nvim, begin_position, end_position) 73 | 74 | # XXX: do we really want to have the execution count here? 75 | # what happens when the counts start to overlap? 76 | MoltenIOError.assert_has_key(cell, "execution_count", int) 77 | output = Output(cell["execution_count"]) 78 | 79 | MoltenIOError.assert_has_key(cell, "status", int) 80 | output.status = OutputStatus(cell["status"]) 81 | 82 | MoltenIOError.assert_has_key(cell, "success", bool) 83 | output.success = cell["success"] 84 | 85 | MoltenIOError.assert_has_key(cell, "chunks", list) 86 | for chunk in cell["chunks"]: 87 | MoltenIOError.assert_has_key(chunk, "data", dict) 88 | MoltenIOError.assert_has_key(chunk, "metadata", dict) 89 | output.chunks.append( 90 | to_outputchunk( 91 | nvim, 92 | moltenbuffer.runtime._alloc_file, 93 | chunk["data"], 94 | chunk["metadata"], 95 | moltenbuffer.options, 96 | ) 97 | ) 98 | 99 | output.old = True 100 | output.status = OutputStatus.DONE 101 | 102 | moltenbuffer.outputs[span] = OutputBuffer( 103 | moltenbuffer.nvim, 104 | moltenbuffer.canvas, 105 | moltenbuffer.extmark_namespace, 106 | moltenbuffer.options, 107 | ) 108 | moltenbuffer.outputs[span].output = output 109 | 110 | 111 | def save(molten_kernel: MoltenKernel, nvim_buffer: int) -> Dict[str, Any]: 112 | """Save the current kernel state for the given buffer.""" 113 | return { 114 | "version": 1, 115 | "kernel": molten_kernel.runtime.kernel_name, 116 | "content_checksum": molten_kernel._get_content_checksum(), 117 | "cells": [ 118 | { 119 | "span": { 120 | "begin": { 121 | "lineno": span.begin.lineno, 122 | "colno": span.begin.colno, 123 | }, 124 | "end": { 125 | "lineno": span.end.lineno, 126 | "colno": span.end.colno, 127 | }, 128 | }, 129 | "execution_count": output.output.execution_count, 130 | "status": output.output.status.value, 131 | "success": output.output.success, 132 | "chunks": [ 133 | { 134 | "data": chunk.jupyter_data, 135 | "metadata": chunk.jupyter_metadata, 136 | } 137 | for chunk in output.output.chunks 138 | if chunk.jupyter_data is not None and chunk.jupyter_metadata is not None 139 | ], 140 | } 141 | for span, output in molten_kernel.outputs.items() 142 | if span.begin.bufno == nvim_buffer 143 | ], 144 | } 145 | -------------------------------------------------------------------------------- /docs/Advanced-Functionality.md: -------------------------------------------------------------------------------- 1 | This page will go over some of the more "advanced" ways you can use this plugin that aren't covered 2 | in the other docs. 3 | 4 | ## Connecting to external kernels 5 | 6 | Normally, Molten will launch the kernel for you, and close it for you when you exit neovim. However, 7 | you may want to launch a kernel from somewhere else, and connect to it with Molten, and close neovim 8 | while the kernel stays running. This is possible with Molten by specifying the connection file for 9 | the running kernel. This is a JSON file that's printed in the console when starting a kernel with 10 | the `jupyter kernel` command. 11 | 12 | ### Example 13 | 14 | ```bash 15 | jupyter kernel --kernel=python3 16 | # [KernelApp] Starting kernel 'molten' 17 | # \/ this is the important part 18 | # [KernelApp] Connection file: /home/benlubas/.local/share/jupyter/runtime/kernel-5094b45f-58e4-4fdc-9e68-baf52e7e76a9.json 19 | # [KernelApp] To connect a client: --existing kernel-5094b45f-58e4-4fdc-9e68-baf52e7e76a9.json 20 | # [IPKernelApp] WARNING | debugpy_stream undefined, debugging will not be enabled 21 | ``` 22 | 23 | Then, in neovim I can run the command: `:MoltenInit 24 | /home/benlubas/.local/share/jupyter/runtime/kernel-5094b45f-58e4-4fdc-9e68-baf52e7e76a9.json` to 25 | connect to that kernel. You can then run code on this kernel like normal. When you leave neovim, the 26 | kernel will remain running. 27 | 28 | > [!NOTE] 29 | > If you get an error like `Caused By: [Errno 2] No such file or directory` when running the 30 | > command, you might have to create a directory yourself. This is because the Jupyter client assumes 31 | > that the folder exists when it might not. Simply create the folder, and you should be good to go! 32 | 33 | You can also start the server with 34 | 35 | ```bash 36 | jupyter console --kernel=python3 -f /tmp/your_path_here.json 37 | ``` 38 | 39 | in order to avoid having to copy paste the file path. But this requires jupyter-console to be 40 | installed. 41 | 42 | ### Remote hosts 43 | 44 | > [!NOTE] 45 | > I've not tested this, but it should work 46 | 47 | It's also possible to use this method to connect to remove jupyter kernels. 48 | 49 | On the remote machine run: 50 | 51 | ```bash 52 | jupyter console --kernel julia-1.7 --ip 1.2.3.4 -f /tmp/remote-julia.json 53 | ``` 54 | 55 | Again, you can also use `jupyter kernel --kernel=` but the file path will be a lot 56 | longer 57 | 58 | Locally run: 59 | 60 | ```bash 61 | scp 1.2.3.4:/tmp/remote-julia.json /tmp/remote-julia.json 62 | ``` 63 | 64 | And finally run `:MoltenInit /tmp/remote-julia.json` in neovim. 65 | 66 | ## MoltenDelete 67 | 68 | The `MoltenDelete` command has two forms: 69 | 70 | - `:MoltenDelete` - Deletes only the currently selected/active cell 71 | - `:MoltenDelete!` - Deletes all cells in the current buffer, for all kernels 72 | 73 | The bang version is useful when you want to quickly clear all outputs and cell definitions from your buffer. This is similar to `MoltenRestart!` but doesn't restart the kernel - it just removes all cell definitions and their outputs. 74 | 75 | ## Importing/Exporting Outputs to/from ipynb files 76 | 77 | > [!NOTE] 78 | > These commands are considered experimental, and while they work well enough to be used, there are 79 | > likely still bugs. If you find one, don't hesitate to create an issue. 80 | 81 | In-depth on: 82 | - `:MoltenExportOutput` 83 | - `:MoltenImportOutput` 84 | 85 | These commands are intended for use with tools like Quarto, or Jupytext, which convert notebooks to 86 | plaintext, but they're implemented in such a way that the plaintext file format shouldn't matter, as 87 | long as the code contents of the cells match. 88 | 89 | ### Usage 90 | 91 | `:MoltenExportOutput` will create a copy of the notebook, prepended with "copy-of-", while 92 | `:MoltenExportOutput!` will overwrite the existing notebook (with an identical one that just has new 93 | outputs). Existing outputs are deleted (only for the cells that you export). 94 | 95 | `:MoltenImportOutput` will import outputs from a notebook file so you can view them in neovim. It 96 | requires a running kernel. 97 | 98 | You can specify a file path as the first argument. By default, Molten looks for an existing notebook 99 | with the same name in the same spot. For example, when editing `/path/to/file.md` the default path 100 | is `/path/to/file.ipynb`. If you call `:MoltenExportOutput! /other/file.ipynb` 101 | then Molten will add outputs to `/other/file.ipynb` (`/other/file.ipynb` must already exist). 102 | 103 | If there are multiple kernels attached to the buffer when either command is called, you will be 104 | prompted for which kernel to use. You can only export one kernels output at a time, and you can only 105 | import outputs to one kernel at a time. 106 | 107 | There is nothing stopping you from exporting outputs from multiple kernels to the same notebook if 108 | you would like. 109 | 110 | ### Bailing 111 | 112 | The export will bail if there is a Molten cell with output that doesn't have a corresponding cell in 113 | the notebook. **Cells are searched for in order.** 114 | 115 | If your export is failing, it's probably b/c your notebook and plaintext representation got out of 116 | sync with each other. 117 | 118 | Imports do not bail in the same sense because they're fairly harmless. If you accidentally import 119 | an output from the wrong notebook, no data is lost, so molten will let it happen. Molten reports the 120 | number of cells imported, so if that number looks wrong, your import didn't quite work out for some 121 | reason. 122 | 123 | 124 | ### Cell Matching 125 | 126 | Cells are matched differently for imports vs exports. We're kinda forced to do this. 127 | 128 | #### exports 129 | 130 | For exports, cells are matched by code content and **comments are ignored**. As a result, **if you 131 | have two or more code cells that have the same code content, and only the second one has output, 132 | molten will export that output to the first cell in the notebook**. 133 | 134 | To avoid this, just don't create cells that are identical. If you must, just execute both before 135 | exporting, they will be correctly lined up. 136 | 137 | #### imports 138 | 139 | Cells are matched as plaintext, line by line, sequentially. This works pretty well, but results in 140 | quarto cells not being "full" as the metadata comments are not matched. Additionally, markdown text 141 | that is exactly the same as a code cell and that comes before the code cell, will get the output 142 | instead of the code cell (but this is _rare_). 143 | -------------------------------------------------------------------------------- /docs/Not-So-Quick-Start-Guide.md: -------------------------------------------------------------------------------- 1 | # Not So Quick Start Guide 2 | 3 | This will walk you through the install, light configuration, and basic usage! It's a little less 4 | than quick in the interest of explaining what's necessary and what's not. 5 | 6 | ## Installation 7 | 8 | ### Dependencies 9 | 10 | This plugin has many dependencies if you would like the full experience. Most of these dependencies 11 | are optional and are only necessary if you would like image support. 12 | 13 | #### Image.nvim 14 | 15 | [Image.nvim](https://github.com/3rd/image.nvim) is a neovim plugin that provides an api for 16 | rendering images. Rending images in the terminal is not the most straight forward thing in the 17 | world. As such, I'd recommend clicking that link, configuring image.nvim, making sure it works 18 | with their builtin markdown integration, and then coming back here to finish setting up Molten. 19 | 20 | ##### After Image.nvim is working 21 | 22 | There are a few image.nvim config options that will dramatically improve your experience. Here is 23 | a sample configuration that leaves out the document integrations (note if you want to disable these, 24 | please see the [image.nvim](https://github.com/3rd/image.nvim) readme. 25 | 26 | ```lua 27 | -- image nvim options table. Pass to `require('image').setup` 28 | { 29 | backend = "kitty", -- Kitty will provide the best experience, but you need a compatible terminal 30 | integrations = {}, -- do whatever you want with image.nvim's integrations 31 | max_width = 100, -- tweak to preference 32 | max_height = 12, -- ^ 33 | max_height_window_percentage = math.huge, -- this is necessary for a good experience 34 | max_width_window_percentage = math.huge, 35 | window_overlap_clear_enabled = true, 36 | window_overlap_clear_ft_ignore = { "cmp_menu", "cmp_docs", "" }, 37 | }, 38 | ``` 39 | 40 | **Important**: `max_width` and `max_height` _must_ be set, or large images can cause your terminal 41 | to crash. I recommend the values 100 and 12, that feels natural to me, but feel free to increase or 42 | decrease as you see fit (font size will make a large difference here). 43 | 44 | Less important but still important: Setting `max_height_window_percentage` to `math.huge` is 45 | necessary for Molten to render output windows at the correct dimensions. This value defaults to 46 | 50 or 60%, and for a plugin like Molten, which tries to display a window that's only as tall as it 47 | needs to be, window percentage caps are problematic. Note that even setting this value to 100% is not 48 | enough, as this can cause images to be resized instead of cropped when the molten output window is 49 | partially off-screen, and the image is (until you scroll) taller than the window. 50 | 51 | 52 | ##### Pinning Image.nvim version 53 | 54 | Image.nvim is still in it's early stages, and as such, breaks more often than other plugins. For the 55 | most reliable experience with Molten, you should pin the version of image.nvim that you use. 56 | 57 | Different package managers allow for pinning versions differently, so please refer to your package 58 | manager's documentation if you don't use Lazy. 59 | 60 | > [!NOTE] 61 | > Note that I will always use the latest version of image.nvim, and will try to keep this doc up to 62 | > date with the last working version. But if you're having issues with the version listed here, 63 | > please first try the latest image.nvim version, and then open an issue or pr. 64 | 65 | ```lua 66 | version = "1.1.0", 67 | ``` 68 | 69 | #### Python Deps 70 | 71 | **Note**: It's recommended that you install python packages in a virtual environment as outlined in 72 | the [venv guide](./Virtual-Environments.md) 73 | 74 | **Absolutely necessary python packages:** 75 | - [`pynvim`](https://github.com/neovim/pynvim) (for the Remote Plugin API) 76 | - [`jupyter_client`](https://github.com/jupyter/jupyter_client) (for interacting with Jupyter) 77 | 78 | **Packages only required for their specific image support:** 79 | - [`cairosvg`](https://cairosvg.org/) (for displaying transparent SVG images) 80 | - If you don't have cariosvg installed, we fallback to image.nvim's svg support, which uses the 81 | ImageMagic library. From what I've gathered, this library has differing levels of support for 82 | SVGs with transparent backgrounds. So I'd recommend trying to get away without cairo, and only 83 | installing it if you notice an issue. 84 | - [`pnglatex`](https://pypi.org/project/pnglatex/) (for displaying TeX formulas) 85 | - Note that this has additional, non-pip, dependencies. You need a TeX distribution installed on 86 | your machine as well as the following executables: `pdftopnm`, `pnmtopng`, `pdfcrop` which you 87 | can find through your system package manager. 88 | - `plotly` and `kaleido` (for displaying Plotly figures) 89 | - In order to render plotly figures you might also needed `nbformat` installed in the project 90 | venv, unfortunately installing it in the neovim venv did not work (see [venv 91 | guide](./Virtual-Environments.md)) 92 | - `pyperclip` if you want to use `molten_copy_output` 93 | 94 | #### .NET Deps 95 | - `dotnet tool install -g Microsoft.dotnet-interactive` 96 | - `dotnet interactive jupyter install` 97 | 98 | > [!NOTE] 99 | > I personally do not use .NET (nor have I ever), all the tooling for .NET is working in theory, but 100 | > hasn't been tested by myself. This is something Magma supported, and there's no reason that it 101 | > shouldn't still work, but I'll be able to provide limited help here. 102 | 103 | ### Sample Lazy.nvim Config 104 | 105 | ```lua 106 | return { 107 | { 108 | "benlubas/molten-nvim", 109 | version = "^1.0.0", -- use version <2.0.0 to avoid breaking changes 110 | dependencies = { "3rd/image.nvim" }, 111 | build = ":UpdateRemotePlugins", 112 | init = function() 113 | -- these are examples, not defaults. Please see the readme 114 | vim.g.molten_image_provider = "image.nvim" 115 | vim.g.molten_output_win_max_height = 20 116 | end, 117 | }, 118 | { 119 | -- see the image.nvim readme for more information about configuring this plugin 120 | "3rd/image.nvim", 121 | opts = { 122 | backend = "kitty", -- whatever backend you would like to use 123 | max_width = 100, 124 | max_height = 12, 125 | max_height_window_percentage = math.huge, 126 | max_width_window_percentage = math.huge, 127 | window_overlap_clear_enabled = true, -- toggles images when windows are overlapped 128 | window_overlap_clear_ft_ignore = { "cmp_menu", "cmp_docs", "" }, 129 | }, 130 | } 131 | }, 132 | ``` 133 | 134 | ### A Note on Remote Plugins 135 | 136 | Molten is a remote plugin. This means that the first time you install, and after you update Molten 137 | you need to run the `:UpdateRemotePlugins` command in Neovim. This can be done with some package 138 | mangers (like Lazy for example) automatically. 139 | 140 | But if things aren't working, make sure that you run that command and then restart your editor. 141 | 142 | > [!WARNING] 143 | > Many neovim distros disable remote plugsins in the name of performance (even regular users who 144 | > unknowingly copy snippets they don't understand may have remote plugins disabled). Obviously this 145 | > will prevent molten from working 146 | 147 | 148 | > [!WARNING] 149 | > Windows users see [the windows page](./Windows.md) 150 | 151 | ### Customize 152 | 153 | The README is the best resource for customization info. Additionally, you'll want to setup some 154 | keybinds for common commands like `:MoltenEvaluateVisual`, more information about doing this is also 155 | in the README! 156 | -------------------------------------------------------------------------------- /rplugin/python3/molten/ipynb.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | from pynvim.api import Buffer, Nvim 3 | from molten.code_cell import CodeCell 4 | from molten.moltenbuffer import MoltenKernel 5 | import os 6 | from molten.outputbuffer import OutputBuffer 7 | from molten.outputchunks import ErrorOutputChunk, Output, OutputStatus, to_outputchunk 8 | from molten.position import DynamicPosition 9 | 10 | from molten.utils import MoltenException, notify_error, notify_info, notify_warn 11 | 12 | NOTEBOOK_VERSION = 4 13 | 14 | 15 | def get_default_import_export_file(nvim: Nvim, buffer: Buffer) -> str: 16 | # WARN: this is string containment checking, not array containment checking. 17 | if "nofile" in buffer.options["buftype"]: 18 | raise MoltenException("Buffer does not correspond to a file") 19 | 20 | file_name = nvim.funcs.expand("%") 21 | cwd = nvim.funcs.getcwd() 22 | full_path = os.path.join(cwd, file_name) 23 | return f"{os.path.splitext(full_path)[0]}.ipynb" 24 | 25 | 26 | def import_outputs(nvim: Nvim, kernel: MoltenKernel, filepath: str): 27 | """Import outputs from an .ipynb file with the given name""" 28 | import nbformat 29 | 30 | if not filepath.endswith(".ipynb"): 31 | filepath += ".ipynb" 32 | 33 | if not os.path.exists(filepath): 34 | notify_warn(nvim, f"Cannot import from file: {filepath} because it does not exist.") 35 | return 36 | 37 | buf_line = 0 38 | buf = nvim.current.buffer 39 | buffer_contents = buf[:] 40 | nb = nbformat.read(filepath, as_version=NOTEBOOK_VERSION) 41 | 42 | molten_outputs: Dict[CodeCell, Output] = {} 43 | 44 | for cell in nb["cells"]: 45 | if cell["cell_type"] != "code" or "outputs" not in cell: 46 | continue 47 | 48 | nb_contents = cell["source"].split("\n") 49 | nb_line = 0 50 | while buf_line < len(buffer_contents): 51 | if len(nb_contents) == 0: 52 | break # out of while loop 53 | if nb_contents[nb_line] != buffer_contents[buf_line]: 54 | # move on to the next buffer line, but reset the nb_line 55 | nb_line = 0 56 | buf_line += 1 57 | continue 58 | 59 | if nb_line >= len(nb_contents) - 1: 60 | # we're done. This is a match, we'll create the output 61 | output = Output(cell["execution_count"]) 62 | output.old = True 63 | output.success = True 64 | if output.execution_count: 65 | output.status = OutputStatus.DONE 66 | else: 67 | output.status = OutputStatus.NEW 68 | 69 | for output_data in cell["outputs"]: 70 | m_chunk, success = handle_output_types(nvim, output_data.get("output_type"), kernel, output_data) 71 | output.chunks.append(m_chunk) 72 | output.success &= success 73 | 74 | start = DynamicPosition( 75 | nvim, 76 | kernel.extmark_namespace, 77 | buf.number, 78 | buf_line - (len(nb_contents) - 1), 79 | 0, 80 | ) 81 | end = DynamicPosition( 82 | nvim, kernel.extmark_namespace, buf.number, buf_line, len(buf[buf_line]) 83 | ) 84 | code_cell = CodeCell(nvim, start, end) 85 | molten_outputs[code_cell] = output 86 | nb_line = 0 87 | buf_line += 1 88 | break # out of the while loop 89 | 90 | buf_line += 1 91 | nb_line += 1 92 | 93 | failed = 0 94 | for span, output in molten_outputs.items(): 95 | if kernel.try_delete_overlapping_cells(span): 96 | kernel.outputs[span] = OutputBuffer( 97 | kernel.nvim, 98 | kernel.canvas, 99 | kernel.extmark_namespace, 100 | kernel.options, 101 | ) 102 | kernel.outputs[span].output = output 103 | kernel.update_interface() 104 | else: 105 | failed += 1 106 | 107 | loaded = len(molten_outputs) - failed 108 | 109 | if len(molten_outputs) == 0: 110 | notify_warn(nvim, "No cell outputs to import") 111 | elif loaded > 0: 112 | notify_info(nvim, f"Successfully loaded {loaded} outputs cells") 113 | if failed > 0: 114 | notify_error( 115 | nvim, f"Failed to load output for {failed} running cell that would be overridden" 116 | ) 117 | 118 | def handle_output_types(nvim: Nvim, output_type: str, kernel: MoltenKernel, output_data): 119 | chunk = None 120 | success = True 121 | match output_type: 122 | case "stream": 123 | chunk = to_outputchunk( 124 | nvim, 125 | kernel.runtime._alloc_file, 126 | { "text/plain": output_data.get("text") }, 127 | output_data.get("metadata"), 128 | kernel.options, 129 | ) 130 | case "error": 131 | chunk = ErrorOutputChunk(output_data["ename"], output_data["evalue"], output_data["traceback"]) 132 | chunk.extras = output_data 133 | success = False 134 | case _: 135 | chunk = to_outputchunk( 136 | nvim, 137 | kernel.runtime._alloc_file, 138 | output_data.get("data"), 139 | output_data.get("metadata"), 140 | kernel.options, 141 | ) 142 | return chunk, success 143 | 144 | def export_outputs(nvim: Nvim, kernel: MoltenKernel, filepath: str, overwrite: bool): 145 | """Export outputs of the current file/kernel to a .ipynb file with the given name.""" 146 | import nbformat 147 | 148 | if not filepath.endswith(".ipynb"): 149 | filepath += ".ipynb" 150 | 151 | if not os.path.exists(filepath): 152 | notify_warn(nvim, f"Cannot export to file: {filepath} because it does not exist.") 153 | return 154 | 155 | nb = nbformat.read(filepath, as_version=NOTEBOOK_VERSION) 156 | 157 | molten_cells = sorted(kernel.outputs.items(), key=lambda x: x[0]) 158 | 159 | if len(molten_cells) == 0: 160 | notify_warn(nvim, "No cell outputs to export") 161 | return 162 | 163 | nb_cells = list(filter(lambda x: x["cell_type"] == "code", nb["cells"])) 164 | nb_index = 0 165 | lang = kernel.runtime.kernel_manager.kernel_spec.language # type: ignore 166 | for mcell in molten_cells: 167 | matched = False 168 | while nb_index < len(nb_cells): 169 | code_cell, output = mcell 170 | nb_cell = nb_cells[nb_index] 171 | nb_index += 1 172 | 173 | if compare_contents(nvim, nb_cell, code_cell, lang): 174 | matched = True 175 | outputs = [ 176 | nbformat.v4.new_output( 177 | chunk.output_type, 178 | chunk.jupyter_data, 179 | **chunk.extras, 180 | ) 181 | if chunk.jupyter_metadata is None 182 | else nbformat.v4.new_output( 183 | chunk.output_type, 184 | chunk.jupyter_data, 185 | metadata=chunk.jupyter_metadata, 186 | **chunk.extras, 187 | ) 188 | for chunk in output.output.chunks 189 | ] 190 | nb_cell["outputs"] = outputs 191 | nb_cell["execution_count"] = output.output.execution_count 192 | break # break out of the while loop 193 | 194 | if not matched: 195 | notify_error( 196 | nvim, 197 | f"No cell matching cell at line: {mcell[0].begin.lineno + 1} in notebook: {filepath}. Bailing.", 198 | ) 199 | return 200 | 201 | if overwrite: 202 | write_to = filepath 203 | else: 204 | head, tail = os.path.split(filepath) 205 | write_to = f"{head}/copy-of-{tail}" 206 | 207 | notify_info(nvim, f"Exporting {len(molten_cells)} cell output(s) to {write_to}") 208 | nbformat.write(nb, write_to) 209 | 210 | 211 | def compare_contents(nvim: Nvim, nb_cell, code_cell: CodeCell, lang: str) -> bool: 212 | molten_contents = code_cell.get_text(nvim) 213 | nvim.exec_lua("_remove_comments = require('remove_comments').remove_comments") 214 | clean_nb = nvim.lua._remove_comments(nb_cell["source"] + "\n", lang) 215 | clean_molten = nvim.lua._remove_comments(molten_contents + "\n", lang) 216 | return clean_nb == clean_molten 217 | -------------------------------------------------------------------------------- /docs/Windows.md: -------------------------------------------------------------------------------- 1 | # Windows Users 2 | 3 | Windows is problematic. 4 | 5 | ## Basic Functionality 6 | 7 | > [!WARNING] 8 | > The entire section is a hack, and can break when you update Molten. The file contents below are up to 9 | > date as of version 1.8.x 10 | 11 | This plugin is a remote plugin, and there are reportedly problems with the `:UpdateRemotePlugins` 12 | command not generating the rplugin manifest file on Windows. This file is supposed to be 13 | autogenerated, but in a pinch you can create this file yourself. 14 | 15 | ### Did you read the warning? 16 | 17 | 1. Run `:UpdateRemotePlugins` and note the filepath that it prints, something like: `remote/host: 18 | generated rplugin manifest: /home/benlubas/.local/share/nvim/rplugin.vim` (but windows) 19 | 2. Paste the below contents into that file (which should be empty except for a few comments). 20 | 3. **Edit the file path** below so that it points to where molten is installed on your system. **You 21 | must use `\\\\`** as the path separator (idk why). 22 | An example of this might look like: 23 | `"C:\\\\Users\\\\username\\\\AppData\\\\Local\\\\nvim-data\\\\lazy\\\\molten-nvim\\\\rplugin\\\\python3\\\\molten` 24 | ```vim 25 | " \/ --- This file path 26 | call remote#host#RegisterPlugin('python3', '/home/benlubas/github/molten-nvim/rplugin/python3/molten', [ 27 | \ {'sync': v:true, 'name': 'MoltenDeinit', 'type': 'command', 'opts': {}}, 28 | \ {'sync': v:true, 'name': 'MoltenDelete', 'type': 'command', 'opts': {'bang':''}}, 29 | \ {'sync': v:true, 'name': 'MoltenEnterOutput', 'type': 'command', 'opts': {}}, 30 | \ {'sync': v:true, 'name': 'MoltenReevaluateCell', 'type': 'command', 'opts': {}}, 31 | \ {'sync': v:true, 'name': 'MoltenEvaluateLine', 'type': 'command', 'opts': {'nargs': '*'}}, 32 | \ {'sync': v:true, 'name': 'MoltenEvaluateOperator', 'type': 'command', 'opts': {}}, 33 | \ {'sync': v:true, 'name': 'MoltenEvaluateVisual', 'type': 'command', 'opts': {'nargs': '*'}}, 34 | \ {'sync': v:true, 'name': 'MoltenExportOutput', 'type': 'command', 'opts': {'bang': '', 'nargs': '*'}}, 35 | \ {'sync': v:true, 'name': 'MoltenGoto', 'type': 'command', 'opts': {'nargs': '*'}}, 36 | \ {'sync': v:true, 'name': 'MoltenHideOutput', 'type': 'command', 'opts': {}}, 37 | \ {'sync': v:true, 'name': 'MoltenImagePopup', 'type': 'command', 'opts': {}}, 38 | \ {'sync': v:true, 'name': 'MoltenImportOutput', 'type': 'command', 'opts': {'nargs': '*'}}, 39 | \ {'sync': v:true, 'name': 'MoltenInfo', 'type': 'command', 'opts': {}}, 40 | \ {'sync': v:true, 'name': 'MoltenInit', 'type': 'command', 'opts': {'complete': 'file', 'nargs': '*'}}, 41 | \ {'sync': v:true, 'name': 'MoltenInterrupt', 'type': 'command', 'opts': {'nargs': '*'}}, 42 | \ {'sync': v:true, 'name': 'MoltenLoad', 'type': 'command', 'opts': {'nargs': '*'}}, 43 | \ {'sync': v:true, 'name': 'MoltenNext', 'type': 'command', 'opts': {'nargs': '*'}}, 44 | \ {'sync': v:true, 'name': 'MoltenOpenInBrowser', 'type': 'command', 'opts': {}}, 45 | \ {'sync': v:true, 'name': 'MoltenPrev', 'type': 'command', 'opts': {'nargs': '*'}}, 46 | \ {'sync': v:true, 'name': 'MoltenReevaluateAll', 'type': 'command', 'opts': {}}, 47 | \ {'sync': v:true, 'name': 'MoltenRestart', 'type': 'command', 'opts': {'bang': '', 'nargs': '*'}}, 48 | \ {'sync': v:true, 'name': 'MoltenSave', 'type': 'command', 'opts': {'nargs': '*'}}, 49 | \ {'sync': v:true, 'name': 'MoltenShowOutput', 'type': 'command', 'opts': {}}, 50 | \ {'sync': v:true, 'name': 'MoltenEvaluateArgument', 'type': 'command', 'opts': {'nargs': '*'}}, 51 | \ {'sync': v:true, 'name': 'MoltenEvaluateRange', 'type': 'function', 'opts': {}}, 52 | \ {'sync': v:true, 'name': 'MoltenAvailableKernels', 'type': 'function', 'opts': {}}, 53 | \ {'sync': v:true, 'name': 'MoltenBufLeave', 'type': 'function', 'opts': {}}, 54 | \ {'sync': v:true, 'name': 'MoltenRunningKernels', 'type': 'function', 'opts': {}}, 55 | \ {'sync': v:true, 'name': 'MoltenDefineCell', 'type': 'function', 'opts': {}}, 56 | \ {'sync': v:true, 'name': 'MoltenOperatorfunc', 'type': 'function', 'opts': {}}, 57 | \ {'sync': v:false, 'name': 'MoltenSendStdin', 'type': 'function', 'opts': {}}, 58 | \ {'sync': v:true, 'name': 'MoltenTick', 'type': 'function', 'opts': {}}, 59 | \ {'sync': v:false, 'name': 'MoltenTickInput', 'type': 'function', 'opts': {}}, 60 | \ {'sync': v:true, 'name': 'MoltenOnBufferUnload', 'type': 'function', 'opts': {}}, 61 | \ {'sync': v:true, 'name': 'MoltenOnCursorMoved', 'type': 'function', 'opts': {}}, 62 | \ {'sync': v:true, 'name': 'MoltenOnExitPre', 'type': 'function', 'opts': {}}, 63 | \ {'sync': v:true, 'name': 'MoltenOnWinScrolled', 'type': 'function', 'opts': {}}, 64 | \ {'sync': v:true, 'name': 'MoltenStatusLineInit', 'type': 'function', 'opts': {}}, 65 | \ {'sync': v:true, 'name': 'MoltenStatusLineKernels', 'type': 'function', 'opts': {}}, 66 | \ {'sync': v:true, 'name': 'MoltenUpdateInterface', 'type': 'function', 'opts': {}}, 67 | \ {'sync': v:true, 'name': 'MoltenUpdateOption', 'type': 'function', 'opts': {}}, 68 | \ ]) 69 | ``` 70 | 71 | Hopefully that worked. 72 | 73 | ## Images 74 | 75 | Image.nvim currently supports two display options, kitty image protocol and uberzugpp. Kitty just 76 | doesn't work due to windows blocking escape codes (explained better 77 | [here](https://github.com/wez/wezterm/issues/1673#issuecomment-1054311400)). That link also links 78 | two windows issues that, when resolved, would theoretically allow it to work. 79 | 80 | ### Workarounds 81 | 82 | There are four workarounds, choose one. The first two require/only work in WSL. The last two should 83 | work both with and without WSL. 84 | #### Uberzugpp 85 | 86 | Uberzugpp should work on WSL. You might have to configure it in a special way. 87 | 88 | #### Kitty in Graphical WSL 89 | 90 | [kitty/discussions/7054](https://github.com/kovidgoyal/kitty/discussions/7054) 91 | 92 | The title of that discussion says it all. This is a cursed method of using kitty in a graphical WSL 93 | session. 94 | 95 | #### `:MoltenImagePopup` 96 | 97 | The first officially supported way to view images on Windows. It's a far worse experience compared 98 | to the other methods, but you can configure `vim.g.molten_auto_image_popup = true` to at least see 99 | images automatically when your code produces them. It's also very simple, and doesn't have any 100 | dependencies. 101 | 102 | #### Wezterm (via Wezterm.nvim) 103 | 104 | The second officially supported way to render images without the use of WSL on Windows is to use 105 | Wezterm as your terminal emulator and setting `vim.g.molten_image_provider = "wezterm"`. This is 106 | a bit of a workaround, but it's the only other way to get images to render in the terminal without 107 | needing an external pop-up window like used with `:MoltenImagePopup`. You can find the instructions 108 | for downloading and setting up Wezterm [here](https://wezfurlong.org/wezterm/install/windows.html). 109 | 110 | ![](https://github.com/akthe-at/assets/blob/main/wezterm.gif) 111 | 112 | The `vim.g.molten_image_provider = "wezterm"` option takes advantage of 113 | [wezterm.nvim](https://github.com/willothy/wezterm.nvim) under the hood to create splits, and send 114 | images to wezterm's `imgcat` program automatically for you. This workflow style feels a little 115 | similar to how Rstudio handles plots, and it's a nice way to keep your code and output in the same 116 | window. If you want a quick glance at plots for fast iteration. If you want a larger more detailed 117 | view of your plots you may prefer to use the `:MoltenImagePopup` command instead. 118 | 119 | An example set of configuration options for molten-nvim (but not necessarily limited to) to use 120 | `Wezterm` as the `vim.g.molten_image_provider` option would look like this: 121 | 122 | ```lua 123 | { 124 | "benlubas/molten-nvim", 125 | build = ":UpdateRemotePlugins", 126 | dependencies = "willothy/wezterm.nvim", 127 | init = function() 128 | vim.g.molten_auto_open_output = false -- cannot be true if molten_image_provider = "wezterm" 129 | vim.g.molten_output_show_more = true 130 | vim.g.molten_image_provider = "wezterm" 131 | vim.g.molten_output_virt_lines = true 132 | vim.g.molten_split_direction = "right" --direction of the output window, options are "right", "left", "top", "bottom" 133 | vim.g.molten_split_size = 40 --(0-100) % size of the screen dedicated to the output window 134 | vim.g.molten_virt_text_output = true 135 | vim.g.molten_use_border_highlights = true 136 | vim.g.molten_virt_lines_off_by_1 = true 137 | vim.g.molten_auto_image_popup = false 138 | vim.g.molten_output_win_zindex = 50 139 | end, 140 | }, 141 | ``` 142 | -------------------------------------------------------------------------------- /rplugin/python3/molten/runtime.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional, Tuple, List, Dict, Generator, IO, Any 3 | from contextlib import contextmanager 4 | from queue import Empty as EmptyQueueException 5 | import os 6 | import tempfile 7 | import json 8 | 9 | import jupyter_client 10 | from pynvim import Nvim 11 | 12 | from molten.options import MoltenOptions 13 | from molten.outputchunks import ( 14 | Output, 15 | MimetypesOutputChunk, 16 | ErrorOutputChunk, 17 | TextOutputChunk, 18 | OutputStatus, 19 | to_outputchunk, 20 | clean_up_text, 21 | ) 22 | from molten.runtime_state import RuntimeState 23 | from molten.jupyter_server_api import JupyterAPIClient, JupyterAPIManager 24 | 25 | 26 | class JupyterRuntime: 27 | state: RuntimeState 28 | kernel_name: str 29 | kernel_id: str 30 | 31 | kernel_manager: jupyter_client.KernelManager | JupyterAPIManager # type: ignore 32 | kernel_client: jupyter_client.KernelClient | JupyterAPIClient # type: ignore 33 | 34 | allocated_files: List[str] 35 | 36 | options: MoltenOptions 37 | nvim: Nvim 38 | 39 | def __init__(self, nvim: Nvim, kernel_name: str, kernel_id: str, options: MoltenOptions): 40 | self.state = RuntimeState.STARTING 41 | self.kernel_name = kernel_name 42 | self.kernel_id = kernel_id 43 | self.nvim = nvim 44 | self.nvim.exec_lua("_prompt_stdin = require('prompt').prompt_stdin") 45 | 46 | if kernel_name.startswith("http://") or kernel_name.startswith("https://"): 47 | self.external_kernel = False 48 | self.kernel_manager = JupyterAPIManager(kernel_name) 49 | self.kernel_manager.start_kernel() 50 | self.kernel_client = self.kernel_manager.client() 51 | self.kernel_client.start_channels() 52 | self.options = options 53 | elif ".json" not in self.kernel_name: 54 | self.external_kernel = False 55 | self.kernel_manager = jupyter_client.manager.KernelManager(kernel_name=kernel_name) 56 | self.kernel_manager.start_kernel() 57 | self.kernel_client = self.kernel_manager.client() 58 | assert isinstance( 59 | self.kernel_client, 60 | jupyter_client.blocking.client.BlockingKernelClient, 61 | ) 62 | self.kernel_client.start_channels() 63 | self.kernel_client.connection_file = ( 64 | f"{self.kernel_client.data_dir}/runtime/kernel-{self.kernel_manager.kernel_id}.json" 65 | ) 66 | self.kernel_client.write_connection_file() 67 | else: 68 | kernel_file = kernel_name 69 | self.external_kernel = True 70 | # Opening JSON file 71 | try: 72 | kernel_json = json.load(open(kernel_file)) 73 | except FileNotFoundError: 74 | raise ValueError(f"Could not find kernel file at path: {kernel_file}") 75 | 76 | # we have a kernel json 77 | self.kernel_manager = jupyter_client.manager.KernelManager( 78 | kernel_name=kernel_json["kernel_name"] 79 | ) 80 | self.kernel_client = self.kernel_manager.client() 81 | self.kernel_client.load_connection_file(connection_file=kernel_file) 82 | 83 | self.allocated_files = [] 84 | self.options = options 85 | 86 | def is_ready(self) -> bool: 87 | return self.state.value > RuntimeState.STARTING.value 88 | 89 | def deinit(self) -> None: 90 | for path in self.allocated_files: 91 | if os.path.exists(path): 92 | os.remove(path) 93 | 94 | if self.external_kernel is False: 95 | self.kernel_client.cleanup_connection_file() 96 | self.kernel_client.shutdown() 97 | 98 | def interrupt(self) -> None: 99 | self.kernel_manager.interrupt_kernel() 100 | 101 | def restart(self) -> None: 102 | self.state = RuntimeState.STARTING 103 | self.kernel_manager.restart_kernel() 104 | 105 | def run_code(self, code: str) -> None: 106 | self.kernel_client.execute(code) 107 | 108 | @contextmanager 109 | def _alloc_file( 110 | self, extension: str, mode: str 111 | ) -> Generator[Tuple[str, IO[bytes]], None, None]: 112 | with tempfile.NamedTemporaryFile(suffix="." + extension, mode=mode, delete=False) as file: 113 | path = file.name 114 | yield path, file 115 | self.allocated_files.append(path) 116 | 117 | def _append_chunk(self, output: Output, data: Dict[str, Any], metadata: Dict[str, Any]) -> None: 118 | if self.options.show_mimetype_debug: 119 | output.chunks.append(MimetypesOutputChunk(list(data.keys()))) 120 | 121 | if output.success: 122 | chunk = to_outputchunk(self.nvim, self._alloc_file, data, metadata, self.options) 123 | output.chunks.append(chunk) 124 | if isinstance(chunk, TextOutputChunk) and chunk.text.startswith("\r"): 125 | output.merge_text_chunks() 126 | 127 | def _tick_one(self, output: Output, message_type: str, content: Dict[str, Any]) -> bool: 128 | def copy_on_demand(content_ctor): 129 | if self.options.copy_output: 130 | import pyperclip 131 | 132 | if type(content_ctor) is str: 133 | pyperclip.copy(content_ctor) 134 | else: 135 | pyperclip.copy(content_ctor()) 136 | 137 | if output._should_clear: 138 | output.chunks.clear() 139 | output._should_clear = False 140 | 141 | if message_type == "execute_input": 142 | output.execution_count = content["execution_count"] 143 | if self.external_kernel is False: 144 | if output.status == OutputStatus.DONE: 145 | return False 146 | if output.status == OutputStatus.HOLD: 147 | output.status = OutputStatus.RUNNING 148 | output.start_time = datetime.now() 149 | elif output.status == OutputStatus.RUNNING: 150 | output.status = OutputStatus.DONE 151 | else: 152 | raise ValueError("bad value for output.status: %r" % output.status) 153 | return True 154 | elif message_type == "status": 155 | execution_state = content["execution_state"] 156 | assert execution_state != "starting" 157 | if execution_state == "idle": 158 | self.state = RuntimeState.IDLE 159 | output.status = OutputStatus.DONE 160 | return True 161 | elif execution_state == "busy": 162 | self.state = RuntimeState.RUNNING 163 | return True 164 | else: 165 | return False 166 | elif message_type == "execute_reply": 167 | # This doesn't really give us any relevant information. 168 | return False 169 | elif message_type == "execute_result": 170 | self._append_chunk(output, content["data"], content["metadata"]) 171 | if "text/plain" in content["data"]: 172 | copy_on_demand(content["data"]["text/plain"]) 173 | return True 174 | elif message_type == "error": 175 | output.success = False 176 | chunk = ErrorOutputChunk(content["ename"], content["evalue"], content["traceback"]) 177 | chunk.extras = content 178 | output.chunks.append(chunk) 179 | 180 | copy_on_demand(lambda: "\n\n".join(map(clean_up_text, content["traceback"]))) 181 | return True 182 | elif message_type == "stream": 183 | copy_on_demand(content["text"]) 184 | self._append_chunk(output, {"text/plain": content["text"]}, {}) 185 | return True 186 | elif message_type == "display_data": 187 | # XXX: consider content['transient'], if we end up saving execution 188 | # outputs. 189 | self._append_chunk(output, content["data"], content["metadata"]) 190 | return True 191 | elif message_type == "update_display_data": 192 | # We don't really want to bother with this type of message. 193 | return False 194 | elif message_type == "clear_output": 195 | if content["wait"]: 196 | output._should_clear = True 197 | else: 198 | output.chunks.clear() 199 | return True 200 | # TODO: message_type == 'debug'? 201 | else: 202 | return False 203 | 204 | def tick(self, output: Optional[Output]) -> bool: 205 | did_stuff = False 206 | 207 | assert isinstance( 208 | self.kernel_client, 209 | ( 210 | jupyter_client.blocking.client.BlockingKernelClient, 211 | JupyterAPIClient, 212 | ), 213 | ) 214 | 215 | if not self.is_ready(): 216 | try: 217 | self.kernel_client.wait_for_ready(timeout=0) 218 | self.state = RuntimeState.IDLE 219 | did_stuff = True 220 | except RuntimeError: 221 | return False 222 | 223 | if output is None: 224 | return did_stuff 225 | 226 | while True: 227 | try: 228 | message = self.kernel_client.get_iopub_msg(timeout=0) 229 | 230 | if "content" not in message or "msg_type" not in message: 231 | continue 232 | 233 | did_stuff_now = self._tick_one(output, message["msg_type"], message["content"]) 234 | did_stuff = did_stuff or did_stuff_now 235 | 236 | if output.status == OutputStatus.DONE: 237 | break 238 | except EmptyQueueException: 239 | break 240 | 241 | return did_stuff 242 | 243 | def tick_input(self): 244 | """Tick to check input_requests""" 245 | if not self.is_ready: 246 | return 247 | 248 | assert isinstance( 249 | self.kernel_client, 250 | (jupyter_client.blocking.client.BlockingKernelClient, 251 | JupyterAPIClient), 252 | ) 253 | 254 | try: 255 | msg = self.kernel_client.get_stdin_msg(timeout=0) 256 | if msg is not None: 257 | self.take_input(msg) 258 | except EmptyQueueException: 259 | pass 260 | 261 | def take_input(self, msg): 262 | if msg["msg_type"] == "input_request": 263 | self.nvim.lua._prompt_stdin(self.kernel_id, msg["content"]["prompt"]) 264 | 265 | 266 | def get_available_kernels() -> List[str]: 267 | return list(jupyter_client.kernelspec.find_kernel_specs().keys()) # type: ignore 268 | -------------------------------------------------------------------------------- /rplugin/python3/molten/images.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Set 2 | from abc import ABC, abstractmethod 3 | 4 | from pynvim import Nvim 5 | from molten.options import MoltenOptions 6 | 7 | from molten.utils import notify_warn, MoltenException 8 | 9 | 10 | class Canvas(ABC): 11 | @abstractmethod 12 | def init(self) -> None: 13 | """ 14 | Initialize the canvas. 15 | 16 | This will be called before the canvas is ever used. 17 | """ 18 | 19 | @abstractmethod 20 | def deinit(self) -> None: 21 | """ 22 | Deinitialize the canvas. 23 | 24 | The canvas will not be used after this operation. 25 | """ 26 | 27 | @abstractmethod 28 | def present(self) -> None: 29 | """ 30 | Present the canvas. 31 | 32 | This is called only when a redraw is necessary -- so, if desired, it 33 | can be implemented so that `clear` and `add_image` only queue images as 34 | to be drawn, and `present` actually performs the operations, in order 35 | to reduce flickering. 36 | """ 37 | 38 | @abstractmethod 39 | def img_size(self, identifier: str) -> Dict[str, int]: 40 | """ 41 | Get the height of an image in terminal rows. 42 | """ 43 | 44 | @abstractmethod 45 | def add_image( 46 | self, 47 | path: str, 48 | identifier: str, 49 | x: int, 50 | y: int, 51 | bufnr: int, 52 | winnr: int | None = None, 53 | ) -> str: 54 | """ 55 | Add an image to the canvas. 56 | Takes effect after a call to present() 57 | 58 | Parameters 59 | - path: str 60 | Path to the image we want to show 61 | - x: int 62 | Column number of where the image is supposed to be drawn at (top-left 63 | corner). 64 | - y: int 65 | Row number of where the image is supposed to be drawn at (top-right 66 | corner). 67 | - bufnr: int 68 | The buffer number for the buffer in which to draw the image. 69 | 70 | Returns: 71 | str the identifier for the image 72 | """ 73 | 74 | @abstractmethod 75 | def remove_image(self, identifier: str) -> None: 76 | """ 77 | Remove an image from the canvas. In practice this is just hiding the image 78 | Takes effect after a call to present() 79 | 80 | Parameters 81 | - identifier: str 82 | The identifier for the image to remove. 83 | """ 84 | 85 | 86 | class NoCanvas(Canvas): 87 | def __init__(self) -> None: 88 | pass 89 | 90 | def init(self) -> None: 91 | pass 92 | 93 | def deinit(self) -> None: 94 | pass 95 | 96 | def present(self) -> None: 97 | pass 98 | 99 | def img_size(self, _indentifier: str) -> Dict[str, int]: 100 | return {"height": 0, "width": 0} 101 | 102 | def add_image( 103 | self, 104 | _path: str, 105 | _identifier: str, 106 | _x: int, 107 | _y: int, 108 | _bufnr: int, 109 | _winnr: int, 110 | ) -> None: 111 | pass 112 | 113 | def remove_image(self, _identifier: str) -> None: 114 | pass 115 | 116 | 117 | class ImageNvimCanvas(Canvas): 118 | nvim: Nvim 119 | to_make_visible: Set[str] 120 | to_make_invisible: Set[str] 121 | visible: Set[str] 122 | 123 | def __init__(self, nvim: Nvim): 124 | self.nvim = nvim 125 | self.visible = set() 126 | self.to_make_visible = set() 127 | self.to_make_invisible = set() 128 | self.next_id = 0 129 | 130 | def init(self) -> None: 131 | self.nvim.exec_lua("_image = require('load_image_nvim').image_api") 132 | self.nvim.exec_lua("_image_utils = require('load_image_nvim').image_utils") 133 | self.image_api = self.nvim.lua._image 134 | self.image_utils = self.nvim.lua._image_utils 135 | 136 | def deinit(self) -> None: 137 | self.image_api.clear_all() 138 | 139 | def present(self) -> None: 140 | # images to both show and hide should be ignored 141 | to_work_on = self.to_make_visible.difference( 142 | self.to_make_visible.intersection(self.to_make_invisible) 143 | ) 144 | self.to_make_invisible.difference_update(self.to_make_visible) 145 | for identifier in self.to_make_invisible: 146 | self.image_api.clear(identifier) 147 | 148 | for identifier in to_work_on: 149 | size = self.img_size(identifier) 150 | self.image_api.render(identifier, size) 151 | 152 | self.visible.update(self.to_make_visible) 153 | self.to_make_invisible.clear() 154 | self.to_make_visible.clear() 155 | 156 | def img_size(self, identifier: str) -> Dict[str, int]: 157 | return self.image_api.image_size(identifier) 158 | 159 | def add_image( 160 | self, 161 | path: str, 162 | identifier: str, 163 | x: int, 164 | y: int, 165 | bufnr: int, 166 | winnr: int | None = None, 167 | ) -> str: 168 | img = self.image_api.from_file( 169 | path, 170 | { 171 | "id": identifier, 172 | "buffer": bufnr, 173 | "with_virtual_padding": True, 174 | "x": x, 175 | "y": y, 176 | "window": winnr, 177 | }, 178 | ) 179 | self.to_make_visible.add(img) 180 | return img 181 | 182 | def remove_image(self, identifier: str) -> None: 183 | self.to_make_invisible.add(identifier) 184 | 185 | 186 | class WeztermCanvas(Canvas): 187 | """A canvas for using Wezterm's imgcat functionality to render images/plots""" 188 | 189 | nvim: Nvim 190 | split_dir: str | None 191 | split_size: int | None 192 | to_make_visible: Set[str] 193 | to_make_invisible: Set[str] 194 | visible: Set[str] 195 | 196 | def __init__(self, nvim: Nvim, split_dir: str | None, split_size: int | None): 197 | self.nvim = nvim 198 | self.split_dir = split_dir 199 | self.split_size = split_size 200 | self.visible = set() 201 | self.to_make_visible = set() 202 | self.to_make_invisible = set() 203 | self.initial_pane_id: int | None = None 204 | self.image_pane: int | None = None 205 | 206 | def init(self) -> None: 207 | self.nvim.exec_lua("_wezterm = require('load_wezterm_nvim').wezterm_api") 208 | self.wezterm_api = self.nvim.lua._wezterm 209 | self.initial_pane_id = self.wezterm_api.get_pane_id() 210 | 211 | def deinit(self) -> None: 212 | """Closes the terminal split that was opened with MoltenInit""" 213 | self.wezterm_api.close_image_pane(str(self.image_pane).strip()) 214 | 215 | def present(self) -> None: 216 | to_work_on = self.to_make_visible.difference( 217 | self.to_make_visible.intersection(self.to_make_invisible) 218 | ) 219 | self.to_make_invisible.difference_update(self.to_make_visible) 220 | 221 | for identifier in to_work_on: 222 | self.wezterm_api.send_image( 223 | identifier, 224 | str(self.image_pane).strip(), 225 | str(self.initial_pane_id).strip(), 226 | ) 227 | 228 | self.visible.update(self.to_make_visible) 229 | self.to_make_invisible.clear() 230 | self.to_make_visible.clear() 231 | 232 | def img_size(self, _indentifier: str) -> Dict[str, int]: 233 | return {"height": 0, "width": 0} 234 | 235 | def add_image( 236 | self, 237 | path: str, 238 | identifier: str, 239 | _x: int, 240 | _y: int, 241 | _bufnr: int, 242 | _winnr: int, 243 | ) -> str | dict[str, str]: 244 | """Adds an image to the queue to be rendered by Wezterm via the place method""" 245 | img = {"path": path, "id": identifier} 246 | self.to_make_visible.add(img["path"]) 247 | return img 248 | 249 | def remove_image(self, identifier: str) -> None: 250 | pass 251 | 252 | def wezterm_split(self): 253 | """Splits the terminal based on config preferences at molten kernel init if 254 | supplied, otherwise resort to default values. Returns the pane id of the new 255 | split to allow sending/moving between the panes correctly. 256 | """ 257 | self.image_pane = self.wezterm_api.wezterm_molten_init( 258 | self.initial_pane_id, self.split_dir, self.split_size 259 | ) 260 | 261 | 262 | class SnacksCanvas(Canvas): 263 | nvim: Nvim 264 | to_make_visible: Set[str] 265 | to_make_invisible: Set[str] 266 | visible: Set[str] 267 | 268 | def __init__(self, nvim: Nvim): 269 | self.nvim = nvim 270 | self.visible = set() 271 | self.to_make_visible = set() 272 | self.to_make_invisible = set() 273 | self.next_id = 0 274 | 275 | def init(self) -> None: 276 | self.nvim.exec_lua("_snacks = require('load_snacks_nvim').snacks_api") 277 | self.snacks_api = self.nvim.lua._snacks 278 | 279 | def deinit(self) -> None: 280 | self.snacks_api.clear_all() 281 | 282 | def present(self) -> None: 283 | # images to both show and hide should be ignored 284 | to_work_on = self.to_make_visible.difference( 285 | self.to_make_visible.intersection(self.to_make_invisible) 286 | ) 287 | self.to_make_invisible.difference_update(self.to_make_visible) 288 | for identifier in self.to_make_invisible: 289 | self.snacks_api.clear(identifier) 290 | 291 | for identifier in to_work_on: 292 | self.snacks_api.render(identifier) 293 | 294 | self.visible.update(self.to_make_visible) 295 | self.to_make_invisible.clear() 296 | self.to_make_visible.clear() 297 | 298 | def img_size(self, identifier: str) -> Dict[str, int]: 299 | return self.snacks_api.image_size(identifier) 300 | 301 | def add_image( 302 | self, 303 | path: str, 304 | identifier: str, 305 | x: int, 306 | y: int, 307 | bufnr: int, 308 | winnr: int | None = None, 309 | ) -> str: 310 | img = self.snacks_api.from_file( 311 | path, 312 | { 313 | "id": identifier, 314 | "buffer": bufnr, 315 | "x": x, 316 | "y": y + 1, 317 | }, 318 | ) 319 | self.to_make_visible.add(img) 320 | return img 321 | 322 | def remove_image(self, identifier: str) -> None: 323 | self.to_make_invisible.add(identifier) 324 | 325 | 326 | def get_canvas_given_provider(nvim: Nvim, options: MoltenOptions) -> Canvas: 327 | name = options.image_provider 328 | 329 | if name == "none": 330 | return NoCanvas() 331 | elif name == "image.nvim": 332 | return ImageNvimCanvas(nvim) 333 | elif name == "snacks.nvim": 334 | return SnacksCanvas(nvim) 335 | elif name == "wezterm": 336 | if options.auto_open_output: 337 | raise MoltenException( 338 | "'wezterm' as an image provider does not currently support molten_auto_open_output = true, please set it to false or use a different image provider" 339 | ) 340 | return WeztermCanvas(nvim, options.split_direction, options.split_size) 341 | else: 342 | notify_warn(nvim, f"unknown image provider: `{name}`") 343 | return NoCanvas() 344 | -------------------------------------------------------------------------------- /rplugin/python3/molten/outputchunks.py: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | Optional, 3 | Tuple, 4 | List, 5 | Dict, 6 | Any, 7 | Callable, 8 | IO, 9 | ) 10 | from contextlib import AbstractContextManager 11 | from enum import Enum 12 | from abc import ABC, abstractmethod 13 | import re 14 | from datetime import datetime 15 | 16 | from pynvim import Nvim 17 | 18 | 19 | from molten.images import Canvas 20 | from molten.options import MoltenOptions 21 | from molten.utils import notify_error 22 | 23 | 24 | class OutputChunk(ABC): 25 | jupyter_data: Optional[Dict[str, Any]] = None 26 | jupyter_metadata: Optional[Dict[str, Any]] = None 27 | # extra keys that are used to write data to jupyter notebook files (ie. for error outputs) 28 | extras: Dict[str, Any] = {} 29 | output_type: str 30 | 31 | @abstractmethod 32 | def place( 33 | self, 34 | bufnr: int, 35 | options: MoltenOptions, 36 | col: int, 37 | lineno: int, 38 | shape: Tuple[int, int, int, int], 39 | canvas: Canvas, 40 | hard_wrap: bool, 41 | winnr: int | None = None, 42 | ) -> Tuple[str, int]: 43 | pass 44 | 45 | 46 | # Adapted from [https://stackoverflow.com/a/14693789/4803382]: 47 | ANSI_CODE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") 48 | 49 | 50 | def clean_up_text(text: str) -> str: 51 | return ( 52 | ANSI_CODE_REGEX 53 | .sub("", text) 54 | .replace("\r\n", "\n") 55 | .replace("\n\n", "\n") 56 | ) 57 | 58 | 59 | class TextOutputChunk(OutputChunk): 60 | text: str 61 | 62 | def __init__(self, text: str): 63 | self.text = text 64 | self.output_type = "display_data" 65 | 66 | def __repr__(self) -> str: 67 | return f'TextOutputChunk("{self.text}")' 68 | 69 | def place( 70 | self, 71 | _bufnr: int, 72 | options: MoltenOptions, 73 | col: int, 74 | _lineno: int, 75 | shape: Tuple[int, int, int, int], 76 | _canvas: Canvas, 77 | hard_wrap: bool, 78 | winnr: int | None = None, 79 | ) -> Tuple[str, int]: 80 | text = clean_up_text(self.text) 81 | extra_lines = 0 82 | if options.wrap_output: # count the number of extra lines this will need when wrapped 83 | win_width = shape[2] 84 | if hard_wrap: 85 | lines = [] 86 | splits = [] 87 | # Assume this is a progress bar, or similar, we shouldn't try to wrap it 88 | if text.find("\r") != -1: 89 | return text, 0 90 | for line in text.split("\n"): 91 | index = 0 92 | if len(line) + col > win_width: 93 | splits.append(line[: win_width - col]) 94 | line = line[win_width - col :] 95 | 96 | for _ in range(len(line) // win_width): 97 | splits.append(line[index * win_width : (index + 1) * win_width]) 98 | index += 1 99 | splits.append(line[index * win_width :]) 100 | 101 | lines.extend(splits) 102 | text = "\n".join(lines) 103 | else: 104 | for line in text.split("\n"): 105 | if len(line) > win_width: 106 | extra_lines += len(line) // win_width 107 | 108 | return text, extra_lines 109 | 110 | 111 | class TextLnOutputChunk(TextOutputChunk): 112 | def __init__(self, text: str): 113 | super().__init__(text + "\n") 114 | 115 | 116 | class BadOutputChunk(TextLnOutputChunk): 117 | def __init__(self, mimetypes: List[str]): 118 | super().__init__("" % mimetypes) 119 | 120 | 121 | class MimetypesOutputChunk(TextLnOutputChunk): 122 | def __init__(self, mimetypes: List[str]): 123 | super().__init__("[DEBUG] Received mimetypes: %r" % mimetypes) 124 | 125 | 126 | class ErrorOutputChunk(TextLnOutputChunk): 127 | def __init__(self, name: str, message: str, traceback: List[str]): 128 | super().__init__( 129 | "\n".join( 130 | [ 131 | f"[Error] {name}: {message}", 132 | "Traceback:", 133 | ] 134 | + traceback 135 | ) 136 | ) 137 | self.output_type = "error" 138 | 139 | 140 | class AbortedOutputChunk(TextLnOutputChunk): 141 | def __init__(self) -> None: 142 | super().__init__("") 143 | 144 | 145 | class ImageOutputChunk(OutputChunk): 146 | def __init__(self, img_path: str): 147 | self.img_path = img_path 148 | self.output_type = "display_data" 149 | self.img_identifier = None 150 | 151 | def place( 152 | self, 153 | bufnr: int, 154 | options: MoltenOptions, 155 | _col: int, 156 | lineno: int, 157 | _shape: Tuple[int, int, int, int], 158 | canvas: Canvas, 159 | virtual: bool, 160 | winnr: int | None = None, 161 | ) -> Tuple[str, int]: 162 | loc = options.image_location 163 | if not (loc == "both" or (loc == "virt" and virtual) or (loc == "float" and not virtual)): 164 | return "", 0 165 | 166 | self.img_identifier = canvas.add_image( 167 | self.img_path, 168 | f"{'virt-' if virtual else ''}{self.img_path}", 169 | 0, 170 | lineno, 171 | bufnr, 172 | winnr, 173 | ) 174 | # images are rendered into virtual lines following the current line, 175 | # which also needs to exist as the extmark is placed there 176 | return " \n", canvas.img_size(self.img_identifier)["height"] 177 | 178 | 179 | class OutputStatus(Enum): 180 | HOLD = 0 181 | """Waiting to run this cell""" 182 | RUNNING = 1 183 | """Currently running, waiting for code to finish running""" 184 | DONE = 2 185 | """Code has already been run""" 186 | NEW = 3 187 | """Cell was created, nothing run, no output""" 188 | 189 | 190 | class Output: 191 | execution_count: Optional[int] 192 | chunks: List[OutputChunk] 193 | status: OutputStatus 194 | success: bool 195 | old: bool 196 | start_time: datetime | None 197 | end_time: datetime | None 198 | 199 | _should_clear: bool 200 | 201 | def __init__(self, execution_count: Optional[int]): 202 | self.execution_count = execution_count 203 | self.status = OutputStatus.HOLD 204 | self.chunks = [] 205 | self.success = True 206 | self.old = False 207 | 208 | self.start_time = None 209 | self.end_time = None 210 | 211 | self._should_clear = False 212 | 213 | def merge_text_chunks(self): 214 | """Merge the last two chunks if they are text chunks, and text on a line before \r 215 | character, this is b/c outputs before a \r aren't shown, and so, should be deleted""" 216 | if ( 217 | len(self.chunks) >= 2 218 | and isinstance((c1 := self.chunks[-2]), TextOutputChunk) 219 | and isinstance((c2 := self.chunks[-1]), TextOutputChunk) 220 | ): 221 | c1.text += c2.text 222 | c1.text = "\n".join([re.sub(r".*\r", "", x) for x in c1.text.split("\n")[:-1]]) 223 | c1.jupyter_data = {"text/plain": c1.text} 224 | self.chunks.pop() 225 | elif len(self.chunks) > 0 and isinstance((c1 := self.chunks[0]), TextOutputChunk): 226 | c1.text = "\n".join([re.sub(r".*\r", "", x) for x in c1.text.split("\n")[:-1]]) 227 | 228 | 229 | def to_outputchunk( 230 | nvim: Nvim, 231 | alloc_file: Callable[ 232 | [str, str], 233 | "AbstractContextManager[Tuple[str, IO[bytes]]]", 234 | ], 235 | data: Dict[str, Any], 236 | metadata: Dict[str, Any], 237 | options: MoltenOptions, 238 | ) -> OutputChunk: 239 | def _to_image_chunk(path: str) -> OutputChunk: 240 | return ImageOutputChunk(path) 241 | 242 | # Output chunk functions: 243 | def _from_image(extension: str, imgdata: bytes) -> OutputChunk: 244 | import base64 245 | 246 | with alloc_file(extension, "wb") as (path, file): 247 | file.write(base64.b64decode(str(imgdata))) 248 | return _to_image_chunk(path) 249 | 250 | def _from_image_svgxml(svg: str) -> OutputChunk: 251 | try: 252 | import cairosvg 253 | 254 | with alloc_file("png", "wb") as (path, file): 255 | cairosvg.svg2png(svg, write_to=file) 256 | return _to_image_chunk(path) 257 | except ImportError: 258 | with alloc_file("svg", "w") as (path, file): 259 | file.write(svg) # type: ignore 260 | return _to_image_chunk(path) 261 | 262 | def _from_application_plotly(figure_json: Any) -> OutputChunk: 263 | from plotly.io import from_json 264 | 265 | # NOTE: import this to cause an import exception which we catch. instead of a different 266 | # error in `write_image` 267 | import kaleido # type: ignore 268 | import json 269 | 270 | figure = from_json(json.dumps(figure_json)) 271 | 272 | with alloc_file("png", "wb") as (path, file): 273 | figure.write_image(file, engine="kaleido") 274 | return _to_image_chunk(path) 275 | 276 | def _from_latex(tex: str) -> OutputChunk: 277 | from pnglatex import pnglatex 278 | 279 | try: 280 | with alloc_file("png", "w") as (path, _): 281 | pass 282 | pnglatex(tex, path) 283 | return _to_image_chunk(path) 284 | except ValueError: 285 | notify_error(nvim, f"pnglatex was unable to render image from LaTeX: {tex}") 286 | return _from_plaintext(tex) 287 | 288 | def _from_plaintext(text: str) -> OutputChunk: 289 | return TextLnOutputChunk(text) 290 | 291 | chunk = None 292 | # if options.image_provider != "none": 293 | # handle these mimetypes first, since they require Molten to render them 294 | special_mimetypes = [ 295 | ("image/svg+xml", _from_image_svgxml), 296 | ("application/vnd.plotly.v1+json", _from_application_plotly), 297 | ("text/latex", _from_latex), 298 | ] 299 | 300 | for mimetype, process_func in special_mimetypes: 301 | try: 302 | maybe_data = None 303 | if data is not None: 304 | maybe_data = data.get(mimetype) 305 | if maybe_data is not None: 306 | chunk = process_func(maybe_data) # type: ignore 307 | break 308 | except ImportError: 309 | continue 310 | 311 | if chunk is None and data is not None: 312 | # handle arbitrary images 313 | for mimetype in data.keys(): 314 | match mimetype.split("/"): 315 | case ["image", extension]: 316 | chunk = _from_image(extension, data[mimetype]) 317 | break 318 | 319 | if chunk is None: 320 | # fallback to plain text if there's nothing else 321 | if data is not None and data.get("text/plain"): 322 | chunk = _from_plaintext(data["text/plain"]) 323 | else: 324 | if data == None: 325 | data = {} 326 | chunk = BadOutputChunk(list(data.keys())) 327 | 328 | chunk.jupyter_data = data 329 | chunk.jupyter_metadata = metadata 330 | 331 | return chunk 332 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.9.2](https://github.com/benlubas/molten-nvim/compare/v1.9.1...v1.9.2) (2025-01-28) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * ensure virtual text is visible at end of file ([#278](https://github.com/benlubas/molten-nvim/issues/278)) ([e458c1f](https://github.com/benlubas/molten-nvim/commit/e458c1f9466b8942e18b517a317478131a509b77)) 9 | * replace deprecated nvim_err_writeln ([#271](https://github.com/benlubas/molten-nvim/issues/271)) ([cc3643c](https://github.com/benlubas/molten-nvim/commit/cc3643cc2bd6ec834f9a9fde8a377fffb3114103)) 10 | * type mismatch between annotations and return types ([#272](https://github.com/benlubas/molten-nvim/issues/272)) ([9e92f2b](https://github.com/benlubas/molten-nvim/commit/9e92f2b780352599a2279424b38095d1f52e2ba7)) 11 | 12 | ## [1.9.1](https://github.com/benlubas/molten-nvim/compare/v1.9.0...v1.9.1) (2024-12-25) 13 | 14 | 15 | ### Bug Fixes 16 | 17 | * check if float window is valid before closing ([#267](https://github.com/benlubas/molten-nvim/issues/267)) ([8117716](https://github.com/benlubas/molten-nvim/commit/8117716aa08156dcdee7233c0fc34f79f597bc75)) 18 | 19 | ## [1.9.0](https://github.com/benlubas/molten-nvim/compare/v1.8.5...v1.9.0) (2024-10-07) 20 | 21 | 22 | ### Features 23 | 24 | * add jupyter api runtime ([#244](https://github.com/benlubas/molten-nvim/issues/244)) ([047bf41](https://github.com/benlubas/molten-nvim/commit/047bf41c7cdb66c75de07e22babe8961857def76)) 25 | * molten_image_location ([#248](https://github.com/benlubas/molten-nvim/issues/248)) ([f2e9ba9](https://github.com/benlubas/molten-nvim/commit/f2e9ba9d229fbc847b7f19da68744954513aff11)) 26 | * OpenInBrowser works with plotly plots ([#240](https://github.com/benlubas/molten-nvim/issues/240)) ([92b2599](https://github.com/benlubas/molten-nvim/commit/92b2599ef813b188391d5f00f5f94ce22ecd2598)) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * % sign file encoding issues ([#246](https://github.com/benlubas/molten-nvim/issues/246)) ([42d7bd7](https://github.com/benlubas/molten-nvim/commit/42d7bd775d7f64995f6a0073da1320d52041191e)) 32 | * match lines added by image.nvim ([#201](https://github.com/benlubas/molten-nvim/issues/201)) ([d643729](https://github.com/benlubas/molten-nvim/commit/d64372964190345c5a4da3639719d0dbe20a2791)) 33 | 34 | ## [1.8.5](https://github.com/benlubas/molten-nvim/compare/v1.8.4...v1.8.5) (2024-08-19) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * clear float win buf with lua api ([#218](https://github.com/benlubas/molten-nvim/issues/218)) ([81aad33](https://github.com/benlubas/molten-nvim/commit/81aad335d46bd3fcd144ee6c798b026335921ed4)) 40 | * **import:** create molten cells for code with no output ([#224](https://github.com/benlubas/molten-nvim/issues/224)) ([35c1941](https://github.com/benlubas/molten-nvim/commit/35c1941d8b631d19f3af725d470781b12ca55f3d)) 41 | 42 | ## [1.8.4](https://github.com/benlubas/molten-nvim/compare/v1.8.3...v1.8.4) (2024-07-02) 43 | 44 | 45 | ### Bug Fixes 46 | 47 | * correct execution time for multiple cells ([#196](https://github.com/benlubas/molten-nvim/issues/196)) ([ab9351b](https://github.com/benlubas/molten-nvim/commit/ab9351baff839c2ea4b0c1b5d1ad8d4968c7f1c1)) 48 | * mark running cells as failed+done on restart ([7f1c31d](https://github.com/benlubas/molten-nvim/commit/7f1c31d554e2b080678ef8855cfb19b86c183b8e)) 49 | 50 | ## [1.8.3](https://github.com/benlubas/molten-nvim/compare/v1.8.2...v1.8.3) (2024-04-22) 51 | 52 | 53 | ### Bug Fixes 54 | 55 | * bounds check call to screenpos ([#189](https://github.com/benlubas/molten-nvim/issues/189)) ([2739a9d](https://github.com/benlubas/molten-nvim/commit/2739a9d58c295b49086eac2f7489ab1b5ba1efd4)) 56 | 57 | ## [1.8.2](https://github.com/benlubas/molten-nvim/compare/v1.8.1...v1.8.2) (2024-04-19) 58 | 59 | 60 | ### Bug Fixes 61 | 62 | * floating window column offset ([#186](https://github.com/benlubas/molten-nvim/issues/186)) ([cbaff48](https://github.com/benlubas/molten-nvim/commit/cbaff4847fc7a28398f8cc73ad4ab43a97d41486)) 63 | 64 | ## [1.8.1](https://github.com/benlubas/molten-nvim/compare/v1.8.0...v1.8.1) (2024-04-14) 65 | 66 | 67 | ### Bug Fixes 68 | 69 | * supply empty opts table to iter_matches ([#183](https://github.com/benlubas/molten-nvim/issues/183)) ([fb59bea](https://github.com/benlubas/molten-nvim/commit/fb59bea5b64e7259dc9bd7c5f54a0ca14c7005c6)) 70 | * surface error when failing to start kernel ([#182](https://github.com/benlubas/molten-nvim/issues/182)) ([7d97cab](https://github.com/benlubas/molten-nvim/commit/7d97cab8d6f26e0ec1ab56004221ee25d3c6daeb)) 71 | 72 | ## [1.8.0](https://github.com/benlubas/molten-nvim/compare/v1.7.0...v1.8.0) (2024-03-30) 73 | 74 | 75 | ### Features 76 | 77 | * execution time ([#153](https://github.com/benlubas/molten-nvim/issues/153)) ([12fc198](https://github.com/benlubas/molten-nvim/commit/12fc198e1dac89cde3e9512b2999972da01dc625)) 78 | * wezterm image provider ([#162](https://github.com/benlubas/molten-nvim/issues/162)) ([4ef66a1](https://github.com/benlubas/molten-nvim/commit/4ef66a162222065f278a5ea92f19eaa9e8b7301c)) 79 | * write connection file ([#176](https://github.com/benlubas/molten-nvim/issues/176)) ([5bc04c9](https://github.com/benlubas/molten-nvim/commit/5bc04c9b985ea7b9d13358b17d82f578021b332e)) 80 | 81 | 82 | ### Bug Fixes 83 | 84 | * catch error on nonexistant kernel init ([#172](https://github.com/benlubas/molten-nvim/issues/172)) ([2fffc76](https://github.com/benlubas/molten-nvim/commit/2fffc76616b645f8e0fff65a3acb89e5f38bd2a9)) 85 | * don't require pillow unless it's used ([#164](https://github.com/benlubas/molten-nvim/issues/164)) ([ca725ae](https://github.com/benlubas/molten-nvim/commit/ca725ae7928b292bbda1fc90eeeae701026c6e83)) 86 | * make sure there is a visual selection ([#159](https://github.com/benlubas/molten-nvim/issues/159)) ([7d8bd23](https://github.com/benlubas/molten-nvim/commit/7d8bd23e3b36bfc911f7af361f3e1c311c61e8e7)) 87 | * make UpdateInterface sync ([#166](https://github.com/benlubas/molten-nvim/issues/166)) ([8d31d04](https://github.com/benlubas/molten-nvim/commit/8d31d04e18acc419f147452861ad5eb34b998276)) 88 | 89 | ## [1.7.0](https://github.com/benlubas/molten-nvim/compare/v1.6.0...v1.7.0) (2024-02-23) 90 | 91 | 92 | ### Features 93 | 94 | * Cover Empty Lines ([#129](https://github.com/benlubas/molten-nvim/issues/129)) ([2f50650](https://github.com/benlubas/molten-nvim/commit/2f50650d2712229c2008c65490ea0915b9e88879)) 95 | * open images externally ([#150](https://github.com/benlubas/molten-nvim/issues/150)) ([3ca888e](https://github.com/benlubas/molten-nvim/commit/3ca888e5a3e554ce7fcda815a79e6eb4d018a35a)) 96 | 97 | 98 | ### Bug Fixes 99 | 100 | * small bugs ([65da69e](https://github.com/benlubas/molten-nvim/commit/65da69e669953d9d2bc89bc400ef5595779d8062)) 101 | 102 | ## [1.6.0](https://github.com/benlubas/molten-nvim/compare/v1.5.2...v1.6.0) (2024-01-20) 103 | 104 | 105 | ### Features 106 | 107 | * handle input_request messages ([#136](https://github.com/benlubas/molten-nvim/issues/136)) ([4a3980f](https://github.com/benlubas/molten-nvim/commit/4a3980f74742ac6f151cc00e444e74fc02b799a2)) 108 | 109 | 110 | ### Bug Fixes 111 | 112 | * error io ([#135](https://github.com/benlubas/molten-nvim/issues/135)) ([3883374](https://github.com/benlubas/molten-nvim/commit/38833744d5cdffc5cfc84b2be0c5449b5b132495)) 113 | 114 | ## [1.5.2](https://github.com/benlubas/molten-nvim/compare/v1.5.1...v1.5.2) (2024-01-18) 115 | 116 | 117 | ### Bug Fixes 118 | 119 | * none check on import loading ([#130](https://github.com/benlubas/molten-nvim/issues/130)) ([921e6e9](https://github.com/benlubas/molten-nvim/commit/921e6e9021dccd48f2d4a43be234ca9c118ef065)) 120 | 121 | ## [1.5.1](https://github.com/benlubas/molten-nvim/compare/v1.5.0...v1.5.1) (2024-01-07) 122 | 123 | 124 | ### Bug Fixes 125 | 126 | * forgot the virtual lines ([#127](https://github.com/benlubas/molten-nvim/issues/127)) ([3733aa8](https://github.com/benlubas/molten-nvim/commit/3733aa8d9dfb433dcf570d413e3da7c0a3b1a60b)) 127 | 128 | ## [1.5.0](https://github.com/benlubas/molten-nvim/compare/v1.4.0...v1.5.0) (2024-01-06) 129 | 130 | 131 | ### Features 132 | 133 | * MoltenAvailableKernels ([#124](https://github.com/benlubas/molten-nvim/issues/124)) ([36a4e9e](https://github.com/benlubas/molten-nvim/commit/36a4e9eb435ff890127c7cb45c50d3efe0819f00)) 134 | 135 | ## [1.4.0](https://github.com/benlubas/molten-nvim/compare/v1.3.1...v1.4.0) (2024-01-06) 136 | 137 | 138 | ### Features 139 | 140 | * configurable tick_rate ([#123](https://github.com/benlubas/molten-nvim/issues/123)) ([d79f48c](https://github.com/benlubas/molten-nvim/commit/d79f48c010639d7bc59fb695a1e94ccf96040cab)) 141 | 142 | 143 | ### Bug Fixes 144 | 145 | * mark imported cells DONE so no re-render ([#119](https://github.com/benlubas/molten-nvim/issues/119)) ([2adcc7f](https://github.com/benlubas/molten-nvim/commit/2adcc7f8b99ade56eac8f7250e4e8cbfb68a151d)) 146 | * progress bar exports and similar ([#122](https://github.com/benlubas/molten-nvim/issues/122)) ([bee4bb5](https://github.com/benlubas/molten-nvim/commit/bee4bb5b43d20d2cdd88f1555af92d362f3aa362)) 147 | 148 | ## [1.3.1](https://github.com/benlubas/molten-nvim/compare/v1.3.0...v1.3.1) (2023-12-31) 149 | 150 | 151 | ### Bug Fixes 152 | 153 | * account for stream jupter outputs ([#113](https://github.com/benlubas/molten-nvim/issues/113)) ([54e7450](https://github.com/benlubas/molten-nvim/commit/54e7450479cb9883a30bea6881965686da79e629)) 154 | * **docs:** image nvim provider config option ([#114](https://github.com/benlubas/molten-nvim/issues/114)) ([02561e0](https://github.com/benlubas/molten-nvim/commit/02561e0301910eaae1ba70ba247dc85e0ea3d517)) 155 | * don't add image height twice ([#107](https://github.com/benlubas/molten-nvim/issues/107)) ([8a661e7](https://github.com/benlubas/molten-nvim/commit/8a661e714c9761302f7e63d21c1396e2e25c7ddc)) 156 | * dont clear img on win changed ([#110](https://github.com/benlubas/molten-nvim/issues/110)) ([ab0eec9](https://github.com/benlubas/molten-nvim/commit/ab0eec985cc16549518453e2cfe5a770b560998d)) 157 | 158 | ## [1.3.0](https://github.com/benlubas/molten-nvim/compare/v1.2.0...v1.3.0) (2023-12-26) 159 | 160 | 161 | ### Features 162 | 163 | * auto init ([#104](https://github.com/benlubas/molten-nvim/issues/104)) ([744be0d](https://github.com/benlubas/molten-nvim/commit/744be0df2d31fb007d0a05ddbe84e3c73024c86b)) 164 | * import outputs from ipynb files ([#94](https://github.com/benlubas/molten-nvim/issues/94)) ([427ecea](https://github.com/benlubas/molten-nvim/commit/427eceac033544fbf1dc50db15918510a837536c)) 165 | * MoltenInit revamp + bug fixes ([#103](https://github.com/benlubas/molten-nvim/issues/103)) ([b626f8d](https://github.com/benlubas/molten-nvim/commit/b626f8d848ed01d648357f5b3d223fddcc732dcf)) 166 | 167 | 168 | ### Bug Fixes 169 | 170 | * import nb format when needed ([#100](https://github.com/benlubas/molten-nvim/issues/100)) ([75ce8fe](https://github.com/benlubas/molten-nvim/commit/75ce8fe3cc8206f8fd3ecdc315e16c47f0ddb75c)) 171 | * status methods in unfamiliar buffer ([#91](https://github.com/benlubas/molten-nvim/issues/91)) ([23e0c79](https://github.com/benlubas/molten-nvim/commit/23e0c79d190d861348733c526a40e7b8ce141c98)) 172 | * trailing comments are removed for comparison ([#99](https://github.com/benlubas/molten-nvim/issues/99)) ([af86259](https://github.com/benlubas/molten-nvim/commit/af86259f22a2286c3ff4334acf5a6858c687418d)) 173 | 174 | ## [1.2.0](https://github.com/benlubas/molten-nvim/compare/v1.1.6...v1.2.0) (2023-12-16) 175 | 176 | 177 | ### Features 178 | 179 | * :ReevaluateAllCells command ([#85](https://github.com/benlubas/molten-nvim/issues/85)) ([6da3d19](https://github.com/benlubas/molten-nvim/commit/6da3d1934922bfde5ba6ccf27b465a23e6190115)) 180 | * cache output text for floats ([#84](https://github.com/benlubas/molten-nvim/issues/84)) ([1ba4023](https://github.com/benlubas/molten-nvim/commit/1ba4023319c23a49f575d5b6a6d37239a4d3312f)) 181 | * MoltenEvaluateRange non-strict indexing ([#81](https://github.com/benlubas/molten-nvim/issues/81)) ([955b0e8](https://github.com/benlubas/molten-nvim/commit/955b0e8d1beecce0e78b4a7b5b70037a16daa94d)) 182 | * MoltenOpenInBrowser command ([#87](https://github.com/benlubas/molten-nvim/issues/87)) ([ebf2bda](https://github.com/benlubas/molten-nvim/commit/ebf2bda74e8b903222ad0378ffda57c9afb5cc84)) 183 | 184 | 185 | ### Bug Fixes 186 | 187 | * account for windows not at the top of screen ([#82](https://github.com/benlubas/molten-nvim/issues/82)) ([900441a](https://github.com/benlubas/molten-nvim/commit/900441aa5d39e1847d180b7aead7b538c4678176)) 188 | * window positioning logic (again) ([#89](https://github.com/benlubas/molten-nvim/issues/89)) ([aa45835](https://github.com/benlubas/molten-nvim/commit/aa45835f2830b1040568f32060e4b5ecb2e003f6)) 189 | 190 | ## [1.1.6](https://github.com/benlubas/molten-nvim/compare/v1.1.5...v1.1.6) (2023-12-10) 191 | 192 | 193 | ### Bug Fixes 194 | 195 | * don't delete running cells ([#75](https://github.com/benlubas/molten-nvim/issues/75)) ([6b2660d](https://github.com/benlubas/molten-nvim/commit/6b2660d790696dc41238b3bca19541e347e27bf2)) 196 | * progress bars in virtual text ([#77](https://github.com/benlubas/molten-nvim/issues/77)) ([3b886c1](https://github.com/benlubas/molten-nvim/commit/3b886c1e987ee9d2654e31f9d0a1234fce8bcb92)) 197 | * remove orphaned cells ([#73](https://github.com/benlubas/molten-nvim/issues/73)) ([b500515](https://github.com/benlubas/molten-nvim/commit/b5005158ddb16fd9c864de957eef92eca9ab1d72)) 198 | 199 | ## [1.1.5](https://github.com/benlubas/molten-nvim/compare/v1.1.4...v1.1.5) (2023-11-30) 200 | 201 | 202 | ### Bug Fixes 203 | 204 | * duplicate virt text/virt text wrap ([#70](https://github.com/benlubas/molten-nvim/issues/70)) ([71faa0d](https://github.com/benlubas/molten-nvim/commit/71faa0d98ee6aea2167f69d9c6c67ccab1571c14)) 205 | * healthcheck on python 3.11+ ([#65](https://github.com/benlubas/molten-nvim/issues/65)) ([523d0ec](https://github.com/benlubas/molten-nvim/commit/523d0eceb3349c8deb798f52c2d827fbfdd44668)) 206 | * truncate output text ([#67](https://github.com/benlubas/molten-nvim/issues/67)) ([3141b93](https://github.com/benlubas/molten-nvim/commit/3141b936ee69f15f3a926b122d110b0940e152e0)) 207 | 208 | ## [1.1.4](https://github.com/benlubas/molten-nvim/compare/v1.1.3...v1.1.4) (2023-11-26) 209 | 210 | 211 | ### Bug Fixes 212 | 213 | * images in output windows with virt text ([#55](https://github.com/benlubas/molten-nvim/issues/55)) ([18b6b9a](https://github.com/benlubas/molten-nvim/commit/18b6b9a680cbce2b037409df79e81e7fdc10c155)) 214 | 215 | ## [1.1.3](https://github.com/benlubas/molten-nvim/compare/v1.1.2...v1.1.3) (2023-11-22) 216 | 217 | 218 | ### Bug Fixes 219 | 220 | * images being pushed down doubly by virt text ([#53](https://github.com/benlubas/molten-nvim/issues/53)) ([909f6f8](https://github.com/benlubas/molten-nvim/commit/909f6f890b6c607ee802ff8662892880dd78baec)) 221 | 222 | ## [1.1.2](https://github.com/benlubas/molten-nvim/compare/v1.1.1...v1.1.2) (2023-11-18) 223 | 224 | 225 | ### Bug Fixes 226 | 227 | * enter float destroying virt output ([#46](https://github.com/benlubas/molten-nvim/issues/46)) ([49ac223](https://github.com/benlubas/molten-nvim/commit/49ac223b5486eb751fadfd627c7618c3b65ad8c4)) 228 | 229 | ## [1.1.1](https://github.com/benlubas/molten-nvim/compare/v1.1.0...v1.1.1) (2023-11-18) 230 | 231 | 232 | ### Bug Fixes 233 | 234 | * hide images when appropriate ([#44](https://github.com/benlubas/molten-nvim/issues/44)) ([f431035](https://github.com/benlubas/molten-nvim/commit/f4310356c6028b29da596888e0804655243f5db8)) 235 | 236 | ## [1.1.0](https://github.com/benlubas/molten-nvim/compare/v1.0.0...v1.1.0) (2023-11-18) 237 | 238 | 239 | ### Features 240 | 241 | * export cell output to ipynb file ([#40](https://github.com/benlubas/molten-nvim/issues/40)) ([ef9cb41](https://github.com/benlubas/molten-nvim/commit/ef9cb41381926878ee832b9c96d74accbb4fabdf)) 242 | * Output as Virtual Text ([#33](https://github.com/benlubas/molten-nvim/issues/33)) ([820463d](https://github.com/benlubas/molten-nvim/commit/820463df259d2c77d080e8106f1ad48ed4e8c7b7)) 243 | 244 | ## 1.0.0 (2023-11-17) 245 | 246 | ### Features From Magma 247 | 248 | - Start a kernel from a list of kernels 249 | - Attach to already running jupyter kernel 250 | - Send code to the Jupyter Kernel to run asynchronously 251 | - View output in a floating window below the `cell` that you ran, including image outputs 252 | - Cells are saved, and you can rerun them, they expand when you type, and you can pull up their 253 | output again, and rerun them. Interact with the output in a vim buffer 254 | 255 | ### New Features (pre 1.0.0) 256 | 257 | - Completely custom borders 258 | - Border colors per run status 259 | - "Cropped" window borders 260 | - Window footer to display the number of extra lines that don't fit in the window 261 | - configurable max window size 262 | - Can specify no border without minimal style 263 | - Buffers can be shared across kernels `:MoltenInit shared [kernal]` 264 | - You can have multiple kernels running in one buffer, including the same kernel running more than 265 | once 266 | - Update configuration values on the fly 267 | - Enter output can also open the output so you have one key to do both 268 | - You can hide the output without leaving the cell 269 | - Quitting an output window hides the output window (configurable) 270 | - A function for running a range of lines, enabling user created code runners 271 | - 272 | - Image rendering 273 | - Images are rendered with Image.nvim which has support for kitty and uberzug++. Much more 274 | consistent image rendering. 275 | - Configurable max image height 276 | - Allows for cropped images 277 | - CairoSVG is no longer required for rendering svg. The ImageMagic dependency of Image.nvim 278 | handles that for us 279 | - more image formats supported 280 | - More graceful LaTeX image rendering errors 281 | - `:MoltenInfo` command to see information about kernels 282 | - Status line functions to see running kernels and/or initialization status 283 | 284 | 285 | ### Bug Fixes 286 | 287 | - Kernel prompt actually works when used from the command line 288 | - Close output command works from inside an output window 289 | - Folding text above an output window is correctly accounted for 290 | - Similarly, virtual lines are correctly accounted for 291 | - Window rendering performance: No longer redraw an open window ever, it's just updated 292 | - Cell rendering performance: Don't redraw the cell highlights every time the window scrolls or the 293 | cursor moves 294 | - Run status is working again 295 | - Save/load is working again 296 | -------------------------------------------------------------------------------- /rplugin/python3/molten/moltenbuffer.py: -------------------------------------------------------------------------------- 1 | from contextlib import AbstractContextManager 2 | from datetime import datetime 3 | from typing import IO, Callable, List, Optional, Dict, Tuple 4 | from queue import Queue 5 | import hashlib 6 | 7 | from pynvim import Nvim 8 | from pynvim.api import Buffer 9 | from molten.code_cell import CodeCell 10 | 11 | from molten.options import MoltenOptions 12 | from molten.images import Canvas 13 | from molten.position import Position 14 | from molten.utils import notify_error, notify_info, notify_warn 15 | from molten.outputbuffer import OutputBuffer 16 | from molten.outputchunks import ImageOutputChunk, OutputChunk, OutputStatus 17 | from molten.runtime import JupyterRuntime 18 | 19 | 20 | class MoltenKernel: 21 | """Handles a Single Kernel that can be attached to multiple buffers 22 | Other MoltenKernels can be attached to the same buffers""" 23 | 24 | nvim: Nvim 25 | canvas: Canvas 26 | highlight_namespace: int 27 | extmark_namespace: int 28 | buffers: List[Buffer] 29 | 30 | runtime: JupyterRuntime 31 | 32 | kernel_id: str 33 | """name unique to this specific jupyter runtime. Only used within Molten. Human Readable""" 34 | 35 | outputs: Dict[CodeCell, OutputBuffer] 36 | current_output: Optional[CodeCell] 37 | queued_outputs: "Queue[CodeCell]" 38 | 39 | selected_cell: Optional[CodeCell] 40 | should_show_floating_win: bool 41 | updating_interface: bool 42 | 43 | options: MoltenOptions 44 | output_statuses: Dict[Optional[CodeCell], OutputStatus] 45 | 46 | def __init__( 47 | self, 48 | nvim: Nvim, 49 | canvas: Canvas, 50 | highlight_namespace: int, 51 | extmark_namespace: int, 52 | main_buffer: Buffer, 53 | options: MoltenOptions, 54 | kernel_name: str, 55 | kernel_id: str, 56 | ): 57 | self.nvim = nvim 58 | self.canvas = canvas 59 | self.highlight_namespace = highlight_namespace 60 | self.extmark_namespace = extmark_namespace 61 | self.buffers = [main_buffer] 62 | 63 | self._doautocmd("MoltenInitPre") 64 | 65 | self.runtime = JupyterRuntime(nvim, kernel_name, kernel_id, options) 66 | self.kernel_id = kernel_id 67 | 68 | self.outputs = {} 69 | self.current_output = None 70 | self.queued_outputs = Queue() 71 | 72 | self.selected_cell = None 73 | self.output_statuses = {} 74 | self.should_show_floating_win = False 75 | self.updating_interface = False 76 | 77 | self.options = options 78 | 79 | def _doautocmd(self, autocmd: str, opts: Dict = {}) -> None: 80 | assert " " not in autocmd 81 | opts["pattern"] = autocmd 82 | self.nvim.api.exec_autocmds("User", opts) 83 | # self.nvim.command(f"doautocmd User {autocmd}") 84 | 85 | def add_nvim_buffer(self, buffer: Buffer) -> None: 86 | self.buffers.append(buffer) 87 | 88 | def deinit(self) -> None: 89 | self._doautocmd("MoltenDeinitPre") 90 | self.runtime.deinit() 91 | self._doautocmd("MoltenDeinitPost") 92 | 93 | def interrupt(self) -> None: 94 | self.runtime.interrupt() 95 | 96 | def restart(self, delete_outputs: bool = False) -> None: 97 | if delete_outputs: 98 | self.clear_virt_outputs() 99 | self.clear_interface() 100 | self.clear_open_output_windows() 101 | self.outputs = {} 102 | else: 103 | for output in self.outputs.values(): 104 | if output.output.status == OutputStatus.RUNNING: 105 | output.output.status = OutputStatus.DONE 106 | output.output.success = False 107 | 108 | self.runtime.restart() 109 | 110 | def run_code(self, code: str, span: CodeCell) -> None: 111 | if not self.try_delete_overlapping_cells(span): 112 | return 113 | self.output_statuses[span] = OutputStatus.RUNNING 114 | self.runtime.run_code(code) 115 | 116 | self.outputs[span] = OutputBuffer( 117 | self.nvim, self.canvas, self.extmark_namespace, self.options 118 | ) 119 | self.queued_outputs.put(span) 120 | 121 | self.selected_cell = span 122 | 123 | if not self.options.virt_text_output: 124 | self.should_show_floating_win = True 125 | 126 | self.update_interface() 127 | 128 | self._check_if_done_running() 129 | 130 | def reevaluate_all(self) -> None: 131 | for span in sorted(self.outputs.keys(), key=lambda s: s.begin): 132 | code = span.get_text(self.nvim) 133 | self.run_code(code, span) 134 | 135 | def reevaluate_cell(self) -> bool: 136 | self.selected_cell = self._get_selected_span() 137 | if self.selected_cell is None: 138 | return False 139 | 140 | code = self.selected_cell.get_text(self.nvim) 141 | 142 | self.run_code(code, self.selected_cell) 143 | return True 144 | 145 | def open_image_popup(self, silent=False) -> bool: 146 | """Open the current image outputs in a floating window 147 | Returns: True if we're in a cell, False otherwise""" 148 | self.selected_cell = self._get_selected_span() 149 | if self.selected_cell is None: 150 | return False 151 | 152 | output = self.outputs[self.selected_cell].output 153 | for chunk in output.chunks: 154 | if isinstance(chunk, ImageOutputChunk): 155 | try: 156 | from PIL import Image 157 | 158 | img = Image.open(chunk.img_path) 159 | img.show(f"[{output.execution_count}] Molten Image") 160 | if not silent: 161 | notify_info(self.nvim, "Opened image popup") 162 | except ModuleNotFoundError: 163 | if not silent: 164 | notify_error( 165 | self.nvim, "Failed to open image becuase module `PIL` was not found" 166 | ) 167 | except: 168 | if not silent: 169 | notify_error( 170 | self.nvim, "Failed to open image when trying to show the popup" 171 | ) 172 | 173 | return True 174 | 175 | def open_in_browser(self, silent=False) -> bool: 176 | """Open the HTML output of the currently selected cell in the browser. 177 | Returns: True if we're in a cell, False otherwise""" 178 | self.selected_cell = self._get_selected_span() 179 | if self.selected_cell is None: 180 | return False 181 | 182 | filepath = write_html_from_chunks( 183 | self.outputs[self.selected_cell].output.chunks, self.runtime._alloc_file 184 | ) 185 | if filepath is None: 186 | if not silent: 187 | notify_warn(self.nvim, "No HTML output to open.") 188 | return True 189 | 190 | opencmd = self.options.open_cmd 191 | import platform 192 | 193 | if opencmd is None: 194 | match platform.system(): 195 | case "Darwin": 196 | opencmd = "open" 197 | case "Linux": 198 | opencmd = "xdg-open" 199 | case "Windows": 200 | opencmd = "start" 201 | 202 | if opencmd is None: 203 | notify_warn(self.nvim, f"Can't open in browser, OS unsupported: {platform.system()}") 204 | else: 205 | import subprocess 206 | 207 | subprocess.run([opencmd, filepath]) 208 | 209 | return True 210 | 211 | def _check_if_done_running(self) -> None: 212 | # TODO: refactor 213 | is_idle = (self.current_output is None or not self.current_output in self.outputs) or ( 214 | self.current_output is not None 215 | and self.outputs[self.current_output].output.status == OutputStatus.DONE 216 | ) 217 | if is_idle and not self.queued_outputs.empty(): 218 | key = self.queued_outputs.get_nowait() 219 | self.current_output = key 220 | 221 | def tick(self) -> None: 222 | self._check_if_done_running() 223 | 224 | was_ready = self.runtime.is_ready() 225 | if self.current_output is None or not self.current_output in self.outputs: 226 | did_stuff = self.runtime.tick(None) 227 | else: 228 | output = self.outputs[self.current_output].output 229 | starting_status = output.status 230 | did_stuff = self.runtime.tick(output) 231 | 232 | if starting_status != OutputStatus.DONE and output.status == OutputStatus.DONE: 233 | if self.options.auto_open_html_in_browser: 234 | self.open_in_browser(silent=True) 235 | if self.options.auto_image_popup: 236 | self.open_image_popup(silent=True) 237 | 238 | output.end_time = datetime.now() 239 | 240 | # HACK: Update the interface here to avoid the incomplete 241 | # update such as the status in output is still `Running` 242 | # when it's already done. 243 | self.update_interface() 244 | # Update the output status 245 | self.output_statuses[self.current_output] = output.status 246 | 247 | if self.options.output_show_exec_time or did_stuff: 248 | self.update_interface() 249 | 250 | if not was_ready and self.runtime.is_ready(): 251 | self._doautocmd( 252 | "MoltenKernelReady", 253 | opts={ 254 | "data": { 255 | "kernel_id": self.kernel_id, 256 | } 257 | }, 258 | ) 259 | notify_info( 260 | self.nvim, 261 | f"Kernel '{self.runtime.kernel_name}' (id: {self.kernel_id}) is ready.", 262 | ) 263 | 264 | def tick_input(self) -> None: 265 | self.runtime.tick_input() 266 | 267 | def send_stdin(self, input: str) -> None: 268 | self.runtime.kernel_client.input(input) 269 | 270 | def enter_output(self) -> None: 271 | if self.selected_cell is not None: 272 | if self.options.enter_output_behavior != "no_open": 273 | self.should_show_floating_win = True 274 | self.should_show_floating_win = self.outputs[self.selected_cell].enter( 275 | self.selected_cell.end 276 | ) 277 | 278 | def _get_cursor_position(self) -> Position: 279 | _, lineno, colno, _, _ = self.nvim.funcs.getcurpos() 280 | return Position(self.nvim.current.buffer.number, lineno - 1, colno - 1) 281 | 282 | def clear_interface(self) -> None: 283 | if self.updating_interface: 284 | return 285 | 286 | for buffer in self.buffers: 287 | self.nvim.funcs.nvim_buf_clear_namespace( 288 | buffer.number, 289 | self.highlight_namespace, 290 | 0, 291 | -1, 292 | ) 293 | 294 | def clear_open_output_windows(self) -> None: 295 | for output in self.outputs.values(): 296 | output.clear_float_win() 297 | 298 | def clear_virt_outputs(self) -> None: 299 | for cell, output in self.outputs.items(): 300 | output.clear_virt_output(cell.bufno) 301 | 302 | def _get_selected_span(self) -> Optional[CodeCell]: 303 | current_position = self._get_cursor_position() 304 | selected = None 305 | for span in reversed(self.outputs.keys()): 306 | if current_position in span: 307 | selected = span 308 | break 309 | 310 | return selected 311 | 312 | def try_delete_overlapping_cells(self, span: CodeCell) -> bool: 313 | """Delete the code cells in this kernel that overlap with the given span, if overlapping 314 | a currently running cell, return False 315 | Returns: 316 | False if the span overlaps with a currently running cell, True otherwise 317 | """ 318 | for output_span in list(self.outputs.keys()): 319 | if output_span.overlaps(span): 320 | if not self._delete_cell(output_span): 321 | return False 322 | return True 323 | 324 | def _delete_cell(self, cell: CodeCell, quiet=False) -> bool: 325 | """Delete the given cell if it exists _and_ isn't running. If the cell is running, display 326 | an error and return False, otherwise return True""" 327 | if cell in self.outputs and self.outputs[cell].output.status == OutputStatus.RUNNING: 328 | if not quiet: 329 | notify_warn( 330 | self.nvim, 331 | "Cannot delete a running cell. Wait for it to finish or use :MoltenInterrupt before creating an overlapping cell.", 332 | ) 333 | return False 334 | self.outputs[cell].clear_float_win() 335 | self.outputs[cell].clear_virt_output(cell.bufno) 336 | cell.clear_interface(self.highlight_namespace) 337 | del self.outputs[cell] 338 | if self.current_output == cell: 339 | self.current_output = None 340 | if self.selected_cell == cell: 341 | self.selected_cell = None 342 | return True 343 | 344 | def delete_current_cell(self) -> None: 345 | self.selected_cell = self._get_selected_span() 346 | if self.selected_cell is None: 347 | return 348 | self._delete_cell(self.selected_cell) 349 | self.selected_cell = None 350 | 351 | def clear_empty_spans(self) -> None: 352 | for span in list(self.outputs.keys()): 353 | if span.empty(): 354 | self._delete_cell(span, quiet=True) 355 | 356 | def clear_buffer(self, buffer_number: int) -> None: 357 | """Delete all cells from this kernel that are in the given buffer""" 358 | for cell in list(self.outputs.keys()): 359 | if cell.bufno == buffer_number: 360 | self._delete_cell(cell, quiet=True) 361 | 362 | def update_interface(self) -> None: 363 | buffer_numbers = [buf.number for buf in self.buffers] 364 | if self.nvim.current.buffer.number not in buffer_numbers: 365 | return 366 | 367 | if self.nvim.current.window.buffer.number not in buffer_numbers: 368 | return 369 | 370 | self.updating_interface = True 371 | self.clear_empty_spans() 372 | new_selected_cell = self._get_selected_span() 373 | 374 | # Clear the cell we just left 375 | if self.selected_cell != new_selected_cell and self.selected_cell is not None: 376 | if self.selected_cell in self.outputs: 377 | self.outputs[self.selected_cell].clear_float_win() 378 | self.selected_cell.clear_interface(self.highlight_namespace) 379 | 380 | if new_selected_cell is None: 381 | self.should_show_floating_win = False 382 | 383 | self.selected_cell = new_selected_cell 384 | 385 | if ( 386 | self.selected_cell is not None 387 | # Prevent from rendering when it's done 388 | and self.output_statuses.get(self.selected_cell, None) != OutputStatus.DONE 389 | ): 390 | self._show_selected(self.selected_cell) 391 | 392 | if self.options.virt_text_output: 393 | for span, output in self.outputs.items(): 394 | output.show_virtual_output(span.end) 395 | 396 | self.canvas.present() 397 | 398 | self.updating_interface = False 399 | 400 | def on_cursor_moved(self, scrolled=False) -> None: 401 | new_selected_cell = self._get_selected_span() 402 | 403 | if ( 404 | self.selected_cell is None 405 | and new_selected_cell is not None 406 | and self.options.auto_open_output 407 | ): 408 | self.should_show_floating_win = True 409 | 410 | if self.selected_cell == new_selected_cell and new_selected_cell is not None: 411 | if ( 412 | scrolled 413 | and new_selected_cell.end.lineno < self.nvim.funcs.line("w$") 414 | and self.should_show_floating_win 415 | ): 416 | self.update_interface() 417 | return 418 | 419 | self.update_interface() 420 | 421 | def _show_selected(self, span: CodeCell) -> None: 422 | """Show the selected cell. Can only have a selected cell in the current buffer""" 423 | buf = self.nvim.current.buffer 424 | if buf.number not in [b.number for b in self.buffers]: 425 | return 426 | 427 | if span.begin.lineno == span.end.lineno: 428 | self.nvim.funcs.nvim_buf_add_highlight( 429 | buf.number, 430 | self.highlight_namespace, 431 | self.options.hl.cell, 432 | span.begin.lineno, 433 | span.begin.colno, 434 | span.end.colno, 435 | ) 436 | else: 437 | self.nvim.funcs.nvim_buf_add_highlight( 438 | buf.number, 439 | self.highlight_namespace, 440 | self.options.hl.cell, 441 | span.begin.lineno, 442 | span.begin.colno, 443 | -1, 444 | ) 445 | for lineno in range(span.begin.lineno + 1, span.end.lineno): 446 | self.nvim.funcs.nvim_buf_add_highlight( 447 | buf.number, 448 | self.highlight_namespace, 449 | self.options.hl.cell, 450 | lineno, 451 | 0, 452 | -1, 453 | ) 454 | self.nvim.funcs.nvim_buf_add_highlight( 455 | buf.number, 456 | self.highlight_namespace, 457 | self.options.hl.cell, 458 | span.end.lineno, 459 | 0, 460 | span.end.colno, 461 | ) 462 | 463 | if self.should_show_floating_win: 464 | self.outputs[span].show_floating_win(span.end) 465 | else: 466 | self.outputs[span].clear_float_win() 467 | 468 | def _get_content_checksum(self) -> str: 469 | return hashlib.md5( 470 | "\n".join(self.nvim.current.buffer.api.get_lines(0, -1, True)).encode("utf-8") 471 | ).hexdigest() 472 | 473 | 474 | def write_html_from_chunks( 475 | chunks: List[OutputChunk], 476 | alloc_file: Callable[ 477 | [str, str], 478 | "AbstractContextManager[Tuple[str, IO[bytes]]]", 479 | ], 480 | ) -> Optional[str]: 481 | """Build an HTML file from the given chunks. 482 | Returns: the filepath of the HTML file, or none if there is no HTML output in the chunks 483 | """ 484 | text_html = "" 485 | plotly_data = [] 486 | for chunk in chunks: 487 | if chunk.output_type == "display_data" and chunk.jupyter_data: 488 | if "application/vnd.plotly.v1+json" in chunk.jupyter_data: 489 | plotly_data.append(chunk.jupyter_data["application/vnd.plotly.v1+json"]) 490 | if "text/html" in chunk.jupyter_data: 491 | text_html += chunk.jupyter_data["text/html"] 492 | 493 | if len(plotly_data) > 0: 494 | try: 495 | import plotly.graph_objects as go 496 | 497 | html_str = go.Figure(plotly_data[0]["data"]).to_html( 498 | include_plotlyjs="cdn", full_html=False 499 | ) 500 | with alloc_file("html", "w") as (path, file): 501 | file.write(html_str) # type: ignore 502 | return path 503 | except: 504 | pass 505 | 506 | if text_html != "": 507 | with alloc_file("html", "w") as (path, file): 508 | file.write(text_html) # type: ignore 509 | return path 510 | return None 511 | -------------------------------------------------------------------------------- /docs/Notebook-Setup.md: -------------------------------------------------------------------------------- 1 | # Notebook Setup 2 | 3 | _TL;DR at the bottom_ 4 | 5 | > [!NOTE] 6 | > Although I include sample configuration, this is **not** a replacement for reading the 7 | > readme for each plugin that I mention. Please setup each of these plugins individually 8 | > to ensure they're working before trying to use them all together. 9 | 10 | How to edit Jupyter Notebooks (`.ipynb` files) in neovim, using molten to run code, and 11 | load/save code cell output. 12 | 13 | This is how _I_ edit notebooks, and it's tailored to python notebooks. It's the best 14 | experience you can get (in my opinion), but there are some extra features you can get with 15 | other plugins that I don't use but will mention at the bottom. 16 | 17 | ## The promise: 18 | 19 | \> your friend sends you a jupyter notebook file 20 | \> `nvim friends_file.ipynb` 21 | \> you see a markdown representation of the notebook, including code outputs and images 22 | \> you edit the notebook, with LSP autocomplete, and format the code cells before running 23 | your new code, and all the cells below it, watching each cell output update as they run 24 | \> `:wq` 25 | \> You send the `.ipynb` file, complete with your changes and the output of the code you 26 | ran, back to your friend 27 | 28 | https://github.com/benlubas/molten-nvim/assets/56943754/02460b48-0c4e-4edd-80e7-0aeb4464757c 29 | 30 | ## The Setup: 31 | 32 | There are four big things required for a good notebook experience in neovim: 33 | 34 | - Code running 35 | - Output viewing 36 | - LSP features (autocomplete, go to definition/references, rename, format, etc.) in 37 | a plaintext/markdown file 38 | - File format conversion 39 | 40 | ### Code Running And Output Viewing 41 | 42 | Shocker we'll be using molten. A few configuration options will dramatically improve the 43 | notebook experience of this plugin. 44 | 45 | ```lua 46 | -- I find auto open annoying, keep in mind setting this option will require setting 47 | -- a keybind for `:noautocmd MoltenEnterOutput` to open the output again 48 | vim.g.molten_auto_open_output = false 49 | 50 | -- this guide will be using image.nvim 51 | -- Don't forget to setup and install the plugin if you want to view image outputs 52 | vim.g.molten_image_provider = "image.nvim" 53 | 54 | -- optional, I like wrapping. works for virt text and the output window 55 | vim.g.molten_wrap_output = true 56 | 57 | -- Output as virtual text. Allows outputs to always be shown, works with images, but can 58 | -- be buggy with longer images 59 | vim.g.molten_virt_text_output = true 60 | 61 | -- this will make it so the output shows up below the \`\`\` cell delimiter 62 | vim.g.molten_virt_lines_off_by_1 = true 63 | ``` 64 | 65 | Additionally, you will want to setup some keybinds (as always, change the lhs to suit your 66 | needs) to run code and interact with the plugin. **At a minimum you should setup:** 67 | 68 | ```lua 69 | vim.keymap.set("n", "e", ":MoltenEvaluateOperator", { desc = "evaluate operator", silent = true }) 70 | vim.keymap.set("n", "os", ":noautocmd MoltenEnterOutput", { desc = "open output window", silent = true }) 71 | ``` 72 | 73 | But I'd also recommend these ones: 74 | 75 | ```lua 76 | vim.keymap.set("n", "rr", ":MoltenReevaluateCell", { desc = "re-eval cell", silent = true }) 77 | vim.keymap.set("v", "r", ":MoltenEvaluateVisualgv", { desc = "execute visual selection", silent = true }) 78 | vim.keymap.set("n", "oh", ":MoltenHideOutput", { desc = "close output window", silent = true }) 79 | vim.keymap.set("n", "md", ":MoltenDelete", { desc = "delete Molten cell", silent = true }) 80 | 81 | -- if you work with html outputs: 82 | vim.keymap.set("n", "mx", ":MoltenOpenInBrowser", { desc = "open output in browser", silent = true }) 83 | ``` 84 | 85 | ### LSP Features with quarto-nvim 86 | 87 | One of the issues with plaintext notebooks is that you end up essentially editing 88 | a markdown file, and the pyright language server (for example) can't read a markdown file 89 | and give you information about the python code cells in it. Enter Quarto, and specifically 90 | quarto-nvim. 91 | 92 | [Quarto](https://quarto.org/) is a lot of things. One of those is tool for writing and 93 | publishing literate programming documents, or just any markdown document really. It's 94 | built on top of Pandoc, and so can render markdown to pdf, html, or any format that Pandoc 95 | supports. 96 | 97 | The neovim plugin [quarto-nvim](https://github.com/quarto-dev/quarto-nvim) provides: 98 | 99 | - LSP Autocomplete, formatting, diagnostics, go to definition, and other LSP features for 100 | code cells in markdown documents via [otter.nvim](https://github.com/jmbuhr/otter.nvim) 101 | - A code running integration with molten (written by me, so I'll provide support if there 102 | are problems/bugs) to easily run code cells (including run above, run below, run all) 103 | - A convenient way to render the file you're working on 104 | 105 |
106 | Sample configuration for quarto-nvim 107 | 108 | ```lua 109 | local quarto = require("quarto") 110 | quarto.setup({ 111 | lspFeatures = { 112 | -- NOTE: put whatever languages you want here: 113 | languages = { "r", "python", "rust" }, 114 | chunks = "all", 115 | diagnostics = { 116 | enabled = true, 117 | triggers = { "BufWritePost" }, 118 | }, 119 | completion = { 120 | enabled = true, 121 | }, 122 | }, 123 | keymap = { 124 | -- NOTE: setup your own keymaps: 125 | hover = "H", 126 | definition = "gd", 127 | rename = "rn", 128 | references = "gr", 129 | format = "gf", 130 | }, 131 | codeRunner = { 132 | enabled = true, 133 | default_method = "molten", 134 | }, 135 | }) 136 | ``` 137 | 138 |
139 | 140 | When you configure quarto, you gain access to these functions which should be mapped to 141 | commands: 142 | 143 | ```lua 144 | local runner = require("quarto.runner") 145 | vim.keymap.set("n", "rc", runner.run_cell, { desc = "run cell", silent = true }) 146 | vim.keymap.set("n", "ra", runner.run_above, { desc = "run cell and above", silent = true }) 147 | vim.keymap.set("n", "rA", runner.run_all, { desc = "run all cells", silent = true }) 148 | vim.keymap.set("n", "rl", runner.run_line, { desc = "run line", silent = true }) 149 | vim.keymap.set("v", "r", runner.run_range, { desc = "run visual range", silent = true }) 150 | vim.keymap.set("n", "RA", function() 151 | runner.run_all(true) 152 | end, { desc = "run all cells of all languages", silent = true }) 153 | ``` 154 | 155 | #### Activate Quarto-nvim in markdown buffers 156 | 157 | By default, quarto only activates in `quarto` buffers. 158 | 159 | We will do this with an ftplugin. 160 | 161 | > [!NOTE] 162 | > In order to do this, you must make sure that quarto is loaded for markdown filetypes 163 | > (ie. if you're using lazy.nvim, use `ft = {"quarto", "markdown"}`) 164 | 165 | ```lua 166 | -- file: nvim/ftplugin/markdown.lua 167 | 168 | require("quarto").activate() 169 | ``` 170 | 171 | ### Notebook Conversion 172 | 173 | [GCBallesteros/jupytext.nvim](https://github.com/GCBallesteros/jupytext.nvim) is a plugin 174 | that will automatically convert from `ipynb` files to plaintext (markdown) files, and then 175 | back again when you save. By default, it converts to python files, but we will configure 176 | the plugin to produce a markdown representation. 177 | 178 | ```lua 179 | require("jupytext").setup({ 180 | style = "markdown", 181 | output_extension = "md", 182 | force_ft = "markdown", 183 | }) 184 | ``` 185 | 186 | > [!NOTE] 187 | > Jupytext can convert to the Quarto format, but it's slow enough to notice, on open _and_ 188 | > on save, so I prefer markdown 189 | 190 | Because Jupytext generates markdown files, we get the full benefits of quarto-nvim when 191 | using Jupytext. 192 | 193 | ### Extras 194 | 195 | #### Treesitter Text Objects 196 | 197 | [Treesitter text objects](https://github.com/nvim-treesitter/nvim-treesitter-textobjects) help quickly 198 | navigate cells, copy their contents, delete them, move them around, and run code with 199 | `:MoltenEvaluateOperator`. 200 | 201 | We'll first want to define a new capture group `@code_cell` for the filetype we want to 202 | run code in. Here's a very simple example for markdown, but you can do this with any 203 | filetype you want to have a code cell in: 204 | 205 | _located in: nvim/after/queries/markdown/textobjects.scm_ 206 | ```scm 207 | ;; extends 208 | 209 | (fenced_code_block (code_fence_content) @code_cell.inner) @code_cell.outer 210 | ``` 211 | 212 | We can now use `@code_cell.inner` and `@code_cell.outer` in the treesitter-text-objects 213 | plugin like so, I use b, you can use whatever mappings you like: 214 | 215 | ```lua 216 | require("nvim-treesitter.configs").setup({ 217 | -- ... other ts config 218 | textobjects = { 219 | move = { 220 | enable = true, 221 | set_jumps = false, -- you can change this if you want. 222 | goto_next_start = { 223 | --- ... other keymaps 224 | ["]b"] = { query = "@code_cell.inner", desc = "next code block" }, 225 | }, 226 | goto_previous_start = { 227 | --- ... other keymaps 228 | ["[b"] = { query = "@code_cell.inner", desc = "previous code block" }, 229 | }, 230 | }, 231 | select = { 232 | enable = true, 233 | lookahead = true, -- you can change this if you want 234 | keymaps = { 235 | --- ... other keymaps 236 | ["ib"] = { query = "@code_cell.inner", desc = "in block" }, 237 | ["ab"] = { query = "@code_cell.outer", desc = "around block" }, 238 | }, 239 | }, 240 | swap = { -- Swap only works with code blocks that are under the same 241 | -- markdown header 242 | enable = true, 243 | swap_next = { 244 | --- ... other keymap 245 | ["sbl"] = "@code_cell.outer", 246 | }, 247 | swap_previous = { 248 | --- ... other keymap 249 | ["sbh"] = "@code_cell.outer", 250 | }, 251 | }, 252 | } 253 | }) 254 | ``` 255 | 256 | Test it by selecting the insides of a code cell with `vib`, or run them with 257 | `:MoltenEvaluateOperatorib`. 258 | 259 | #### Output Chunks 260 | 261 | Saving output chunks has historically not been possible (afaik) with plaintext notebooks. 262 | You will lose output chunks in a round trip from `ipynb` to `qmd` to `ipynb`. And while 263 | that's still true, we can work around it. 264 | 265 | Jupytext _updates_ notebooks and doesn't destroy outputs that already exist, and Molten 266 | can both **import outputs** from a notebook AND **export outputs from code you run** to a jupyter 267 | notebook file. More details about how and when this works on the [advanced 268 | functionality](./Advanced-Functionality.md#importingexporting-outputs-tofrom-ipynb-files) 269 | page. 270 | 271 | We can make importing/exporting outputs seamless with a few autocommands: 272 | 273 | ```lua 274 | -- automatically import output chunks from a jupyter notebook 275 | -- tries to find a kernel that matches the kernel in the jupyter notebook 276 | -- falls back to a kernel that matches the name of the active venv (if any) 277 | local imb = function(e) -- init molten buffer 278 | vim.schedule(function() 279 | local kernels = vim.fn.MoltenAvailableKernels() 280 | local try_kernel_name = function() 281 | local metadata = vim.json.decode(io.open(e.file, "r"):read("a"))["metadata"] 282 | return metadata.kernelspec.name 283 | end 284 | local ok, kernel_name = pcall(try_kernel_name) 285 | if not ok or not vim.tbl_contains(kernels, kernel_name) then 286 | kernel_name = nil 287 | local venv = os.getenv("VIRTUAL_ENV") or os.getenv("CONDA_PREFIX") 288 | if venv ~= nil then 289 | kernel_name = string.match(venv, "/.+/(.+)") 290 | end 291 | end 292 | if kernel_name ~= nil and vim.tbl_contains(kernels, kernel_name) then 293 | vim.cmd(("MoltenInit %s"):format(kernel_name)) 294 | end 295 | vim.cmd("MoltenImportOutput") 296 | end) 297 | end 298 | 299 | -- automatically import output chunks from a jupyter notebook 300 | vim.api.nvim_create_autocmd("BufAdd", { 301 | pattern = { "*.ipynb" }, 302 | callback = imb, 303 | }) 304 | 305 | -- we have to do this as well so that we catch files opened like nvim ./hi.ipynb 306 | vim.api.nvim_create_autocmd("BufEnter", { 307 | pattern = { "*.ipynb" }, 308 | callback = function(e) 309 | if vim.api.nvim_get_vvar("vim_did_enter") ~= 1 then 310 | imb(e) 311 | end 312 | end, 313 | }) 314 | ``` 315 | 316 | > [!NOTE] 317 | > If no matching kernel is found, this will prompt you for a kernel to start 318 | 319 | ```lua 320 | -- automatically export output chunks to a jupyter notebook on write 321 | vim.api.nvim_create_autocmd("BufWritePost", { 322 | pattern = { "*.ipynb" }, 323 | callback = function() 324 | if require("molten.status").initialized() == "Molten" then 325 | vim.cmd("MoltenExportOutput!") 326 | end 327 | end, 328 | }) 329 | ``` 330 | 331 | > [!WARNING] 332 | > This export, in conjunction with the jupytext conversion, can make saving lag the editor 333 | > for ~500ms, so autosave plugins can cause a bad experience. 334 | 335 | > [!NOTE] 336 | > If you have more than one kernel active this will prompt you for a kernel to choose 337 | > from 338 | 339 | #### Hydra 340 | 341 | The [Hydra](https://github.com/nvimtools/hydra.nvim) plugin allows very quick navigation 342 | and code running. 343 | 344 | I have a detailed explanation of how to set this up 345 | [on the quarto-nvim wiki](https://github.com/quarto-dev/quarto-nvim/wiki/Integrating-with-Hydra). 346 | Recommend setting up treesitter-text-objects before following that. 347 | 348 | #### Disable Annoying Pyright Diagnostic 349 | 350 | It's very common to leave an unused expression at the bottom of a cell as a way of 351 | printing the value. Pyright will yell at you for this. Fortunately we can configure it to 352 | not do that. Just add this option to whatever existing configuration you have: 353 | 354 | ```lua 355 | require("lspconfig")["pyright"].setup({ 356 | on_attach = on_attach, 357 | capabilities = capabilities, 358 | settings = { 359 | python = { 360 | analysis = { 361 | diagnosticSeverityOverrides = { 362 | reportUnusedExpression = "none", 363 | }, 364 | }, 365 | }, 366 | }, 367 | }) 368 | ``` 369 | 370 | #### Change Molten settings based on filetype 371 | 372 | Molten is a multi purpose code runner, I use it in regular python files to quickly test 373 | out a line of code. In those situations, creating virtual text is obnoxious, and I'd 374 | rather have output shown in a float that disappears when I move away. 375 | 376 | Autocommands to the rescue: 377 | 378 | ```lua 379 | -- change the configuration when editing a python file 380 | vim.api.nvim_create_autocmd("BufEnter", { 381 | pattern = "*.py", 382 | callback = function(e) 383 | if string.match(e.file, ".otter.") then 384 | return 385 | end 386 | if require("molten.status").initialized() == "Molten" then -- this is kinda a hack... 387 | vim.fn.MoltenUpdateOption("virt_lines_off_by_1", false) 388 | vim.fn.MoltenUpdateOption("virt_text_output", false) 389 | else 390 | vim.g.molten_virt_lines_off_by_1 = false 391 | vim.g.molten_virt_text_output = false 392 | end 393 | end, 394 | }) 395 | 396 | -- Undo those config changes when we go back to a markdown or quarto file 397 | vim.api.nvim_create_autocmd("BufEnter", { 398 | pattern = { "*.qmd", "*.md", "*.ipynb" }, 399 | callback = function(e) 400 | if string.match(e.file, ".otter.") then 401 | return 402 | end 403 | if require("molten.status").initialized() == "Molten" then 404 | vim.fn.MoltenUpdateOption("virt_lines_off_by_1", true) 405 | vim.fn.MoltenUpdateOption("virt_text_output", true) 406 | else 407 | vim.g.molten_virt_lines_off_by_1 = true 408 | vim.g.molten_virt_text_output = true 409 | end 410 | end, 411 | }) 412 | ``` 413 | 414 | #### Creating new notebooks 415 | 416 | Since Jupytext needs a valid notebook file to convert, creating a blank new notebook is not as easy as making an empty buffer and loading it up. 417 | 418 | To simplify this workflow, you can define a vim user command to create an empty, but valid, notebook file and open it: 419 | 420 | ```lua 421 | -- Provide a command to create a blank new Python notebook 422 | -- note: the metadata is needed for Jupytext to understand how to parse the notebook. 423 | -- if you use another language than Python, you should change it in the template. 424 | local default_notebook = [[ 425 | { 426 | "cells": [ 427 | { 428 | "cell_type": "markdown", 429 | "metadata": {}, 430 | "source": [ 431 | "" 432 | ] 433 | } 434 | ], 435 | "metadata": { 436 | "kernelspec": { 437 | "display_name": "Python 3", 438 | "language": "python", 439 | "name": "python3" 440 | }, 441 | "language_info": { 442 | "codemirror_mode": { 443 | "name": "ipython" 444 | }, 445 | "file_extension": ".py", 446 | "mimetype": "text/x-python", 447 | "name": "python", 448 | "nbconvert_exporter": "python", 449 | "pygments_lexer": "ipython3" 450 | } 451 | }, 452 | "nbformat": 4, 453 | "nbformat_minor": 5 454 | } 455 | ]] 456 | 457 | local function new_notebook(filename) 458 | local path = filename .. ".ipynb" 459 | local file = io.open(path, "w") 460 | if file then 461 | file:write(default_notebook) 462 | file:close() 463 | vim.cmd("edit " .. path) 464 | else 465 | print("Error: Could not open new notebook file for writing.") 466 | end 467 | end 468 | 469 | vim.api.nvim_create_user_command('NewNotebook', function(opts) 470 | new_notebook(opts.args) 471 | end, { 472 | nargs = 1, 473 | complete = 'file' 474 | }) 475 | ``` 476 | 477 | You can then use `:NewNotebook folder/notebook_name` to start a new notebook from scratch! 478 | 479 | ## Compromises 480 | 481 | Compared to Jupyter-lab: 482 | 483 | - output formats. Molten can't render everything that jupyter-lab can, specifically 484 | in-editor HTML is just not going to happen 485 | - Markdown and latex-in-markdown rendering. Currently you can render latex, but you have 486 | to send it to the kernel. It doesn't happen automatically. 487 | - jank. the UI is definitely worse, and sometimes images will move somewhere weird until 488 | you scroll. Molten is still relatively new, and bugs are still being ironed out. 489 | - setup is a lot of work. I've mentioned ~4~ 5 different plugins that are required to get 490 | this working and all 4 of those plugins have external dependencies. 491 | 492 | ## Honorable Mentions 493 | 494 | Plugins that didn't quite make it into my workflow, but which are still really good and 495 | worth looking at. 496 | 497 | - [jupyter-kernel.nvim](https://github.com/lkhphuc/jupyter-kernel.nvim) - this plugin adds 498 | autocomplete from the jupyter kernel, as well as hover inspections from the jupyter 499 | kernel. Me personally, I'd rather just use pyright via quarto-nvim/otter.nvim. This 500 | plugin could co-exist with the current setup, but might lead to double completions, and so 501 | you might want to disable quarto's lsp features if you choose to use this plugin 502 | - [NotebookNavigator.nvim](https://github.com/GCBallesteros/NotebookNavigator.nvim) - 503 | a plugin for editing notebooks as a different plaintext format which defines cells using 504 | comments in the native language of the notebook. This plugin would be used in place of 505 | quarto-nvim, as language servers just work in a `.py` file. I prefer to edit markdown 506 | notebooks, and the point of notebooks to me is the markdown component, and having markdown 507 | shown as comments without syntax highlighting is a deal breaker. 508 | 509 | ## TL;DR 510 | 511 | molten-nvim + image.nvim + quarto-nvim (+ otter.nvim) + jupytext.nvim = great notebook experience, 512 | unfortunately, it does take some time to setup. 513 | 514 | 515 | -------------------------------------------------------------------------------- /rplugin/python3/molten/outputbuffer.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, List, Optional, Tuple, Union, Callable 3 | 4 | from pynvim import Nvim 5 | from pynvim.api import Buffer, Window 6 | 7 | from molten.images import Canvas 8 | from molten.outputchunks import ImageOutputChunk, Output, OutputStatus 9 | from molten.options import MoltenOptions 10 | from molten.position import DynamicPosition, Position 11 | from molten.utils import notify_error 12 | 13 | 14 | def truncate_bottom(lines: list[str], text_max_lines: int) -> list[str]: 15 | truncated_lines = lines[: text_max_lines - 1] 16 | truncated_lines.append(f"󰁅 {len(lines) - text_max_lines + 1} More lines ") 17 | return truncated_lines 18 | 19 | 20 | def truncate_top(lines: list[str], text_max_lines: int): 21 | truncated_lines = [lines[0]] 22 | truncated_lines.append(f"↑ {len(lines) - text_max_lines} More lines") 23 | truncated_lines.extend(lines[-text_max_lines + 2 :]) 24 | return truncated_lines 25 | 26 | 27 | class OutputBuffer: 28 | nvim: Nvim 29 | canvas: Canvas 30 | 31 | output: Output 32 | 33 | display_buf: Buffer 34 | display_win: Optional[Window] 35 | display_virt_lines: Optional[DynamicPosition] 36 | extmark_namespace: int 37 | virt_text_id: Optional[int] 38 | displayed_status: OutputStatus 39 | 40 | options: MoltenOptions 41 | lua: Any 42 | 43 | def __init__(self, nvim: Nvim, canvas: Canvas, extmark_namespace: int, options: MoltenOptions): 44 | self.nvim = nvim 45 | self.canvas = canvas 46 | 47 | self.output = Output(None) 48 | 49 | self.display_buf = self.nvim.buffers[self.nvim.funcs.nvim_create_buf(False, True)] 50 | self.display_win: Window | None = None 51 | self.display_virt_lines = None 52 | self.virt_hidden: bool = False 53 | self.extmark_namespace = extmark_namespace 54 | self.virt_text_id = None 55 | self.displayed_status = OutputStatus.HOLD 56 | 57 | self.options = options 58 | self.nvim.exec_lua("_ow = require('output_window')") 59 | self.lua = self.nvim.lua._ow 60 | 61 | self.truncate_lines: Callable[[list[str], int], list[str]] 62 | if self.options.virt_text_truncate == "bottom": 63 | self.truncate_lines = truncate_bottom 64 | elif self.options.virt_text_truncate == "top": 65 | self.truncate_lines = truncate_top 66 | else: 67 | raise ValueError("Wrong virtual text truncate option") 68 | 69 | def _buffer_to_window_lineno(self, lineno: int) -> int: 70 | return self.lua.calculate_window_position(lineno) 71 | 72 | def _get_header_text(self, output: Output) -> str: 73 | if output.execution_count is None: 74 | execution_count = "..." 75 | else: 76 | execution_count = str(output.execution_count) 77 | 78 | match output.status: 79 | case OutputStatus.HOLD: 80 | status = "* On Hold" 81 | case OutputStatus.DONE: 82 | if output.success: 83 | status = "✓ Done" 84 | else: 85 | status = "✗ Failed" 86 | case OutputStatus.RUNNING: 87 | status = "... Running" 88 | case OutputStatus.NEW: 89 | status = "" 90 | case _: 91 | raise ValueError("bad output.status: %s" % output.status) 92 | 93 | if output.old: 94 | old = "[OLD] " 95 | else: 96 | old = "" 97 | 98 | if not output.old and self.options.output_show_exec_time and output.start_time: 99 | start = output.start_time 100 | end = output.end_time if output.end_time is not None else datetime.now() 101 | diff = end - start 102 | 103 | days = diff.days 104 | hours = diff.seconds // 3600 105 | minutes = diff.seconds // 60 106 | seconds = diff.seconds - hours * 3600 - minutes * 60 107 | microseconds = diff.microseconds 108 | 109 | time = "" 110 | 111 | # Days 112 | if days: 113 | time += f"{days}d " 114 | if hours: 115 | time += f"{hours}hr " 116 | if minutes: 117 | time += f"{minutes}m " 118 | 119 | # Microseconds is an int, roundabout way to round to 2 digits 120 | time += f"{seconds}.{int(round(microseconds, -4) / 10000)}s" 121 | else: 122 | time = "" 123 | 124 | if output.status == OutputStatus.NEW: 125 | return f"Out[_]: Never Run" 126 | else: 127 | return f"{old}Out[{execution_count}]: {status} {time}".rstrip() 128 | 129 | def enter(self, anchor: Position) -> bool: 130 | entered = False 131 | if self.display_win is None: 132 | if self.options.enter_output_behavior == "open_then_enter": 133 | self.show_floating_win(anchor) 134 | elif self.options.enter_output_behavior == "open_and_enter": 135 | self.show_floating_win(anchor) 136 | entered = True 137 | self.nvim.funcs.nvim_set_current_win(self.display_win) 138 | elif self.options.enter_output_behavior != "no_open": 139 | entered = True 140 | self.nvim.funcs.nvim_set_current_win(self.display_win) 141 | if entered: 142 | if self.options.output_show_more: 143 | self.remove_window_footer() 144 | if self.options.output_win_hide_on_leave: 145 | return False 146 | return True 147 | 148 | def clear_float_win(self) -> None: 149 | if self.display_win is not None: 150 | if self.display_win.valid: 151 | self.nvim.funcs.nvim_win_close(self.display_win, True) 152 | self.display_win = None 153 | redraw = False 154 | for chunk in self.output.chunks: 155 | if isinstance(chunk, ImageOutputChunk) and chunk.img_identifier is not None: 156 | self.canvas.remove_image(chunk.img_identifier) 157 | redraw = True 158 | if redraw: 159 | self.canvas.present() 160 | if self.display_virt_lines is not None: 161 | del self.display_virt_lines 162 | self.display_virt_lines = None 163 | 164 | def clear_virt_output(self, bufnr: int) -> None: 165 | if self.virt_text_id is not None: 166 | # remove the extmark… 167 | self.nvim.funcs.nvim_buf_del_extmark(bufnr, self.extmark_namespace, self.virt_text_id) 168 | # …and clear our flag so show_virtual_output can re-add it 169 | self.virt_text_id = None 170 | # (optional) reset displayed_status so your guard won’t block: 171 | # self.displayed_status = OutputStatus.NEW 172 | self.virt_hidden = True 173 | 174 | # clear any inline images, etc. 175 | redraw = False 176 | for chunk in self.output.chunks: 177 | if isinstance(chunk, ImageOutputChunk) and chunk.img_identifier is not None: 178 | self.canvas.remove_image(chunk.img_identifier) 179 | redraw = True 180 | if redraw: 181 | self.canvas.present() 182 | 183 | def toggle_virtual_output(self, anchor: Position) -> None: 184 | if self.virt_hidden: 185 | # currently suppressed ⇒ un‐suppress and show 186 | self.virt_hidden = False 187 | self.show_virtual_output(anchor) 188 | else: 189 | # currently visible (or default) ⇒ hide and suppress 190 | self.clear_virt_output(anchor.bufno) 191 | # clear_virtual_output already set virt_hidden=True 192 | 193 | def set_win_option(self, option: str, value) -> None: 194 | if self.display_win: 195 | self.nvim.api.set_option_value( 196 | option, 197 | value, 198 | {"scope": "local", "win": self.display_win.handle}, 199 | ) 200 | 201 | def build_output_text(self, shape, buf: int, virtual: bool) -> Tuple[List[str], int]: 202 | lineno = 1 # we add a status line at the top in the end 203 | lines_str = "" 204 | # images are rendered with virtual lines by image.nvim 205 | virtual_lines = 0 206 | if len(self.output.chunks) > 0: 207 | x = 0 208 | for chunk in self.output.chunks: 209 | y = lineno 210 | if virtual: 211 | y = shape[1] 212 | chunktext, virt_lines = chunk.place( 213 | buf, 214 | self.options, 215 | x, 216 | y, 217 | shape, 218 | self.canvas, 219 | virtual, 220 | winnr=self.nvim.current.window.handle if virtual else None, 221 | ) 222 | lines_str += chunktext 223 | lineno += chunktext.count("\n") 224 | virtual_lines += virt_lines 225 | x = len(lines_str) - lines_str.rfind("\n") 226 | 227 | limit = self.options.limit_output_chars 228 | if limit and len(lines_str) > limit: 229 | lines_str = lines_str[:limit] 230 | lines_str += f"\n...truncated to {limit} chars\n" 231 | 232 | lines = lines_str.split("\n") 233 | lineno = len(lines) + virtual_lines 234 | else: 235 | lines = [] 236 | 237 | # Remove trailing empty lines 238 | while len(lines) > 0 and lines[-1] == "": 239 | lines.pop() 240 | 241 | # HACK: add an extra line for snacks image in windows 242 | if self.options.image_provider == "snacks.nvim": 243 | lines.append("") 244 | 245 | lines.insert(0, self._get_header_text(self.output)) 246 | return lines, len(lines) - 1 + virtual_lines 247 | 248 | def show_virtual_output(self, anchor: Position) -> None: 249 | if self.virt_hidden: 250 | return 251 | if self.displayed_status == OutputStatus.DONE and self.virt_text_id is not None: 252 | return 253 | offset = self.calculate_offset(anchor) if self.options.cover_empty_lines else 0 254 | self.displayed_status = self.output.status 255 | 256 | buf = self.nvim.buffers[anchor.bufno] 257 | 258 | # clear the existing virtual text 259 | if self.virt_text_id is not None: 260 | self.nvim.funcs.nvim_buf_del_extmark( 261 | anchor.bufno, self.extmark_namespace, self.virt_text_id 262 | ) 263 | self.virt_text_id = None 264 | 265 | win = self.nvim.current.window 266 | win_info = self.nvim.funcs.getwininfo(win.handle)[0] 267 | win_col = win_info["wincol"] 268 | win_row = anchor.lineno + offset 269 | win_width = win_info["width"] - win_info["textoff"] 270 | win_height = win_info["height"] 271 | last = self.nvim.funcs.line("$") 272 | 273 | if self.options.virt_lines_off_by_1 and win_row < last - 1: 274 | win_row += 1 275 | 276 | if win_row > last: 277 | win_row = last 278 | 279 | shape = ( 280 | win_col, 281 | win_row, 282 | win_width, 283 | win_height, 284 | ) 285 | lines, _ = self.build_output_text(shape, anchor.bufno, True) 286 | 287 | if len(lines) > self.options.virt_text_max_lines: 288 | lines = self.truncate_lines(lines, self.options.virt_text_max_lines) 289 | 290 | self.virt_text_id = buf.api.set_extmark( 291 | self.extmark_namespace, 292 | win_row, 293 | 0, 294 | { 295 | "virt_lines": [[(line, self.options.hl.virtual_text)] for line in lines], 296 | }, 297 | ) 298 | self.canvas.present() 299 | 300 | def calculate_offset(self, anchor: Position) -> int: 301 | offset = 0 302 | lineno = anchor.lineno 303 | while lineno > 0: 304 | current_line = self.nvim.funcs.nvim_buf_get_lines( 305 | anchor.bufno, 306 | lineno, 307 | lineno + 1, 308 | False, 309 | )[0] 310 | is_comment = False 311 | for x in self.options.cover_lines_starting_with: 312 | if current_line.startswith(x): 313 | is_comment = True 314 | break 315 | if current_line != "" and not is_comment: 316 | return offset 317 | else: 318 | lineno -= 1 319 | offset -= 1 320 | # Only get here if current_pos.lineno == 0 321 | return 0 322 | 323 | def show_floating_win(self, anchor: Position) -> None: 324 | win = self.nvim.current.window 325 | win_col = 0 326 | offset = 0 327 | if self.options.cover_empty_lines: 328 | offset = self.calculate_offset(anchor) 329 | win_row = self._buffer_to_window_lineno(anchor.lineno + offset) + 1 330 | else: 331 | win_row = self._buffer_to_window_lineno(anchor.lineno + 1) 332 | 333 | if win_row <= 0: # anchor position is off screen 334 | return 335 | win_width = win.width 336 | win_height = win.height 337 | 338 | border_w, border_h = border_size(self.options.output_win_border) 339 | 340 | win_height -= border_h 341 | win_width -= border_w 342 | 343 | # Clear buffer: 344 | self.display_buf.api.set_lines(0, -1, False, []) 345 | 346 | sign_col_width = 0 347 | text_off = self.nvim.funcs.getwininfo(win.handle)[0]["textoff"] 348 | if not self.options.output_win_cover_gutter: 349 | sign_col_width = text_off 350 | 351 | shape = ( 352 | win_col + sign_col_width, 353 | win_row, 354 | win_width - sign_col_width, 355 | win_height, 356 | ) 357 | lines, real_height = self.build_output_text(shape, self.display_buf.number, False) 358 | 359 | # You can't append lines normally, there will be a blank line at the top 360 | self.display_buf[0] = lines[0] 361 | self.display_buf.append(lines[1:]) 362 | self.nvim.api.set_option_value( 363 | "filetype", "molten_output", {"buf": self.display_buf.handle} 364 | ) 365 | 366 | # Open output window 367 | # assert self.display_window is None 368 | if not win_row < win_height: 369 | return 370 | 371 | border = self.options.output_win_border 372 | zindex = self.options.output_win_zindex 373 | max_height = min(real_height + 1, self.options.output_win_max_height) 374 | height = min(win_height - win_row, max_height) 375 | 376 | cropped = False 377 | if height == win_height - win_row and max_height > height: # It didn't fit on the screen 378 | if self.options.output_crop_border and type(border) is list: 379 | cropped = True 380 | # Expand the border, so top and bottom can change independently 381 | border = [border[i % len(border)] for i in range(8)] 382 | border[5 % len(border)] = "" 383 | height += 1 384 | 385 | if self.options.use_border_highlights: 386 | border = self.set_border_highlight(border) 387 | 388 | win_opts = { 389 | "relative": "win", 390 | "row": shape[1], 391 | "col": shape[0], 392 | "width": min(shape[2], self.options.output_win_max_width), 393 | "height": height, 394 | "border": border, 395 | "focusable": True, 396 | "zindex": zindex, 397 | } 398 | if self.options.output_win_style: 399 | win_opts["style"] = self.options.output_win_style 400 | if ( 401 | self.options.output_show_more 402 | and not cropped 403 | and height == self.options.output_win_max_height 404 | ): 405 | # the entire window size is shown, but the buffer still has more lines to render 406 | hidden_lines = len(self.display_buf) - height 407 | if self.options.output_win_cover_gutter and type(border) == list: 408 | border_pad = border[5 % len(border)][0] * text_off 409 | win_opts["footer"] = [ 410 | (border_pad, border[5 % len(border)][1]), 411 | (f" 󰁅 {hidden_lines} More Lines ", self.options.hl.foot), 412 | ] 413 | else: 414 | win_opts["footer"] = [(f" 󰁅 {hidden_lines} More Lines ", self.options.hl.foot)] 415 | win_opts["footer_pos"] = "left" 416 | 417 | if self.display_win is None or not self.display_win.valid: # open a new window 418 | window: Window = self.nvim.api.open_win( 419 | self.display_buf.number, 420 | False, 421 | win_opts, 422 | ) 423 | self.display_win = window 424 | 425 | hl = self.options.hl 426 | self.set_win_option("winhighlight", f"Normal:{hl.win},NormalNC:{hl.win_nc}") 427 | # TODO: Refactor once MoltenOutputWindowOpen autocommand is a thing. 428 | # note, the above setting will probably stay there, just so users can set highlights 429 | # with their other highlights 430 | self.set_win_option("wrap", self.options.wrap_output) 431 | self.set_win_option("cursorline", False) 432 | self.canvas.present() 433 | else: # move the current window 434 | self.display_win.api.set_config(win_opts) 435 | 436 | if self.display_virt_lines is not None: 437 | del self.display_virt_lines 438 | self.display_virt_lines = None 439 | 440 | if self.options.output_virt_lines or self.options.cover_empty_lines: 441 | virt_lines_y = anchor.lineno 442 | if self.options.cover_empty_lines: 443 | virt_lines_y += offset 444 | virt_lines_height = max_height + border_h 445 | if self.options.virt_lines_off_by_1: 446 | virt_lines_y += 1 447 | virt_lines_height -= 1 448 | self.display_virt_lines = DynamicPosition( 449 | self.nvim, self.extmark_namespace, anchor.bufno, virt_lines_y, 0 450 | ) 451 | self.display_virt_lines.set_height(virt_lines_height) 452 | 453 | if self.options.floating_window_focus == "top": 454 | self.display_win.api.set_cursor((1, 0)) 455 | 456 | elif self.options.floating_window_focus == "bottom": 457 | self.display_win.api.set_cursor((len(self.display_buf), 0)) 458 | 459 | def set_border_highlight(self, border): 460 | hl = self.options.hl.border_norm 461 | if not self.output.success: 462 | hl = self.options.hl.border_fail 463 | elif self.output.status == OutputStatus.DONE: 464 | hl = self.options.hl.border_succ 465 | 466 | if type(border) == str: 467 | notify_error( 468 | self.nvim, 469 | "`use_border_highlights` only works when `output_win_border` is specified as a table", 470 | ) 471 | return border 472 | 473 | for i in range(len(border)): 474 | match border[i]: 475 | case [str(_), *_]: 476 | border[i][1] = hl 477 | case str(_): 478 | border[i] = [border[i], hl] 479 | 480 | return border 481 | 482 | def remove_window_footer(self) -> None: 483 | if self.display_win is not None: 484 | self.display_win.api.set_config({"footer": ""}) 485 | 486 | 487 | def border_size(border: Union[str, List[str], List[List[str]]]): 488 | width, height = 0, 0 489 | match border: 490 | case list(b): 491 | height += border_char_size(1, b) 492 | height += border_char_size(5, b) 493 | width += border_char_size(7, b) 494 | width += border_char_size(3, b) 495 | case "rounded" | "single" | "double" | "solid": 496 | height += 2 497 | width += 2 498 | case "shadow": 499 | height += 1 500 | width += 1 501 | return width, height 502 | 503 | 504 | def border_char_size(index: int, border: Union[List[str], List[List[str]]]): 505 | match border[index % len(border)]: 506 | case str(ch) | [str(ch), _]: 507 | return len(ch) 508 | case _: 509 | return 0 510 | --------------------------------------------------------------------------------