From 820463df259d2c77d080e8106f1ad48ed4e8c7b7 Mon Sep 17 00:00:00 2001 From: Ben Lubas <56943754+benlubas@users.noreply.github.com> Date: Thu, 16 Nov 2023 21:53:57 -0500 Subject: [PATCH] feat: Output as Virtual Text (#33) --- README.md | 5 +- lua/output_window.lua | 45 +-------- rplugin/python3/molten/__init__.py | 4 +- rplugin/python3/molten/images.py | 12 +-- rplugin/python3/molten/moltenbuffer.py | 59 ++++++----- rplugin/python3/molten/options.py | 10 +- rplugin/python3/molten/outputbuffer.py | 130 ++++++++++++++++++------- rplugin/python3/molten/outputchunks.py | 22 ++++- 8 files changed, 170 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 70718da..113808e 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,9 @@ variable, their values, and a brief description. | `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_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_virt_lines_off_by_1` | `true` \| (`false`) | _only has effect when `output_virt_lines` is true_ Allows the output window to cover exactly one line of the regular buffer. (useful for running code in a markdown file where that covered line will just be \`\`\`) | +| `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 `virt_text_output`. (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. 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_wrap_output` | `true` \| (`false`) | Wrap text in output windows | | [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 | @@ -205,6 +207,7 @@ Here is a complete list of the highlight groups that Molten uses, and their defa - `MoltenOutputWinNC` = `MoltenOutputWin`: a "Non-Current" output window - `MoltenOutputFooter` = `FloatFooter`: the "x more lines" text - `MoltenCell` = `CursorLine`: applied to code that makes up a cell +- `MoltenVirtualText` = `Comment`: output that is rendered as virtual text ## Autocommands diff --git a/lua/output_window.lua b/lua/output_window.lua index b23c955..bba9523 100644 --- a/lua/output_window.lua +++ b/lua/output_window.lua @@ -1,49 +1,14 @@ local M = {} ----Calculate the y position of the output window, accounting for folds, extmarks, and scroll. ----@param buf number +---Calculate the y position of the output window ---@param buf_line number ---@return number -M.calculate_window_position = function(buf, buf_line) - -- code modified from image.nvim https://github.com/3rd/image.nvim/blob/16f54077ca91fa8c4d1239cc3c1b6663dd169092/lua/image/renderer.lua#L254 - local win_top = vim.fn.line("w0") - if win_top == nil then return buf_line end - local offset = 0 +M.calculate_window_position = function(buf_line) + local win = vim.api.nvim_get_current_win() + local pos = vim.fn.screenpos(win, buf_line, 0) - if vim.wo.foldenable then - local i = win_top - while i <= buf_line do - local fold_start, fold_end = vim.fn.foldclosed(i), vim.fn.foldclosedend(i) - - if fold_start ~= -1 and fold_end ~= -1 then - offset = offset + (fold_end - fold_start) - i = fold_end + 1 - else - i = i + 1 - end - end - end - - local extmarks = vim.tbl_map( - function(mark) - local mark_id, mark_row, mark_col, mark_opts = unpack(mark) - local virt_height = #(mark_opts.virt_lines or {}) - return { id = mark_id, row = mark_row, col = mark_col, height = virt_height } - end, - vim.api.nvim_buf_get_extmarks( - buf, - -1, - { win_top - 1, 0 }, - { buf_line - 1, 0 }, - { details = true } - ) - ) - for _, mark in ipairs(extmarks) do - if mark.row + 1 ~= buf_line then offset = offset - mark.height end - end - - return buf_line - win_top + 1 - offset + return pos.row end return M diff --git a/rplugin/python3/molten/__init__.py b/rplugin/python3/molten/__init__.py index af2142b..94cffce 100644 --- a/rplugin/python3/molten/__init__.py +++ b/rplugin/python3/molten/__init__.py @@ -648,7 +648,7 @@ def command_show_output(self) -> None: for molten in molten_kernels: if molten.current_output is not None: - molten.should_show_display_window = True + molten.should_show_floating_win = True self._update_interface() return @@ -672,7 +672,7 @@ def command_hide_output(self) -> None: return for molten in molten_kernels: - molten.should_show_display_window = False + molten.should_show_floating_win = False self._update_interface() diff --git a/rplugin/python3/molten/images.py b/rplugin/python3/molten/images.py index 22620ca..c2d250c 100644 --- a/rplugin/python3/molten/images.py +++ b/rplugin/python3/molten/images.py @@ -1,7 +1,9 @@ from typing import Dict, Set from abc import ABC, abstractmethod -from pynvim import Nvim, logging +from pynvim import Nvim + +from molten.utils import notify_warn class Canvas(ABC): @@ -101,8 +103,6 @@ def add_image( pass -# I think this class will end up being calls to equivalent lua functions in some lua file -# somewhere class ImageNvimCanvas(Canvas): nvim: Nvim to_make_visible: Set[str] @@ -180,9 +180,5 @@ def get_canvas_given_provider(name: str, nvim: Nvim) -> Canvas: elif name == "image.nvim": return ImageNvimCanvas(nvim) else: - nvim.api.notify( - f"[Molten] unknown image provider: `{name}`", - logging.ERROR, - {"title": "Molten"}, - ) + notify_warn(nvim, f"unknown image provider: `{name}`") return NoCanvas() diff --git a/rplugin/python3/molten/moltenbuffer.py b/rplugin/python3/molten/moltenbuffer.py index ca8d59a..01fa84c 100644 --- a/rplugin/python3/molten/moltenbuffer.py +++ b/rplugin/python3/molten/moltenbuffer.py @@ -34,7 +34,7 @@ class MoltenKernel: queued_outputs: "Queue[CodeCell]" selected_cell: Optional[CodeCell] - should_show_display_window: bool + should_show_floating_win: bool updating_interface: bool options: MoltenOptions @@ -66,7 +66,7 @@ def __init__( self.queued_outputs = Queue() self.selected_cell = None - self.should_show_display_window = False + self.should_show_floating_win = False self.updating_interface = False self.options = options @@ -90,6 +90,7 @@ def restart(self, delete_outputs: bool = False) -> None: if delete_outputs: self.outputs = {} self.clear_interface() + self.clear_open_output_windows() self.runtime.restart() @@ -97,17 +98,16 @@ def run_code(self, code: str, span: CodeCell) -> None: self.delete_overlapping_cells(span) self.runtime.run_code(code) - if span in self.outputs: - self.outputs[span].clear_interface() - del self.outputs[span] - self.outputs[span] = OutputBuffer( self.nvim, self.canvas, self.extmark_namespace, self.options ) self.queued_outputs.put(span) self.selected_cell = span - self.should_show_display_window = True + + if not self.options.virt_text_output: + self.should_show_floating_win = True + self.update_interface() self._check_if_done_running() @@ -148,8 +148,8 @@ def tick(self) -> None: def enter_output(self) -> None: if self.selected_cell is not None: if self.options.enter_output_behavior != "no_open": - self.should_show_display_window = True - self.should_show_display_window = self.outputs[self.selected_cell].enter( + self.should_show_floating_win = True + self.should_show_floating_win = self.outputs[self.selected_cell].enter( self.selected_cell.end ) @@ -171,7 +171,7 @@ def clear_interface(self) -> None: def clear_open_output_windows(self) -> None: for output in self.outputs.values(): - output.clear_interface() + output.clear_float_win() def _get_selected_span(self) -> Optional[CodeCell]: current_position = self._get_cursor_position() @@ -189,7 +189,8 @@ def delete_overlapping_cells(self, span: CodeCell) -> None: if output_span.overlaps(span): if self.current_output == output_span: self.current_output = None - self.outputs[output_span].clear_interface() + self.outputs[output_span].clear_float_win() + self.outputs[output_span].clear_virt_output(span.bufno) del self.outputs[output_span] output_span.clear_interface(self.highlight_namespace) @@ -198,7 +199,7 @@ def delete_cell(self) -> None: if self.selected_cell is None: return - self.outputs[self.selected_cell].clear_interface() + self.outputs[self.selected_cell].clear_float_win() self.selected_cell.clear_interface(self.highlight_namespace) del self.outputs[self.selected_cell] self.selected_cell = None @@ -212,40 +213,44 @@ def update_interface(self) -> None: return self.updating_interface = True - selected_cell = self._get_selected_span() + new_selected_cell = self._get_selected_span() # Clear the cell we just left - if self.selected_cell != selected_cell and self.selected_cell is not None: + if self.selected_cell != new_selected_cell and self.selected_cell is not None: if self.selected_cell in self.outputs: - self.outputs[self.selected_cell].clear_interface() + self.outputs[self.selected_cell].clear_float_win() self.selected_cell.clear_interface(self.highlight_namespace) - if selected_cell is None: - self.should_show_display_window = False + if new_selected_cell is None: + self.should_show_floating_win = False - self.selected_cell = selected_cell + self.selected_cell = new_selected_cell if self.selected_cell is not None: self._show_selected(self.selected_cell) self.canvas.present() + if self.options.virt_text_output: + for span, output in self.outputs.items(): + output.show_virtual_output(span.end) + self.updating_interface = False def on_cursor_moved(self, scrolled=False) -> None: - selected_cell = self._get_selected_span() + new_selected_cell = self._get_selected_span() if ( self.selected_cell is None - and selected_cell is not None + and new_selected_cell is not None and self.options.auto_open_output ): - self.should_show_display_window = True + self.should_show_floating_win = True - if self.selected_cell == selected_cell and selected_cell is not None: + if self.selected_cell == new_selected_cell and new_selected_cell is not None: if ( scrolled - and selected_cell.end.lineno < self.nvim.funcs.line("w$") - and self.should_show_display_window + and new_selected_cell.end.lineno < self.nvim.funcs.line("w$") + and self.should_show_floating_win ): self.update_interface() return @@ -294,10 +299,10 @@ def _show_selected(self, span: CodeCell) -> None: span.end.colno, ) - if self.should_show_display_window: - self.outputs[span].show(span.end) + if self.should_show_floating_win: + self.outputs[span].show_floating_win(span.end) else: - self.outputs[span].clear_interface() + self.outputs[span].clear_float_win() def _get_content_checksum(self) -> str: return hashlib.md5( diff --git a/rplugin/python3/molten/options.py b/rplugin/python3/molten/options.py index 61fbcb9..52949a4 100644 --- a/rplugin/python3/molten/options.py +++ b/rplugin/python3/molten/options.py @@ -16,6 +16,7 @@ class HL: win_nc = "MoltenOutputWinNC" foot = "MoltenOutputFooter" cell = "MoltenCell" + virtual_text = "MoltenVirtualText" defaults = { border_norm: "FloatBorder", @@ -25,6 +26,7 @@ class HL: win_nc: win, foot: "FloatFooter", cell: "CursorLine", + virtual_text: "Comment", } @@ -35,17 +37,19 @@ class MoltenOptions: image_provider: str output_crop_border: bool output_show_more: bool + output_virt_lines: bool output_win_border: Union[str, List[str]] output_win_cover_gutter: bool output_win_hide_on_leave: bool output_win_max_height: int output_win_max_width: int output_win_style: Optional[str] - output_virt_lines: bool save_path: str show_mimetype_debug: bool use_border_highlights: bool virt_lines_off_by_1: bool + virt_text_max_lines: int + virt_text_output: bool wrap_output: bool nvim: Nvim hl: HL @@ -61,17 +65,19 @@ def __init__(self, nvim: Nvim): ("molten_image_provider", "none"), ("molten_output_crop_border", True), ("molten_output_show_more", False), + ("molten_output_virt_lines", False), ("molten_output_win_border", [ "", "━", "", "" ]), ("molten_output_win_cover_gutter", True), ("molten_output_win_hide_on_leave", True), ("molten_output_win_max_height", 999999), ("molten_output_win_max_width", 999999), ("molten_output_win_style", False), - ("molten_output_virt_lines", False), ("molten_save_path", os.path.join(nvim.funcs.stdpath("data"), "molten")), ("molten_show_mimetype_debug", False), ("molten_use_border_highlights", False), ("molten_virt_lines_off_by_1", False), + ("molten_virt_text_max_lines", 12), + ("molten_virt_text_output", False), ("molten_wrap_output", False), ] # fmt: on diff --git a/rplugin/python3/molten/outputbuffer.py b/rplugin/python3/molten/outputbuffer.py index 7dcc790..486c0f3 100644 --- a/rplugin/python3/molten/outputbuffer.py +++ b/rplugin/python3/molten/outputbuffer.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional, Union +from typing import Any, List, Optional, Tuple, Union from pynvim import Nvim from pynvim.api import Buffer, Window @@ -20,6 +20,8 @@ class OutputBuffer: display_win: Optional[Window] display_virt_lines: Optional[DynamicPosition] extmark_namespace: int + virt_text_id: Optional[int] + displayed_status: OutputStatus options: MoltenOptions lua: Any @@ -34,13 +36,15 @@ def __init__(self, nvim: Nvim, canvas: Canvas, extmark_namespace: int, options: self.display_win = None self.display_virt_lines = None self.extmark_namespace = extmark_namespace + self.virt_text_id = None + self.displayed_status = OutputStatus.HOLD self.options = options self.nvim.exec_lua("_ow = require('output_window')") self.lua = self.nvim.lua._ow - def _buffer_to_window_lineno(self, lineno: int, bufno: int) -> int: - return self.lua.calculate_window_position(bufno, lineno) + def _buffer_to_window_lineno(self, lineno: int) -> int: + return self.lua.calculate_window_position(lineno) def _get_header_text(self, output: Output) -> str: if output.execution_count is None: @@ -71,9 +75,9 @@ def enter(self, anchor: Position) -> bool: entered = False if self.display_win is None: if self.options.enter_output_behavior == "open_then_enter": - self.show(anchor) + self.show_floating_win(anchor) elif self.options.enter_output_behavior == "open_and_enter": - self.show(anchor) + self.show_floating_win(anchor) entered = True self.nvim.funcs.nvim_set_current_win(self.display_win) elif self.options.enter_output_behavior != "no_open": @@ -86,7 +90,7 @@ def enter(self, anchor: Position) -> bool: return False return True - def clear_interface(self) -> None: + def clear_float_win(self) -> None: if self.display_win is not None: self.nvim.funcs.nvim_win_close(self.display_win, True) self.canvas.clear() @@ -95,6 +99,10 @@ def clear_interface(self) -> None: del self.display_virt_lines self.display_virt_lines = None + def clear_virt_output(self, bufnr: int) -> None: + if self.virt_text_id is not None: + self.nvim.funcs.nvim_buf_del_extmark(bufnr, self.extmark_namespace, self.virt_text_id) + def set_win_option(self, option: str, value) -> None: if self.display_win: self.nvim.api.set_option_value( @@ -103,10 +111,86 @@ def set_win_option(self, option: str, value) -> None: {"scope": "local", "win": self.display_win.handle}, ) - def show(self, anchor: Position) -> None: + def build_output_text(self, shape, buf: int, virtual: bool) -> Tuple[List[str], int]: + lineno = 0 + lines_str = "" + # images are rendered with virtual lines by image.nvim + virtual_lines = 0 + if len(self.output.chunks) > 0: + for chunk in self.output.chunks: + y = lineno + if virtual: + y += shape[1] + chunktext, virt_lines = chunk.place( + buf, + self.options, + y, + shape, + self.canvas, + virtual, + ) + lines_str += chunktext + lineno += chunktext.count("\n") + virtual_lines += virt_lines + + lines = handle_progress_bars(lines_str) + lineno = len(lines) + virtual_lines + else: + lines = [] + + lines.insert(0, self._get_header_text(self.output)) + return lines, lineno + virtual_lines + + def show_virtual_output(self, anchor: Position) -> None: + if self.displayed_status == OutputStatus.DONE and self.virt_text_id is not None: + return + + self.displayed_status = self.output.status + + # clear the existing virtual text + if self.virt_text_id is not None: + self.nvim.funcs.nvim_buf_del_extmark( + anchor.bufno, self.extmark_namespace, self.virt_text_id + ) + self.virt_text_id = None + win = self.nvim.current.window win_col = win.col - win_row = self._buffer_to_window_lineno(anchor.lineno + 1, anchor.bufno) + win_row = anchor.lineno + win_width = win.width + win_height = win.height + + if self.options.virt_lines_off_by_1: + win_row += 1 + + shape = ( + win_col, + win_row, + win_width, + win_height, + ) + lines, _ = self.build_output_text(shape, anchor.bufno, True) + l = len(lines) + if l > self.options.virt_text_max_lines: + lines = lines[: self.options.virt_text_max_lines - 1] + lines.append(f"󰁅 {l - self.options.virt_text_max_lines} More Lines ") + + self.virt_text_id = self.nvim.current.buffer.api.set_extmark( + self.extmark_namespace, + win_row, + 0, + { + "virt_lines": [[(line, self.options.hl.virtual_text)] for line in lines], + }, + ) + self.canvas.present() + + def show_floating_win(self, anchor: Position) -> None: + win = self.nvim.current.window + win_col = win.col + win_row = self._buffer_to_window_lineno(anchor.lineno + 1) + if win_row == 0: # anchor position is off screen + return win_width = win.width win_height = win.height @@ -117,11 +201,6 @@ def show(self, anchor: Position) -> None: # Clear buffer: self.nvim.funcs.deletebufline(self.display_buf.number, 1, "$") - # Add output chunks to buffer - lines_str = "" - lineno = 0 - # images are rendered with virtual lines by image.nvim - virtual_lines = 0 sign_col_width = 0 text_off = self.nvim.funcs.getwininfo(win.handle)[0]["textoff"] @@ -134,26 +213,11 @@ def show(self, anchor: Position) -> None: win_width - sign_col_width, win_height, ) - if len(self.output.chunks) > 0: - for chunk in self.output.chunks: - chunktext, virt_lines = chunk.place( - self.display_buf.number, - self.options, - lineno, - shape, - self.canvas, - ) - lines_str += chunktext - lineno += chunktext.count("\n") - virtual_lines += virt_lines - - lines = handle_progress_bars(lines_str) - lineno = len(lines) - else: - lines = [] + lines, real_height = self.build_output_text(shape, self.display_buf.number, False) - self.display_buf[0] = self._get_header_text(self.output) - self.display_buf.append(lines) + # You can't append lines normally, there will be a blank line at the top + self.display_buf[0] = lines[0] + self.display_buf.append(lines[1:]) self.nvim.api.set_option_value( "filetype", "molten_output", {"buf": self.display_buf.handle} ) @@ -162,7 +226,7 @@ def show(self, anchor: Position) -> None: # assert self.display_window is None if win_row < win_height: border = self.options.output_win_border - max_height = min(virtual_lines + lineno + 1, self.options.output_win_max_height) + max_height = min(real_height + 1, self.options.output_win_max_height) height = min(win_height - win_row, max_height) cropped = False diff --git a/rplugin/python3/molten/outputchunks.py b/rplugin/python3/molten/outputchunks.py index 2bf6b06..b338551 100644 --- a/rplugin/python3/molten/outputchunks.py +++ b/rplugin/python3/molten/outputchunks.py @@ -32,6 +32,7 @@ def place( lineno: int, shape: Tuple[int, int, int, int], canvas: Canvas, + hard_wrap: bool, ) -> Tuple[str, int]: pass @@ -62,14 +63,27 @@ def place( _: int, shape: Tuple[int, int, int, int], _canvas: Canvas, + hard_wrap: bool, ) -> Tuple[str, int]: text = self._cleanup_text(self.text) extra_lines = 0 if options.wrap_output: # count the number of extra lines this will need when wrapped win_width = shape[2] - for line in text.split("\n"): - if len(line) > win_width: - extra_lines += len(line) // win_width + if hard_wrap: + lines = [] + splits = [] + for line in text.split("\n"): + for i in range(len(line) // win_width): + splits.append(line[i * win_width : win_width]) + else: + splits.append(line) + lines.extend(splits) + text = "\n".join(lines) + else: + for line in text.split("\n"): + if len(line) > win_width: + extra_lines += len(line) // win_width + return text, extra_lines @@ -117,8 +131,8 @@ def place( lineno: int, _shape: Tuple[int, int, int, int], canvas: Canvas, + _hard_wrap: bool, ) -> Tuple[str, int]: - # _x, _y, win_w, win_h = shape img = canvas.add_image( self.img_path, x=0,