diff --git a/README.md b/README.md index e4a3c20..6832a27 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ Magma is a NeoVim plugin for running code interactively with Jupyter. - [`cairosvg`](https://cairosvg.org/) (for displaying SVG images) - [`pnglatex`](https://pypi.org/project/pnglatex/) (for displaying TeX formulas) - `plotly` and `kaleido` (for displaying Plotly figures) + - `pyperclip` if you want to use `magma_copy_output` +- For .NET (C#, F#) + - `dotnet tool install -g Microsoft.dotnet-interactive` + - `dotnet interactive jupyter install` You can do a `:checkhealth` to see if you are ready to go. @@ -59,6 +63,37 @@ let g:magma_image_provider = "ueberzug" **Note:** The options that are altered here don't have these as their default values in order to provide a simpler (albeit perhaps a bit more inconvenient) UI for someone who just added the plugin without properly reading the README. +To make initialisation of kernels easier, you can add these commands: + +```lua +function MagmaInitPython() + vim.cmd[[ + :MagmaInit python3 + :MagmaEvaluateArgument a=5 + ]] +end + +function MagmaInitCSharp() + vim.cmd[[ + :MagmaInit .net-csharp + :MagmaEvaluateArgument Microsoft.DotNet.Interactive.Formatting.Formatter.SetPreferredMimeTypesFor(typeof(System.Object),"text/plain"); + ]] +end + +function MagmaInitFSharp() + vim.cmd[[ + :MagmaInit .net-fsharp + :MagmaEvaluateArgument Microsoft.DotNet.Interactive.Formatting.Formatter.SetPreferredMimeTypesFor(typeof,"text/plain") + ]] +end + +vim.cmd[[ +:command MagmaInitPython lua MagmaInitPython() +:command MagmaInitCSharp lua MagmaInitCSharp() +:command MagmaInitFSharp lua MagmaInitFSharp() +]] +``` + ## Usage The plugin provides a bunch of commands to enable interaction. It is recommended to map most of them to keys, as explained in [Keybindings](#keybindings). However, this section will refer to the commands by their names (so as to not depend on some specific mappings). @@ -141,6 +176,14 @@ nnoremap r nvim_exec('MagmaEvaluateOperator', v:true) Upon using this mapping, you will enter operator mode, with which you will be able to select text you want to execute. You can, of course, hit ESC to cancel, as usual with operator mode. +#### MagmaEvaluateArgument + +Evaluate the text following this command. Could be used for some automation (e. g. run something on initialization of a kernel). + +```vim +:MagmaEvaluateArgument a=5; +``` + #### MagmaReevaluateCell Reevaluate the currently selected cell. @@ -307,6 +350,12 @@ Where to save/load with [`:MagmaSave`](#magmasave) and [`:MagmaLoad`](#magmaload The generated file is placed in this directory, with the filename itself being the buffer's name, with `%` replaced by `%%` and `/` replaced by `%`, and postfixed with the extension `.json`. +### `g:magma_copy_output` + +Defaults to `v:false`. + +To copy the evaluation output to clipboard automatically. + ### [DEBUG] `g:magma_show_mimetype_debug` Defaults to `v:false`. @@ -340,6 +389,10 @@ Here is a list of the currently handled mimetypes: This already provides quite a bit of basic functionality. As development continues, more mimetypes will be added. +### Correct progress bars and alike stuff + +![](./caret.gif) + ### Notifications We use the `vim.notify` API. This means that you can use plugins such as [rcarriga/nvim-notify](https://github.com/rcarriga/nvim-notify) for pretty notifications. diff --git a/caret.gif b/caret.gif new file mode 100644 index 0000000..a0cbdf3 Binary files /dev/null and b/caret.gif differ diff --git a/rplugin/python3/magma/__init__.py b/rplugin/python3/magma/__init__.py index 2c5eb23..8742b93 100644 --- a/rplugin/python3/magma/__init__.py +++ b/rplugin/python3/magma/__init__.py @@ -153,7 +153,7 @@ def _initialize_buffer(self, kernel_name: str) -> MagmaBuffer: return magma - @pynvim.command("MagmaInit", nargs="?", sync=True) # type: ignore + @pynvim.command("MagmaInit", nargs="?", sync=True, complete='file') # type: ignore @nvimui # type: ignore def command_init(self, args: List[str]) -> None: self._initialize_if_necessary() @@ -224,6 +224,18 @@ def _do_evaluate( magma.run_code(code, span) + def _do_evaluate_expr(self, expr): + self._initialize_if_necessary() + + magma = self._get_magma(True) + assert magma is not None + bufno = self.nvim.current.buffer.number + span = Span( + DynamicPosition(self.nvim, self.extmark_namespace, bufno, 0, 0), + DynamicPosition(self.nvim, self.extmark_namespace, bufno, 0, 0), + ) + magma.run_code(expr, span) + @pynvim.command("MagmaEnterOutput", sync=True) # type: ignore @nvimui # type: ignore def command_enter_output_window(self) -> None: @@ -231,6 +243,12 @@ def command_enter_output_window(self) -> None: assert magma is not None magma.enter_output() + @pynvim.command("MagmaEvaluateArgument", nargs=1, sync=True) + @nvimui + def commnand_magma_evaluate_argument(self, expr) -> None: + assert len(expr) == 1 + self._do_evaluate_expr(expr[0]) + @pynvim.command("MagmaEvaluateVisual", sync=True) # type: ignore @nvimui # type: ignore def command_evaluate_visual(self) -> None: diff --git a/rplugin/python3/magma/magmabuffer.py b/rplugin/python3/magma/magmabuffer.py index 9cde2ef..0b73215 100644 --- a/rplugin/python3/magma/magmabuffer.py +++ b/rplugin/python3/magma/magmabuffer.py @@ -83,9 +83,13 @@ def restart(self, delete_outputs: bool = False) -> None: self.runtime.restart() def run_code(self, code: str, span: Span) -> None: + self._delete_all_cells_in_span(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.options) self.queued_outputs.put(span) @@ -168,15 +172,25 @@ def _get_selected_span(self) -> Optional[Span]: return selected + def _delete_all_cells_in_span(self, span: Span) -> None: + for output_span in reversed(list(self.outputs.keys())): + if ( + output_span.begin in span + or output_span.end in span + or span.begin in output_span + or span.end in output_span + ): + self.outputs[output_span].clear_interface() + del self.outputs[output_span] + def delete_cell(self) -> None: self.selected_cell = self._get_selected_span() if self.selected_cell is None: return + self.outputs[self.selected_cell].clear_interface() del self.outputs[self.selected_cell] - self.update_interface() - def update_interface(self) -> None: if self.buffer.number != self.nvim.current.buffer.number: return diff --git a/rplugin/python3/magma/options.py b/rplugin/python3/magma/options.py index 7dc44b5..efecf5b 100644 --- a/rplugin/python3/magma/options.py +++ b/rplugin/python3/magma/options.py @@ -11,12 +11,13 @@ class MagmaOptions: cell_highlight_group: str save_path: str image_provider: str + copy_output: bool def __init__(self, nvim: Nvim): self.automatically_open_output = nvim.vars.get( "magma_automatically_open_output", True ) - self.wrap_output = nvim.vars.get("magma_wrap_output", True) + self.wrap_output = nvim.vars.get("magma_wrap_output", False) self.output_window_borders = nvim.vars.get( "magma_output_window_borders", True ) @@ -31,3 +32,6 @@ def __init__(self, nvim: Nvim): os.path.join(nvim.funcs.stdpath("data"), "magma"), ) self.image_provider = nvim.vars.get("magma_image_provider", "none") + self.copy_output = nvim.vars.get( + "magma_copy_output", False + ) diff --git a/rplugin/python3/magma/outputbuffer.py b/rplugin/python3/magma/outputbuffer.py index 054eefa..db26272 100644 --- a/rplugin/python3/magma/outputbuffer.py +++ b/rplugin/python3/magma/outputbuffer.py @@ -97,6 +97,14 @@ def show(self, anchor: Position) -> None: # XXX .show_outputs(_, anchor) lines_str += chunktext lineno += chunktext.count("\n") lines = lines_str.rstrip().split("\n") + actualLines = [] + for line in lines: + parts = line.split('\r') + last = parts[-1] + if last != "": + actualLines.append(last) + lines = actualLines + lineno = len(lines) else: lines = [lines_str] self.display_buffer[0] = self._get_header_text(self.output) # TODO diff --git a/rplugin/python3/magma/outputchunks.py b/rplugin/python3/magma/outputchunks.py index de1a574..d2a27e0 100644 --- a/rplugin/python3/magma/outputchunks.py +++ b/rplugin/python3/magma/outputchunks.py @@ -33,21 +33,21 @@ def place( ) -> str: pass +# Adapted from [https://stackoverflow.com/a/14693789/4803382]: +ANSI_CODE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") +def clean_up_text(text: str) -> str: + text = ANSI_CODE_REGEX.sub("", text) + text = text.replace("\r\n", "\n") + return text class TextOutputChunk(OutputChunk): text: str - # Adapted from [https://stackoverflow.com/a/14693789/4803382]: - ANSI_CODE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") - def __init__(self, text: str): self.text = text def _cleanup_text(self, text: str) -> str: - # Adapted from [https://stackoverflow.com/a/14693789/4803382]: - text = self.ANSI_CODE_REGEX.sub("", text) - text = text.replace("\r\n", "\n") - return text + return clean_up_text(text) def place( self, @@ -209,7 +209,7 @@ def __init__(self, execution_count: Optional[int]): def to_outputchunk( alloc_file: Callable[ [str, str], - AbstractContextManager[Tuple[str, IO[bytes]]], + "AbstractContextManager[Tuple[str, IO[bytes]]]", ], data: Dict[str, Any], metadata: Dict[str, Any], diff --git a/rplugin/python3/magma/runtime.py b/rplugin/python3/magma/runtime.py index dbae2c1..7a994e6 100644 --- a/rplugin/python3/magma/runtime.py +++ b/rplugin/python3/magma/runtime.py @@ -4,6 +4,7 @@ from queue import Empty as EmptyQueueException import os import tempfile +import json import jupyter_client @@ -15,6 +16,7 @@ TextOutputChunk, OutputStatus, to_outputchunk, + clean_up_text ) @@ -39,20 +41,40 @@ def __init__(self, kernel_name: str, options: MagmaOptions): self.state = RuntimeState.STARTING self.kernel_name = kernel_name - self.kernel_manager = jupyter_client.manager.KernelManager( - kernel_name=kernel_name - ) - self.kernel_manager.start_kernel() - self.kernel_client = self.kernel_manager.client() - assert isinstance( - self.kernel_client, - jupyter_client.blocking.client.BlockingKernelClient, - ) - self.kernel_client.start_channels() + if ".json" not in self.kernel_name: + + self.external_kernel = True + self.kernel_manager = jupyter_client.manager.KernelManager( + kernel_name=kernel_name + ) + self.kernel_manager.start_kernel() + self.kernel_client = self.kernel_manager.client() + assert isinstance( + self.kernel_client, + jupyter_client.blocking.client.BlockingKernelClient, + ) + self.kernel_client.start_channels() + + self.allocated_files = [] - self.allocated_files = [] + self.options = options - self.options = options + else: + kernel_file = kernel_name + self.external_kernel = True + # Opening JSON file + kernel_json = json.load(open(kernel_file)) + # we have a kernel json + self.kernel_manager = jupyter_client.manager.KernelManager( + kernel_name=kernel_json["kernel_name"] + ) + self.kernel_client = self.kernel_manager.client() + + self.kernel_client.load_connection_file(connection_file=kernel_file) + + self.allocated_files = [] + + self.options = options def is_ready(self) -> bool: return self.state.value > RuntimeState.STARTING.value @@ -62,7 +84,8 @@ def deinit(self) -> None: if os.path.exists(path): os.remove(path) - self.kernel_client.shutdown() + if self.external_kernel is False: + self.kernel_client.shutdown() def interrupt(self) -> None: self.kernel_manager.interrupt_kernel() @@ -96,21 +119,31 @@ def _append_chunk( def _tick_one( self, output: Output, message_type: str, content: Dict[str, Any] ) -> bool: + + def copy_on_demand(content_ctor): + if self.options.copy_output: + import pyperclip + if type(content_ctor) is str: + pyperclip.copy(content_ctor) + else: + pyperclip.copy(content_ctor()) + if output._should_clear: output.chunks.clear() output._should_clear = False if message_type == "execute_input": output.execution_count = content["execution_count"] - assert output.status != OutputStatus.DONE - if output.status == OutputStatus.HOLD: - output.status = OutputStatus.RUNNING - elif output.status == OutputStatus.RUNNING: - output.status = OutputStatus.DONE - else: - raise ValueError( - "bad value for output.status: %r" % output.status - ) + if self.external_kernel is False: + assert output.status != OutputStatus.DONE + if output.status == OutputStatus.HOLD: + output.status = OutputStatus.RUNNING + elif output.status == OutputStatus.RUNNING: + output.status = OutputStatus.DONE + else: + raise ValueError( + "bad value for output.status: %r" % output.status + ) return True elif message_type == "status": execution_state = content["execution_state"] @@ -129,6 +162,8 @@ def _tick_one( return False elif message_type == "execute_result": self._append_chunk(output, content["data"], content["metadata"]) + if 'text/plain' in content['data']: + copy_on_demand(content["data"]['text/plain']) return True elif message_type == "error": output.chunks.append( @@ -136,9 +171,11 @@ def _tick_one( content["ename"], content["evalue"], content["traceback"] ) ) + copy_on_demand(lambda: "\n\n".join(map(clean_up_text, content["traceback"]))) output.success = False return True elif message_type == "stream": + copy_on_demand(content["text"]) output.chunks.append(TextOutputChunk(content["text"])) return True elif message_type == "display_data":