branch: externals/comint-mime commit a8b0f6769d3b93b87a07aa333527f12bc6e3b05f Author: Augusto Stoffel <arstof...@gmail.com> Commit: Augusto Stoffel <arstof...@gmail.com>
Initial commit --- .gitignore | 2 + README.md | 93 +++++++++++++++++++++++ comint-mime.el | 231 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ comint-mime.py | 49 ++++++++++++ comint-mime.sh | 29 ++++++++ 5 files changed, 404 insertions(+) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7827ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.elc +autoloads.el diff --git a/README.md b/README.md new file mode 100644 index 0000000..650c2a7 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +comint-mime.el +============== + +This Emacs package provides a mechanism for REPLs (or comint buffers, +in Emacs parlance) to display graphics and other types of special +content. + +![comint-mime in Python][python] + +The main motivation behind this package is to display plots in the +Python shell. However, it does more that that. + +First, it is not constrained to graphics, and can display other “MIME +attachments” such as HTML and LaTeX content. In fact, the Python +backend of the package implements IPython's [rich display +interface][ipython_repr]. A use-case beyond the displaying of +graphics would be to render dataframes as HTML tables; this opens up +the possibility of typographical improvements over the usual pure-text +representation. You can also easily define rich representations for +your own classes. + +Second, the package defines a flexible communication protocol between +Emacs and the inferior process, and, consequently, can be extended to +other comint types. Currently, besides Python, there is support for +the regular (Unix) shell. In this case, a special command, `mimecat`, +is provided to display content. Again, this works for images, HTML, +LaTeX snippets, etc. + +![comint-mime in Bash][bash] + +Usage +----- + +To start enjoying comint-mime, simply call `M-x comint-mime-setup` +from a supported buffer (which, at the moment, are the `M-x shell` and +`M-x run-python` buffers). To apply this permanently, add that same +function to the appropriate mode hook: + +``` elisp +(add-hook 'shell-mode-hook 'comint-mime-setup) +(add-hook 'inferior-python-mode-hook 'comint-mime-setup) +``` + +Note that for Python it is important to use the IPython interpreter. +It can be configured to have the same look-and-feel as the classic +`python` program as follows. + +``` elisp +(when (executable-find "ipython3") + (setq python-shell-interpreter "ipython3" + python-shell-interpreter-args "--simple-prompt --classic")) +``` + +Extending +--------- + +To add support for new MIME types, see `comint-mime-renderer-alist`. + +To add support for new comints, an entry should be added to +`comint-mime-setup-function-alist`. This function should arrange for +the inferior process to emit an escape sequence whenever some MIME +content is to be displayed. + +The escape sequence has the following shape: + +``` +ESC ] 5 1 5 1 ; header LF payload ESC \ +``` + +Here, `header` is a JSON object containing, at least, the entry +`type`, which should be the name of a MIME type. Other header entries +can be passed; the interpretation is up to the rendering function. + +The `payload` can be either the content of the attachment, encoded in +base64 (which is decoded before being passed to the selected +renderer), or a `file://` URL (whose content is read and passed to the +renderer), or yet a `tmpfile://` URL, which indicates that the file +should be deleted after it is read. + +Note that it can take considerable time to insert large amounts of +data in a comint buffer, specially if it contains long lines. +Consider using a temporary file for large data transfers. + +Todos +----- + +- [ ] It should be possible to support at least Matplotlib in the + classic `python` interpreter. +- [ ] Improve the HTML rendering for numeric tables + +[python]: https://user-images.githubusercontent.com/6500902/133823411-ca75122d-4a39-4e3c-ac55-b2a1f974ff5e.png +[bash]: https://user-images.githubusercontent.com/6500902/133823494-696ee5a7-f0b0-47a3-9ccb-29ab9f36c3a9.png +[ipython_repr]: https://ipython.readthedocs.io/en/stable/config/integrating.html#rich-display diff --git a/comint-mime.el b/comint-mime.el new file mode 100644 index 0000000..6071730 --- /dev/null +++ b/comint-mime.el @@ -0,0 +1,231 @@ +;;; comint-mime.el --- Display content of various MIME types in comint buffers -*- lexical-binding: t; -*- + +;; Copyright (C) 2021 Augusto Stoffel + +;; Author: Augusto Stoffel <arstof...@gmail.com> +;; Keywords: processes, multimedia +;; Version: 0 +;; Homepage: https://github.com/astoff/comint-mime +;; Package-Requires: ((emacs "28.1")) + +;; This program is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <https://www.gnu.org/licenses/>. + +;;; Commentary: + +;; This package provides a mechanism to display graphics and other +;; kinds of "MIME attachments" in comint buffers. The applications +;; depend on the type of comint. +;; +;; In the regular shell, a command `mimecat' becomes available. It +;; displays the contents of any file (or standard input) of a +;; supported format. +;; +;; In the Python shell, it is possible to display inline plots, images +;; and, more generally, alternative representations of any object that +;; implements IPython's rich display interface. +;; +;; To enable comint-mime, simply call `M-x comint-mime-setup' in the +;; desired comint buffer. To enable it permanently, add that same +;; function to an appropriate hook, e.g. +;; +;; (add-hook 'shell-mode-hook 'comint-mime-setup) +;; (add-hook 'inferior-python-mode-hook 'comint-mime-setup) + +;;; Code: + +(require 'json) +(require 'svg) +(require 'url-parse) + +(defvar comint-mime-enabled-types 'all + "MIME types which the inferior process may send to Emacs. +This is either a list of strings or the symbol `all'. + +Note that this merely expresses a preference and its +interpretation is up to the backend. The shell, for instance, +only sends MIME content to Emacs via the mimecat command, so it +ignores this option altogether.") + +(defvar comint-mime-renderer-alist + '(("^image/svg+xml\\>" . comint-mime-render-svg) + ("^image\\>" . comint-mime-render-image) + ("^text/html" . comint-mime-render-html) + ("^text/latex" . comint-mime-render-latex) + ("^text\\>" . comint-mime-render-plain-text) + ("." . comint-mime-render-literally)) + "Alist associating MIME types to rendering functions. + +The keys are interpreted as regexps; the first matching entry is +chosen. + +The values should be functions, to called with a header alist +and (undecoded) data as arguments and with point at the location +where the content is to be inserted.") + +(defvar comint-mime-setup-function-alist nil + "Alist of setup functions for comint-mime. +The keys should be major modes derived from `comint-mode'. The +values should be functions, called by `comint-mime-setup' to +perform the mode-specific part of the setup.") + +(defvar comint-mime-setup-script-dir (if load-file-name + (file-name-directory load-file-name) + default-directory) + "Directory to look for setup scripts.") + +(defun comint-mime-osc-handler (_ text) + "Interpret TEXT as an OSC 5151 control sequence. +This function is intended to be used as an entry of +`comint-osc-handlers'." + (string-match "[^\n]*\n?" text) + (let* ((payload (substring text (match-end 0))) + (header (json-read-from-string (match-string 0 text))) + (data (if (string-match "\\(tmp\\)?file:" payload) + (let* ((tmp (match-beginning 1)) + (url (url-generic-parse-url payload)) + (file (concat (file-remote-p default-directory) + (url-filename url)))) + (with-temp-buffer + (set-buffer-multibyte nil) + (insert-file-contents-literally file) + (when tmp (delete-file file)) + (buffer-substring-no-properties (point-min) (point-max)))) + (base64-decode-string payload)))) + (when-let ((fun (cdr (assoc (alist-get 'type header) + comint-mime-renderer-alist + 'string-match)))) + (funcall fun header data)))) + +;;;###autoload +(defun comint-mime-setup () + "Enable rendering of MIME types in this comint buffer. + +This function can be called in the hook of major modes deriving +from `comint-mode', or interactively after starting the comint." + (interactive) + (unless (derived-mode-p 'comint-mode) + (user-error "`comint-mime' only makes sense in comint buffers")) + (if-let ((fun (cdr (assoc major-mode comint-mime-setup-function-alist + 'provided-mode-derived-p)))) + (progn + (add-to-list 'comint-osc-handlers '("5151" . comint-mime-osc-handler)) + (add-hook 'comint-output-filter-functions 'comint-osc-process-output nil t) + (funcall fun)) + (user-error "`comint-mime' is not available for this kind of inferior process"))) + +;;; Renderes + +;;;; Images +(defun comint-mime-render-svg (header data) + "Render SVG from HEADER and DATA provided by `comint-mime-osc-handler'." + (let ((start (point))) + (insert-image (svg-image data)) + (put-text-property start (point) 'comint-mime header))) + +(defun comint-mime-render-image (header data) + "Render image from HEADER and DATA provided by `comint-mime-osc-handler'." + (let ((start (point))) + (insert-image (create-image data nil t)) + (put-text-property start (point) 'comint-mime header))) + +;;;; HTML +(defun comint-mime-render-html (header data) + "Render HTML from HEADER and DATA provided by `comint-mime-osc-handler'." + (insert + (with-temp-buffer + (insert data) + (decode-coding-region (point-min) (point-max) 'utf-8) + (shr-render-region (point-min) (point-max)) + ;; Don't let font-lock override those faces + (goto-char (point-min)) + (let (match) + (while (setq match (text-property-search-forward 'face)) + (put-text-property (prop-match-beginning match) (prop-match-end match) + 'font-lock-face (prop-match-value match)))) + (put-text-property (point-min) (point-max) 'comint-mime header) + (buffer-string)))) + +;;;; LaTeX +(autoload 'org-format-latex "org") +(defvar org-preview-latex-default-process) + +(defun comint-mime-render-latex (header data) + "Render LaTeX from HEADER and DATA provided by `comint-mime-osc-handler'." + (let ((start (point))) + (insert data) + (decode-coding-region start (point) 'utf-8) + (put-text-property start (point) 'comint-mime header) + (save-excursion + (org-format-latex "org-ltximg" start (point) default-directory + t nil t org-preview-latex-default-process)))) + +;;;; Plain text +(defun comint-mime-render-plain-text (header data) + "Render plain text from HEADER and DATA provided by `comint-mime-osc-handler'." + (let ((start (point))) + (insert data) + (decode-coding-region start (point) 'utf-8) + (put-text-property start (point) 'comint-mime header))) + +;;;; Dump without rendering or decoding (for debugging) +(defun comint-mime-render-literally (header data) + "Print HEADER and DATA without special rendering." + (print header (current-buffer)) + (insert data)) + +;;; Mode-specific setup + +;;;; Python + +(defvar python-shell--first-prompt-received) +(declare-function python-shell-send-string-no-output "python.el") + +(defun comint-mime-setup-python () + "Setup code specific to `inferior-python-mode'." + (if (not python-shell--first-prompt-received) + (add-hook 'python-shell-first-prompt-hook #'comint-mime-setup-python nil t) + (python-shell-send-string-no-output + (format "%s\n__COMINT_MIME_setup('''%s''')" + (with-temp-buffer + (insert-file-contents + (expand-file-name "comint-mime.py" + comint-mime-setup-script-dir)) + (buffer-string)) + (if (listp comint-mime-enabled-types) + (string-join comint-mime-enabled-types ";") + comint-mime-enabled-types))))) + +(push '(inferior-python-mode . comint-mime-setup-python) + comint-mime-setup-function-alist) + +;;;; Shell + +(defun comint-mime-setup-shell (&rest _) + "Setup code specific to `shell-mode'." + (if (save-excursion + (goto-char (field-beginning (point-max) t)) + (not (re-search-forward comint-prompt-regexp nil t))) + (add-hook 'comint-output-filter-functions 'comint-mime-setup-shell nil t) + (remove-hook 'comint-output-filter-functions 'comint-mime-setup-shell t) + (comint-redirect-send-command + (format ". %s\n" (shell-quote-argument + (expand-file-name "comint-mime.sh" + comint-mime-setup-script-dir))) + nil nil t))) + +(push '(shell-mode . comint-mime-setup-shell) + comint-mime-setup-function-alist) + +(provide 'comint-mime) +;;; comint-mime.el ends here diff --git a/comint-mime.py b/comint-mime.py new file mode 100644 index 0000000..86584f4 --- /dev/null +++ b/comint-mime.py @@ -0,0 +1,49 @@ +# This file is part of https://github.com/astoff/comint-mime + +def __COMINT_MIME_setup(types): + try: + import IPython, matplotlib + ipython = IPython.get_ipython() + matplotlib.use('module://ipykernel.pylab.backend_inline') + except: + print("`comint-mime': error setting up") + return + + from base64 import encodebytes + from json import dumps as to_json + from functools import partial + + OSC = '\033]5151;' + ST = '\033\\' + + MIME_TYPES = { + "image/png": None, + "image/jpeg": None, + "text/latex": str.encode, + "text/html": str.encode, + "application/json": lambda d: to_json(d).encode(), + } + + if types == "all": + types = MIME_TYPES + else: + types = types.split(";") + + def print_osc(type, encoder, data, meta): + meta = meta or {} + if encoder: + data = encoder(data) + header = to_json({**meta, "type": type}) + payload = encodebytes(data).decode() + print(f'{OSC}{header}\n{payload}{ST}') + + ipython.display_formatter.active_types = list(MIME_TYPES.keys()) + for mime, encoder in MIME_TYPES.items(): + ipython.display_formatter.formatters[mime].enabled = mime in types + ipython.mime_renderers[mime] = partial(print_osc, mime, encoder) + + if types: + print("`comint-mime' enabled for", + ", ".join(t for t in types if t in MIME_TYPES.keys())) + else: + print("`comint-mime' disabled") diff --git a/comint-mime.sh b/comint-mime.sh new file mode 100644 index 0000000..b718aac --- /dev/null +++ b/comint-mime.sh @@ -0,0 +1,29 @@ +# This file is part of https://github.com/astoff/comint-mime +# shellcheck shell=sh + +mimecat () { + local type + local file + case "$1" in + -h|--help) + echo "Usage: mimecat [-t TYPE] [FILE]" + return 0 + ;; + -t|--type) + type="$2" + shift; shift + ;; + esac + if [ -z "$1" ]; then + if [ -z "$type" ]; then + echo "mimecat: When reading from stdin, please provide -t TYPE" + return 1 + fi + base64 | xargs -0 printf '\033]5151;{"type":"%s"}\n%s\033\\\n' "$type" + else + file=$(realpath -e "$1") || return 1 + [ -n "$type" ] || type=$(file -bi "$file") + printf '\033]5151;{"type":"%s"}\nfile://%s%s\033\\\n' \ + "$type" "$(hostname)" "$file" + fi +}