Skip to content

Commit

Permalink
feat: wezterm image provider (#162)
Browse files Browse the repository at this point in the history
Co-authored-by: Ben Lubas <56943754+benlubas@users.noreply.github.com>
  • Loading branch information
akthe-at and benlubas authored Mar 30, 2024
1 parent 5bc04c9 commit 4ef66a1
Show file tree
Hide file tree
Showing 8 changed files with 281 additions and 18 deletions.
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ https://github.com/benlubas/molten-nvim/assets/56943754/17ae81c0-306f-4496-bce8-

- Send code to run asynchronously in the jupyter kernel
- See output below the code in real time, without flicker, as virtual text or in a floating
window (or both)
window (or both)
- Renders images, plots, and LaTeX in neovim
- Take input from stdin with `vim.ui.input`
- Send code from multiple buffers to the same kernel
Expand All @@ -23,7 +23,10 @@ window (or both)

- NeoVim 9.4+
- Python 3.10+
- [image.nvim](https://github.com/3rd/image.nvim) is only required if you want to render images
- [image.nvim](https://github.com/3rd/image.nvim) is only required for the `image.nvim` image
provider
- [wezterm.nvim](https://github.com/willothy/wezterm.nvim) is only required for the `wezterm` image
provider
- Required Python packages (can be installed in a venv. [read more](./docs/Virtual-Environments.md)):
- [`pynvim`](https://github.com/neovim/pynvim) (for the Remote Plugin API)
- [`jupyter_client`](https://github.com/jupyter/jupyter_client) (for interacting with Jupyter)
Expand Down Expand Up @@ -178,7 +181,7 @@ variable, their values, and a brief description.
| `g:molten_cover_lines_starting_with` | (`{}`) \| array of str | When `cover_empty_lines` is true, also covers lines starting with these strings |
| `g:molten_copy_output` | `true` \| (`false`) | Copy evaluation output to clipboard automatically (requires [`pyperclip`](#requirements))|
| `g:molten_enter_output_behavior` | (`"open_then_enter"`) \| `"open_and_enter"` \| `"no_open"` | The behavior of [MoltenEnterOutput](#moltenenteroutput) |
| `g:molten_image_provider` | (`"none"`) \| `"image.nvim"` | How images are displayed |
| `g:molten_image_provider` | (`"none"`) \| `"image.nvim"` \| `"wezterm"` \| | How images are displayed see [Images](#images) for more details |
| `g:molten_open_cmd` | (`nil`) \| Any command | Defaults to `xdg-open` on Linux, `open` on Darwin, and `start` on Windows. But you can override it to whatever you want. The command is called like: `subprocess.run([open_cmd, filepath])` |
| `g:molten_output_crop_border` | (`true`) \| `false` | 'crops' the bottom border of the output window when it would otherwise just sit at the bottom of the screen |
| `g:molten_output_show_exec_time` | (`true`) \| `false` | Shows the current amount of time since the cell has begun execution |
Expand All @@ -191,15 +194,35 @@ variable, their values, and a brief description.
| `g:molten_output_win_max_width` | (`999999`) \| int | Max width of the output window |
| `g:molten_output_win_style` | (`false`) \| `"minimal"` | Value passed to the `style` option in `:h nvim_open_win()` |
| `g:molten_save_path` | (`stdpath("data").."/molten"`) \| any path to a folder | Where to save/load data with `:MoltenSave` and `:MoltenLoad` |
| `g:molten_tick_rate` | (`500`) \| `int` | How often (in ms) we poll the kernel for updates. Determines how quickly the ui will update, if you want a snappier experience, you can set this to 150 or 200 |
| `g:molten_split_direction` | (`"right"`) \| `"left"` \| `"top"` \| `"bottom"` \| | Direction of the terminal split created by wezterm. *Only applies if `g:molten_image_provider = "wezterm"`* |
| `g:molten_split_size` | (`40`) \| int | (0-100) % size of the screen dedicated to the output window. _Only applies if `g:molten_image_provider = "wezterm"`_ |
| `g:molten_tick_rate` | (`500`) \| int | How often (in ms) we poll the kernel for updates. Determines how quickly the ui will update, if you want a snappier experience, you can set this to 150 or 200 |
| `g:molten_use_border_highlights` | `true` \| (`false`) | When true, uses different highlights for output border depending on the state of the cell (running, done, error). see [highlights](#highlights) |
| `g:molten_limit_output_chars` | (`1000000`) \| int | Limit on the number of chars in an output. If you're lagging your editor with too much output text, decrease it |
| `g:molten_virt_lines_off_by_1` | `true` \| (`false`) | Allows the output window to cover exactly one line of the regular buffer when `output_virt_lines` is true, also effects where `virt_text_output` is displayed. (useful for running code in a markdown file where that covered line will just be \`\`\`) |
| `g:molten_virt_text_output` | `true` \| (`false`) | When true, show output as virtual text below the cell, virtual text stays after leaving the cell. When true, output window doesn't open automatically on run. Effected by `virt_lines_off_by_1` |
| `g:molten_virt_text_max_lines` | (`12`) \| `int` | Max height of the virtual text |
| `g:molten_virt_text_max_lines` | (`12`) \| int | Max height of the virtual text |
| `g:molten_wrap_output` | `true` \| (`false`) | Wrap output text |
| [DEBUG] `g:molten_show_mimetype_debug` | `true` \| (`false`) | Before any non-iostream output chunk, the mime-type for that output chunk is shown. Meant for debugging/plugin devlopment |

### Images

Molten has two image providers, `image.nvim` or `wezterm`:

- `image.nvim` requires the [image.nvim](https://github.com/3rd/image.nvim) plugin (and it's
dependencies). It renders images in neovim inline with other cell output. This creates a better
experience, but it can be buggy with large numbers of images, and it does not work on Windows.

- `wezterm` requires the [wezterm.nvim](https://willothy/wezterm.nvim) plugin (and the wezterm
terminal emulator). It renders images in a wezterm split pane using wezterm's `imgcat` program. This
method is significantly less buggy with large numbers of images and works on Windows, but it doesn't
keep images next to the code they came from.
- Cannot be used with `g:molten_auto_open_output = true`
- Configurable with the `g:molten_split_direction` and `g:molten_split_size` options.
- Currently, the `wezterm` image provider does not integrate with **tmux**. There are issues
with allowing images to passing through tmux to wezterm. If you are using tmux, you will need to
use the `image.nvim` image provider.

### Status Line

Molten provides a few functions that you can use to see information in your status line. These are
Expand Down
56 changes: 48 additions & 8 deletions docs/Windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ two windows issues that, when resolved, would theoretically allow it to work.

### Workarounds

There are three workarounds, choose one
There are four workarounds, choose one. The first two require/only work in WSL. The last two should
work both with and without WSL.
#### Uberzugpp

Uberzugpp should work on WSL. You might have to configure it in a special way.
Expand All @@ -91,10 +92,49 @@ Uberzugpp should work on WSL. You might have to configure it in a special way.
The title of that discussion says it all. This is a cursed method of using kitty in a graphical WSL
session.

#### Just use `:MoltenImagePopup`

The MoltenImagePopup is the officially supported way to view images on windows. It's a far worse
experience, but you can configure `vim.g.molten_auto_image_popup = true` to at least see images
automatically when your code produces them.

This is the only method known to work without using WSL.
#### `:MoltenImagePopup`

The first officially supported way to view images on Windows. It's a far worse experience compared
to the other methods, but you can configure `vim.g.molten_auto_image_popup = true` to at least see
images automatically when your code produces them. It's also very simple, and doesn't have any
dependencies.

#### Wezterm (via Wezterm.nvim)

The second officially supported way to render images without the use of WSL on Windows is to use
Wezterm as your terminal emulator and setting `vim.g.molten_image_provider = "wezterm"`. This is
a bit of a workaround, but it's the only other way to get images to render in the terminal without
needing an external pop-up window like used with `:MoltenImagePopup`. You can find the instructions
for downloading and setting up Wezterm [here](https://wezfurlong.org/wezterm/install/windows.html).

![](https://github.com/akthe-at/assets/blob/main/wezterm.gif)

The `vim.g.molten_image_provider = "wezterm"` option takes advantage of
[wezterm.nvim](https://github.com/willothy/wezterm.nvim) under the hood to create splits, and send
images to wezterm's `imgcat` program automatically for you. This workflow style feels a little
similar to how Rstudio handles plots, and it's a nice way to keep your code and output in the same
window. If you want a quick glance at plots for fast iteration. If you want a larger more detailed
view of your plots you may prefer to use the `:MoltenImagePopup` command instead.

An example set of configuration options for molten-nvim (but not necessarily limited to) to use
`Wezterm` as the `vim.g.molten_image_provider` option would look like this:

```lua
{
"benlubas/molten-nvim",
build = ":UpdateRemotePlugins",
dependencies = "willothy/wezterm.nvim",
init = function()
vim.g.molten_auto_open_output = false -- cannot be true if molten_image_provider = "wezterm"
vim.g.molten_output_show_more = true
vim.g.molten_image_provider = "wezterm"
vim.g.molten_output_virt_lines = true
vim.g.molten_split_direction = "right" --direction of the output window, options are "right", "left", "top", "bottom"
vim.g.molten_split_size = 40 --(0-100) % size of the screen dedicated to the output window
vim.g.molten_virt_text_output = true
vim.g.molten_use_border_highlights = true
vim.g.molten_virt_lines_off_by_1 = true
vim.g.molten_auto_image_popup = false
end,
},
```
97 changes: 97 additions & 0 deletions lua/load_wezterm_nvim.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
-- loads the wezterm.nvim plugin and exposes methods to the python remote plugin
local ok, wezterm = pcall(require, "wezterm")
if not ok then
vim.api.nvim_err_writeln("[Molten] `wezterm.nvim` not found")
return
end

local wezterm_api = {}

wezterm_api.get_pane_id = function()
local current_pane_id = wezterm.get_current_pane()
return current_pane_id
end

--- Validate the split direction
--- type function
--- @param direction string the direction to validate
--- @return string validated direction if valid
local validate_split_dir = function(direction)
local accepted_dirs = { "top", "bottom", "left", "right" }
--if direction not in accepted_dirs, return "bottom" else return direction
if not vim.tbl_contains(accepted_dirs, direction) then
vim.notify(
"[Molten] 'molten_split_dir' must be one of 'top', 'bottom', 'left', or 'right', defaulting to 'right'"
)
return "right"
end
return direction
end

--- Validate the split size
--- type function
--- @param size number the size to validate
--- @return number validated size if valid
local validate_split_size = function(size)
if size == nil or size < 0 or size > 100 then
vim.notify(
"[Molten] 'molten_split_size' must be a number between 0 and 100, defaulting to a 40% split."
)
return 40
end
return size
end

-- Split the current pane and return the new pane id
--- type function
--- @param initial_pane_id number, the pane id to split
--- @param direction string, direction to split the pane
--- @param size number, size of the new pane
--- @return number image_pane_id the new pane id
wezterm_api.wezterm_molten_init = function(initial_pane_id, direction, size)
direction = "--" .. validate_split_dir(direction)
size = validate_split_size(size)

wezterm.exec_sync({ "cli", "split-pane", direction, "--percent", tostring(size) })
wezterm.exec_sync({ "cli", "activate-pane", "--pane-id", tostring(initial_pane_id) })
local _, image_pane_id = wezterm.exec_sync({ "cli", "get-pane-direction", "Prev" })
return image_pane_id
end

-- Send an image to the image pane (terminal split)
--- type function
--- @param path string, path to the image
--- @param image_pane_id number, the pane id of the image pane
--- @param initial_pane_id number, the pane id of the initial pane
--- @return nil
wezterm_api.send_image = function(path, image_pane_id, initial_pane_id)
local placeholder = "wezterm imgcat --tmux-passthru detect %s \r"
local image = string.format(placeholder, path)
wezterm.exec_sync({ "cli", "activate-pane", "--pane-id", image_pane_id })
wezterm.exec_sync({
"cli",
"send-text",
"--pane-id",
image_pane_id,
"--no-paste",
image,
})
wezterm.exec_sync({ "cli", "activate-pane", "--pane-id", initial_pane_id })
end

-- Close the image pane
--- type function
--- @param image_pane_id number, the pane id of the image pane
--- @return nil
wezterm_api.close_image_pane = function(image_pane_id)
wezterm.exec_sync({
"cli",
"send-text",
"--pane-id",
image_pane_id,
"--no-paste",
"wezterm cli kill-pane --pane-id " .. image_pane_id .. "\r",
})
end

return { wezterm_api = wezterm_api }
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ line-length = 100

[tool.pyright]
extraPaths = ["rplugin/python3"]

[tool.ruff]
line-length = 100
6 changes: 4 additions & 2 deletions rplugin/python3/molten/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pynvim
from pynvim.api import Buffer
from molten.code_cell import CodeCell
from molten.images import Canvas, get_canvas_given_provider
from molten.images import Canvas, get_canvas_given_provider, WeztermCanvas
from molten.info_window import create_info_window
from molten.ipynb import export_outputs, get_default_import_export_file, import_outputs
from molten.save_load import MoltenIOError, get_default_save_file, load, save
Expand Down Expand Up @@ -62,7 +62,7 @@ def _initialize(self) -> None:

self.options = MoltenOptions(self.nvim)

self.canvas = get_canvas_given_provider(self.options.image_provider, self.nvim)
self.canvas = get_canvas_given_provider(self.nvim, self.options)
self.canvas.init()

self.highlight_namespace = self.nvim.funcs.nvim_create_namespace("molten-highlights")
Expand Down Expand Up @@ -199,6 +199,8 @@ def _initialize_buffer(self, kernel_name: str, shared=False) -> MoltenKernel | N

self.add_kernel(self.nvim.current.buffer, kernel_id, molten)
molten._doautocmd("MoltenInitPost")
if isinstance(self.canvas, WeztermCanvas):
self.canvas.wezterm_split()

return molten
except:
Expand Down
97 changes: 95 additions & 2 deletions rplugin/python3/molten/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
from abc import ABC, abstractmethod

from pynvim import Nvim
from molten.options import MoltenOptions

from molten.utils import notify_warn
from molten.utils import notify_warn, MoltenException


class Canvas(ABC):
Expand Down Expand Up @@ -199,11 +200,103 @@ def remove_image(self, identifier: str) -> None:
self.to_make_invisible.add(identifier)


def get_canvas_given_provider(name: str, nvim: Nvim) -> Canvas:
class WeztermCanvas(Canvas):
"""A canvas for using Wezterm's imgcat functionality to render images/plots"""

nvim: Nvim
split_dir: str | None
split_size: int | None
to_make_visible: Set[str]
to_make_invisible: Set[str]
visible: Set[str]

def __init__(self, nvim: Nvim, split_dir: str | None, split_size: int | None):
self.nvim = nvim
self.split_dir = split_dir
self.split_size = split_size
self.images: dict = {}
self.visible = set()
self.to_make_visible = set()
self.to_make_invisible = set()
self.initial_pane_id: int | None = None
self.image_pane: int | None = None

def init(self) -> None:
self.nvim.exec_lua("_wezterm = require('load_wezterm_nvim').wezterm_api")
self.wezterm_api = self.nvim.lua._wezterm
self.initial_pane_id = self.wezterm_api.get_pane_id()

def deinit(self) -> None:
"""Closes the terminal split that was opened with MoltenInit"""
self.wezterm_api.close_image_pane(str(self.image_pane).strip())

def present(self) -> None:
to_work_on = self.to_make_visible.difference(
self.to_make_visible.intersection(self.to_make_invisible)
)
self.to_make_invisible.difference_update(self.to_make_visible)

for identifier in to_work_on:
self.wezterm_api.send_image(
identifier,
str(self.image_pane).strip(),
str(self.initial_pane_id).strip(),
)

self.visible.update(self.to_make_visible)
self.to_make_invisible.clear()
self.to_make_visible.clear()

def clear(self) -> None:
pass

def img_size(self, _indentifier: str) -> Dict[str, int]:
return {"height": 0, "width": 0}

def add_image(
self,
path: str,
identifier: str,
_x: int,
_y: int,
_bufnr: int,
_winnr: int,
) -> str | dict[str, str]:
"""Adds an image to the queue to be rendered by Wezterm via the place method"""
if path not in self.images:
img = {"path": path, "id": identifier}
self.to_make_visible.add(img["path"])
return img
return path

def remove_image(self, identifier: str) -> None:
pass

def wezterm_split(self):
"""Splits the terminal based on config preferences at molten kernel init if
supplied, otherwise resort to default values. Returns the pane id of the new
split to allow sending/moving between the panes correctly.
"""
self.image_pane = self.wezterm_api.wezterm_molten_init(
self.initial_pane_id, self.split_dir, self.split_size
)


def get_canvas_given_provider(
nvim: Nvim, options: MoltenOptions
) -> Canvas:
name = options.image_provider

if name == "none":
return NoCanvas()
elif name == "image.nvim":
return ImageNvimCanvas(nvim)
elif name == "wezterm":
if options.auto_open_output:
raise MoltenException(
"'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"
)
return WeztermCanvas(nvim, options.split_direction, options.split_size)
else:
notify_warn(nvim, f"unknown image provider: `{name}`")
return NoCanvas()
3 changes: 2 additions & 1 deletion rplugin/python3/molten/moltenbuffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,8 @@ def tick(self) -> None:
},
)
notify_info(
self.nvim, f"Kernel '{self.runtime.kernel_name}' (id: {self.kernel_id}) is ready."
self.nvim,
f"Kernel '{self.runtime.kernel_name}' (id: {self.kernel_id}) is ready.",
)

def tick_input(self) -> None:
Expand Down
Loading

0 comments on commit 4ef66a1

Please sign in to comment.