├── .gitignore ├── README.org └── notebook.el /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac OS 2 | .DS_Store 3 | 4 | # IDE - Emacs 5 | *~ 6 | \#*\# 7 | *.dir-locals.el 8 | 9 | # test data 10 | test-data/ -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | #+TITLE: enb: Emacs NoteBooks 2 | #+STARTUP: content 3 | This is a work-in-progress attempt at improving support for Jupyter notebooks in Emacs. 4 | 5 | ** Approach 6 | - The notebook's internal JSON representation is rendered in a markdown buffer with code-blocks 7 | - each cell can be edited, run, moved, and deleted within this buffer 8 | - saving this buffer results in any changes being applied back to the JSON file (or the creation of such a file) 9 | ** Current status 10 | A simple draft of the rendering/saving logic is complete. 11 | ** Roadmap 12 | - *get rendering/saving logic to a stable status* 13 | - *cell actions* 14 | - toggle cell-type (~markdown~ to ~code~ and the other way) 15 | - move cell up/down 16 | - insert cell above/below 17 | - delete cell 18 | - run code in cell 19 | 20 | This last one is the most important. At least in Python, this should be easy to implement by simply calling [[https://git.savannah.gnu.org/cgit/emacs.git/tree/lisp/progmodes/python.el#n3739][run-python]]. The kernel to use should be configurable but default to a reasonable one within the project. It should be possible to redirect output from the python-interpreter into the notebook buffer. 21 | - *full multi-modal capabilities within the rendered buffer* 22 | 23 | Markdown cells should offer (mostly) all the same features available in a pure markdown buffer, and code cells should offer the same features one would get in their programming-language buffer. 24 | Using ~tree-sitter~ should help with this. 25 | This might be easier to implement in ~org-mode~ (leveraging ~org-babel~), but I'd like to keep the focus on ~markdown~, as it's the standard in Jupyter notebooks. 26 | ** Lower priority features 27 | - *support for ~org-mode~ as another possible 'front-end'* 28 | - *display non-text output* 29 | 30 | Simple graphs and plots should be relatively easy, but full-featured support for images (with zoom, etc) would not be possible within a normal Emacs buffer. One approach could be to open a separate [[https://www.gnu.org/software/emacs/manual/html_node/elisp/Xwidgets.html][xwidget-webkit buffer]] for these cases 31 | -------------------------------------------------------------------------------- /notebook.el: -------------------------------------------------------------------------------- 1 | ;;; notebook.el --- (WIP) support for Jupyter notebooks in Emacs -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2024 Andrew De Angelis 4 | 5 | ;; Author: Andrew De Angelis 6 | ;; Keywords: convenience, languages 7 | 8 | ;; This program is free software; you can redistribute it and/or modify 9 | ;; it under the terms of the GNU General Public License as published by 10 | ;; the Free Software Foundation, either version 3 of the License, or 11 | ;; (at your option) any later version. 12 | 13 | ;; This program is distributed in the hope that it will be useful, 14 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | ;; GNU General Public License for more details. 17 | 18 | ;; You should have received a copy of the GNU General Public License 19 | ;; along with this program. If not, see . 20 | 21 | ;;; Commentary: 22 | 23 | ;; see README.org 24 | 25 | ;;; Code: 26 | 27 | (require 'json) 28 | 29 | ;; TODO implement these functions 30 | (defun enb-toggle-cell-type (&rest args) 31 | "Toggle the type of current cell between markdown and code") 32 | 33 | (defun enb-move-cell (&rest args) 34 | "Move the cell up or down depending on ARGS") 35 | 36 | (defun enb-insert-cell (&rest args) 37 | "Insert a new cell") 38 | 39 | (defun enb-delete-cell (&rest args) 40 | "Delete the current cell") 41 | 42 | (defvar enb-actions 43 | ;; TODO rather than using emojis, integrate with all-the-icons, and 44 | ;; provide pure-text alternatives 45 | (list 46 | (buttonize "[md/py]" #'enb-toggle-cell-type) 47 | (buttonize " ⬆️ " #'enb-move-cell 'up) 48 | (buttonize " ⬇️ " #'enb-move-cell 'down) 49 | (buttonize " ➕⬆️ " #'enb-insert-cell 'above) 50 | (buttonize " ➕⬇️ " #'enb-insert-cell 'below) 51 | (buttonize " 🗑️ " #'enb-delete-cell)) 52 | "Actions to display next to a cell") 53 | 54 | (defvar enb-formatted-actions 55 | (propertize 56 | (concat 57 | (char-to-string ?\N{U+200F}) 58 | (string-join enb-actions " ") 59 | "\n") 60 | 'face 'minibuffer-prompt) 61 | "`enb-actions' formatted into the propertized string to insert in the buffer") 62 | 63 | (defun enb-concat-vector-into-string (vec) 64 | (cl-loop for line across vec 65 | ;; do (message "line: %s" line) 66 | concat line into str 67 | finally return str)) 68 | 69 | (defun enb-get-cell-outputs (cell) 70 | (if-let ((outputs (alist-get 'outputs cell)) 71 | ;; TODO find examples with multiple outputs and loop 72 | ;; through them 73 | (non-empty (> (length outputs) 0)) 74 | (first (aref outputs 0)) 75 | ;; TODO generalize this to all possible data types 76 | (text (or (alist-get 'text first) 77 | (alist-get 'text-plain (alist-get 'data 78 | first))))) 79 | (enb-concat-vector-into-string text) 80 | "")) 81 | 82 | (defun enb-get-cell-contents (cell) 83 | "Construct a string from CELL's source. 84 | Add delimiters specifying the cell's contents" 85 | ;; TODO: this delimiters approach integrates well with markdown, but 86 | ;; we could probably eventually transition to a better approach with 87 | ;; invisible text that is parsed by tree-sitter. In particular, the 88 | ;; backticks are redundant (we already have cell-delimiter 89 | ;; overlays), and should be done away with 90 | (let ((delimiters (if (equal "markdown" 91 | (alist-get 'cell_type cell)) 92 | '("# markdown cell\n" "\n") 93 | '("# code cell\n```python\n" "\n```\n"))) 94 | (source (enb-concat-vector-into-string (alist-get 'source cell))) 95 | (outputs (enb-get-cell-outputs cell))) 96 | (concat (car delimiters) 97 | source 98 | (cadr delimiters) 99 | outputs) 100 | ;; TODO output should be a special case that is displayed UNDER 101 | ;; the cell is output `raw'? if so let's put in fundamental mode 102 | )) 103 | 104 | ;; this function is just for testing for now, not sure it'll be useful 105 | ;; as a feature 106 | (defun enb-cleanup-ovs () 107 | "Helper function for testing, removes all overlays from the current buffer" 108 | (interactive) 109 | (remove-overlays (point-min) (point-max))) 110 | 111 | (defun enb-next-delimiter (i overlays) 112 | "Return the start of the next overlay, or `point-max' when there's 113 | no next overlay" 114 | (if-let ((ov (nth i overlays))) 115 | (overlay-start ov) 116 | (point-max))) 117 | 118 | (defun enb-get-all-contents () 119 | "Read the contents of the current buffer and encode them into a JSON object" 120 | (let* ((all-overlays (overlays-in (point-min) (point-max))) 121 | (overlays (seq-filter 122 | (lambda (ov) 123 | (equal (overlay-get ov 'category) 'emacs-notebook)) 124 | all-overlays))) 125 | ;; (message "(length overlays): %s" (length overlays)) 126 | (cl-loop for i from 0 to (1- (length overlays)) 127 | vconcat 128 | (let* ((this-ov (nth i overlays)) 129 | (block (buffer-substring-no-properties 130 | (overlay-end this-ov) 131 | (enb-next-delimiter (1+ i) overlays))) 132 | (display (overlay-get this-ov 'display)) 133 | (props (get-text-property 0 134 | 'notebook-properties 135 | display))) 136 | (list 137 | (if (not (string-empty-p block)) 138 | `((source . ,(string-split block "\n")) 139 | ,@props) 140 | props))) 141 | into cells 142 | finally return 143 | 144 | (progn 145 | ;; (message "%s"`((cells . ,cells))) 146 | (json-encode `((cells . ,cells))) 147 | ) 148 | ))) 149 | 150 | (defun ipynb-save () 151 | "Save the current buffer into a ipynb file" 152 | (interactive) 153 | (let ((contents (enb-get-all-contents))) 154 | ;; TODO add check to ensure file hasn't been edited since last 155 | ;; time 156 | ;; also maybe we shouldn't go by buffer name but some other 157 | ;; property that would be less liable to get edited by user/emacs 158 | (find-file (concat 159 | (replace-regexp-in-string "^\\*\\|\\*$" "" 160 | (buffer-name)) 161 | ".ipynb")) 162 | (erase-buffer) 163 | (insert contents) 164 | ;; (save-buffer) 165 | ;; (kill-buffer) 166 | )) 167 | 168 | 169 | (defun ipynb-render-buffer () 170 | "Render the notebook's JSON object into a markdown buffer" 171 | (let* ((nb-contents (json-read-from-string 172 | (buffer-substring-no-properties 173 | (point-min) 174 | (point-max))))) 175 | ;; set this buffer to JSON mode and move to the new buffer 176 | (json-mode) 177 | (switch-to-buffer (concat "*" (file-name-base) "*") 178 | nil 'same-window) 179 | ;; (erase-buffer) 180 | (goto-char (point-min)) 181 | ;; (message "(length of cells: %s" (length (alist-get 'cells 182 | ;; nb-contents))) 183 | (setq cells (alist-get 'cells 184 | nb-contents)) 185 | 186 | (cl-loop for cell across (alist-get 'cells 187 | nb-contents) 188 | do 189 | (save-excursion 190 | (insert (enb-get-cell-contents cell))) 191 | (let ((ov (make-overlay (point) 192 | (search-forward-regexp "# .* cell")))) 193 | (overlay-put 194 | ov 'display 195 | (propertize 196 | enb-formatted-actions 197 | 'notebook-properties (delq (assoc 'source cell) 198 | cell))) 199 | (overlay-put ov 'category 'emacs-notebook)) 200 | (goto-char (point-max)) 201 | ) 202 | (goto-char (point-min))) 203 | ;; TODO figure out a way to connect `buffer-modified-p' to the 204 | ;; status of the buffer and the file 205 | (ipynb-mode)) 206 | 207 | (define-derived-mode ipynb-mode markdown-mode "ipynb" 208 | "Major mode for editing python notebooks." 209 | (define-key ipynb-mode-map (kbd "C-x C-s") #'ipynb-save)) 210 | 211 | (define-derived-mode json-ipynb-mode json-mode "json-ipynb" 212 | "Major mode for rendering python notebooks." 213 | (ipynb-render-buffer)) 214 | 215 | (push '("\\.ipynb\\'" . json-ipynb-mode) auto-mode-alist) 216 | 217 | 218 | (provide 'notebook) 219 | ;;; notebook.el ends here 220 | --------------------------------------------------------------------------------