;;; pico8.el --- major mode for editing PICO-8 cartridges ;; ;; Author: Joe Wreschnig ;; Package-Version: 20170620 ;; Package-Requires: ((emacs "25") (polymode "20170307") (lua-mode "20151025") (dash "2.12.0")) ;; URL: https://git.korewanetadesu.com/pico8.git ;; Keywords: convenience ;; ;; 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. ;;; Commentary: ;; ;; This mode (ab)uses polymode to fit six modes into one buffer, one of ;; which is "real Lua" text and the other five of which have diverse ;; strict formatting requirements. ;; ;; It provides keybindings and commands for inter-mode actions. ;;; Code: (require 'polymode) (require 'lua-mode) (require 'thingatpt) (require 'dash) (defgroup pico8 nil "Support for PICO-8 (.p8) cartridge files." :tag "PICO-8" :group 'languages) (defcustom pico8-executable-paths '("/Applications/PICO-8.app/Contents/MacOS/pico8" ; macOS "/usr/lib/pico8/pico8" ; PocketCHIP "pico8") ; Normal systems "The locations to search for the PICO-8 executable." :group 'pico8 :tag "PICO-8 Executable Paths" :type '(repeat string)) (defcustom pico8-preserve-output-on-exit nil "Whether to keep the output buffer when PICO-8 exits. PICO-8 processes are long-lived with little surprising output, so their output buffers are killed by default when they exit. However, this is not usual behavior in Emacs, and can be disabled by setting this to t." :group 'pico8 :tag "Preserve PICO-8 Output On Exit" :type 'boolean) (defcustom pico8-lua-indent-level 1 "Default indentation for PICO-8 Lua mode. This overrides ‘lua-indent-level’ in ‘pico8-lua-mode’. ‘lua-mode’ uses a default indentation is 3, which is both idiosyncratic and quite large when viewed in the PICO-8 editor, where the convention is 1." :group 'pico8 :tag "PICO-8 Lua Indent Level") (defconst pico8-colors (mapcar #'symbol-value (list (defconst pico8-color-0 "#000000") (defconst pico8-color-1 "#1D2B53") (defconst pico8-color-2 "#7E2553") (defconst pico8-color-3 "#008751") (defconst pico8-color-4 "#AB5236") (defconst pico8-color-5 "#5F574F") (defconst pico8-color-6 "#C2C3C7") (defconst pico8-color-7 "#FFF1E8") (defconst pico8-color-8 "#FF004D") (defconst pico8-color-9 "#FFA300") (defconst pico8-color-a "#FFEC27") (defconst pico8-color-b "#00E436") (defconst pico8-color-c "#29ADFF") (defconst pico8-color-d "#83769C") (defconst pico8-color-e "#FF77A8") (defconst pico8-color-f "#FFCCAA")))) (defconst pico8-data-characters (append "0123456789abcdef" nil)) (defface pico8-data '((t (:inherit fixed-pitch))) "Face for PICO-8 binary data.") (define-derived-mode pico8-data-mode fundamental-mode "PICO-8 Data" "Major mode for non-text PICO-8 cartridge sections. This is an 'abstract' mode and should not be used as an actual major mode, only to derive new modes.") (defun pico8-data-self-insert-command (n) "Insert the data character you type N times. PICO-8 cartridges represent binary data using fixed-length strings of 0-f, one character per nybble. This command will only insert the typed character if it is one of these characters, overwriting one of these characters." (interactive "P") (when (memq (char-after) pico8-data-characters) (let ((overwrite-mode 'overwrite-mode-textual)) (self-insert-command (prefix-numeric-value n))))) (let ((map pico8-data-mode-map)) (suppress-keymap map) (dolist (c (mapcar #'char-to-string pico8-data-characters)) (define-key map c 'pico8-data-self-insert-command))) (defun pico8-goto-char (position) "Set point to POSITION, a number. The position is global to the cartridge, and the buffer is widened if necessary to reach it." (unless (<= (point-min) position (point-max)) (widen)) (goto-char position)) (defun pico8-executable () "Look up the PICO-8 executable." (or (car (delete nil (mapcar 'executable-find pico8-executable-paths))) (error "The PICO-8 executable could not be found. Make sure it is installed, and present in ‘pico8-executable-paths’"))) (defun pico8--create-output-buffer () "Create and return a buffer for PICO-8 output." (let* ((output-name (generate-new-buffer-name "*PICO-8*")) (output (get-buffer-create output-name))) (display-buffer output) (with-current-buffer output (insert "(Use C-r within PICO-8 to reload changes from Emacs.)\n") ;; Setting the point in the buffer doesn't have a lasting ;; effect, we need to change it in the window it opened in. ;; https://emacs.stackexchange.com/questions/21464/ (set-window-point (get-buffer-window output) (point-max)) (compilation-mode)) output)) (defun pico8--process-sentinel (process signal) "Delete buffers and windows for PROCESS if SIGNAL is exit." (when (and (not pico8-preserve-output-on-exit) (process-buffer process) (memq (process-status process) '(exit signal))) (let ((buffer (process-buffer process))) (dolist (window (get-buffer-window-list buffer)) (quit-restore-window window)) (kill-buffer buffer)))) (defun pico8--execute (&rest params) "Run PICO-8 with the provided PARAMS after saving etc." (let ((pico8 (pico8-executable)) (cartridge-file-name (buffer-file-name (buffer-base-buffer)))) (when (and (buffer-modified-p) (y-or-n-p (format "Save %s? " cartridge-file-name))) (save-buffer)) (let* ((output (pico8--create-output-buffer)) (process-args (append params (list cartridge-file-name))) (process (apply #'start-file-process "PICO-8" output pico8 process-args))) (set-process-sentinel process 'pico8--process-sentinel)))) (defun pico8-run-cartridge () "Run the current PICO-8 cartridge." (interactive) (pico8--execute "-run")) (defun pico8-load-cartridge () "Load the current cartridge in PICO-8." (interactive) (pico8--execute)) (defun pico8-cartridge-section-header (name) "Return the header string for cartridge section NAME." (format "__%s__" name)) (defconst pico8-cartridge-sections '("lua" "gfx" "gff" "map" "sfx" "music")) (defconst pico8-cartridge-keywords (mapcar #'pico8-cartridge-section-header pico8-cartridge-sections)) (defconst pico8-cartridge-header "pico-8 cartridge // http://www.pico-8.com\nversion [0-9]+") (define-derived-mode pico8-cartridge-mode fundamental-mode "Cartridge" "Major mode for showing PICO-8 cartridge structure." (font-lock-add-keywords nil (list (regexp-opt pico8-cartridge-keywords 'symbols) (cons pico8-cartridge-header font-lock-comment-face))) (suppress-keymap pico8-cartridge-mode-map)) (defun pico8-cartridge-point-of-section (name) "Find the point where the data for section NAME begins." (save-restriction (widen) (save-excursion (goto-char (point-min)) (let ((token (format "^%s\n" (pico8-cartridge-section-header name)))) (if (not (or (re-search-forward token nil t) (re-search-forward token nil t))) (error "Unable to find a %s section in current buffer" name))) (point)))) (defun pico8-cartridge-goto-section (name) "Go to the beginning of section NAME." (interactive "sGoto PICO-8 cartridge section: ") (pico8-goto-char (pico8-cartridge-point-of-section name))) (defmacro pico8-cartridge-with-section (section &rest body) "Go to and narrow SECTION and evaluate BODY there." `(save-restriction (save-excursion (pico8-cartridge-goto-section ,section) (pm-narrow-to-span) ,@body))) (defconst pico8-lua-builtins (regexp-opt '("abs" "add" "all" "atan2" "btn" "btnp" "camera" "cartdata" "circ" "circfill" "clip" "cls" "cocreate" "coresume" "costatus" "color" "cos" "cstore" "cursor" "del" "dget" "dset" "fget" "flip" "flr" "folder" "foreach" "fset" "info" "line" "load" "ls" "map" "max" "memcpy" "memset" "menuitem" "mget" "mid" "min" "mset" "music" "pairs" "pal" "palt" "peek" "pget" "poke" "print" "printh" "pset" "reboot" "rect" "rectfill" "reload" "resume" "rnd" "run" "save" "sfx" "sget" "sin" "spr" "sqrt" "srand" "sset" "sspr" "stat") 'symbols)) (define-derived-mode pico8-lua-mode lua-mode "Lua" "Major mode for editing Lua code in PICO-8 cartridges." (font-lock-add-keywords nil `((,pico8-lua-builtins . font-lock-builtin-face))) (set (make-local-variable 'lua-indent-level) pico8-lua-indent-level)) (defun pico8-gff-current-position () "Calculate the flag position of the cursor." (pm-with-narrowed-to-span (pm-get-innermost-span) (let ((row (1- (line-number-at-pos))) (col (min 255 (current-column)))) (+ (/ col 2) (* row 128))))) (defun pico8-gff-lighter () "Calculate the flag under the cursor." (pm-with-narrowed-to-span (pm-get-innermost-span) (let ((row (1- (line-number-at-pos))) (col (current-column))) (+ (* 128 row) (/ col 2))))) (define-derived-mode pico8-gff-mode pico8-data-mode '(:eval (format "Flag[%d]" (pico8-gff-lighter))) "Major mode for editing flags in PICO-8 cartridges.") (defun pico8-gff-offset-of-flag (flag) "Calculate the offset of of flag number FLAG." (unless (<= 0 flag 255) (error "Valid flag numbers are 0 to 255, inclusive")) (+ (* 2 flag) (if (> flag 128) 1 0))) (defface pico8-pixel '((t (:inherit pico8-data :height 100))) "Face for PICO-8 sprite “pixels.”" :group 'pico8) (defconst pico8-gfx-font-lock-keywords (cons ;; If the \n isn't in the smaller face the line is taller to ;; accommodate the full sized point at the end-of-line. '("\n" . 'pico8-pixel) (-map-indexed (lambda (i c) `(,(format "%x+" i) 0 '(face (:inherit pico8-pixel :foreground ,c)))) pico8-colors))) (defun pico8-gfx-current-position () "Calculate the sprite and in-sprite position of the cursor." ;; FIXME: Ensure the span we got was actually the gfx one. (pm-with-narrowed-to-span (pm-get-innermost-span) (let ((row (1- (line-number-at-pos))) (col (min 127 (current-column)))) (list (+ (* 16 (/ row 8)) (/ col 8)) (% col 8) (% row 8))))) (defun pico8-forward-sprite (n) "Move the point N sprites forward (backward if N is negative)." (interactive "P") (let* ((n (prefix-numeric-value n)) (current (pico8-gfx-current-position)) (offset (pico8-gfx-offset-of-sprite (% (+ 256 n (nth 0 current)) 256) (nth 1 current) (nth 2 current)))) (pm-with-narrowed-to-span (pm-get-innermost-span) (goto-char (+ (point-min) offset))))) (defun pico8-backward-sprite (n) "Move the point N sprites backward (forward if N is negative)." (interactive "P") (pico8-forward-sprite (- (prefix-numeric-value n)))) (defun pico8-gfx-lighter () "Show a short description of the current sprite position." (let ((current (pico8-gfx-current-position))) (if current (apply #'format (cons "Sprite[%d:%d,%d]" current)) "Sprite[-]"))) (define-derived-mode pico8-gfx-mode pico8-data-mode '(:eval (pico8-gfx-lighter)) "Major mode for editing sprites in PICO-8 cartridges." (font-lock-add-keywords nil pico8-gfx-font-lock-keywords) (read-only-mode t)) (defun pico8-gfx-offset-of-sprite (sprite &optional x y) "Calculate the point of SPRITE's X,Y pixel (0,0 by default)." (let ((x (or x 0)) (y (or y 0)) (line (* (/ sprite 16) 8)) (row (* (% sprite 16) 8))) ;; A limit of 4x4 is kind of arbitrary but if you're using sprites ;; larger than that you probably aren't going to be doing so in a ;; way that this command is useful anyway. (unless (and (<= 0 x 31) (<= 0 y 31)) (error "Valid sprite offsets are 0 to 31, inclusive")) (unless (<= 0 sprite 255) (error "Valid sprite numbers are 0 to 255, inclusive")) (+ (* 129 (+ y line)) row x))) (define-key pico8-gfx-mode-map "q" 'pico8-backward-sprite) (define-key pico8-gfx-mode-map "w" 'pico8-forward-sprite) (defface pico8-map-tile '((t (:inherit pico8-data :height 100))) "Face for PICO-8 map 'tiles'." :group 'pico8-faces) (defconst pico8-map-font-lock-keywords '(("[0-9a-f]+" . 'pico8-map-tile) ("\n" . 'pico8-map-tile))) (defun pico8-map-lighter () "Calculate the map tile under the cursor." (pm-with-narrowed-to-span (pm-get-innermost-span) (let ((row (- (line-number-at-pos) 1)) (col (current-column))) ;; TODO: Show sprite number and flags value (format "%d,%d" (/ col 2) row)))) (define-derived-mode pico8-map-mode pico8-data-mode '(:eval (format "Map[%s]" (pico8-map-lighter))) "Major mode for editing map data in PICO-8 cartridges." (setq font-lock-defaults '(pico8-map-font-lock-keywords))) (defun pico8-sfx-lighter () "Calculate the sound effect under the cursor." (pm-with-narrowed-to-span (pm-get-innermost-span) (let ((row (- (line-number-at-pos) 1))) (format "%d" row)))) (define-derived-mode pico8-sfx-mode pico8-data-mode '(:eval (format "Sound[%s]" (pico8-sfx-lighter))) "Major mode for editing sound data in PICO-8 cartridges.") (defun pico8-music-lighter () "Calculate the map tile under the cursor." (pm-with-narrowed-to-span (pm-get-innermost-span) (let ((row (- (line-number-at-pos) 1))) (format "%d" row)))) (define-derived-mode pico8-music-mode pico8-data-mode '(:eval (format "Pattern[%s]" (pico8-music-lighter))) "Major mode for editing music data in PICO-8 cartridges.") (defun pico8-goto-sprite (sprite &optional x y) "Set point to the top-left pixel of SPRITE (or the X,Y pixel)." (interactive "nGo to sprite [0-255]: ") (let ((base (pico8-cartridge-point-of-section "gfx")) (offset (pico8-gfx-offset-of-sprite sprite x y))) (pico8-goto-char (+ base offset)))) (defun pico8-goto-flag (flag) "Set point to the start of flag number FLAG." (interactive "nGo to flag [0-255]: ") (let ((base (pico8-cartridge-point-of-section "gff")) (offset (pico8-gff-offset-of-flag flag))) (pico8-goto-char (+ base offset)))) (defun pico8--string-to-number (string) "Convert STRING to a number, guessing the base. Returns nil, not 0, if the string was not converted." (cond ((string-match-p "^0[xX][0-9a-fA-F]+$" string) (string-to-number (substring string 2) 16)) ((string-match-p "^0[0-9]+$" string) (string-to-number (substring string 1) 8)) ((string-match-p "^[0-9]+$" string) (string-to-number string 10)) (t nil))) (defun pico8-sprite-relevant-to-point () "Get the sprite number relevant to the point. When editing a flag, this is the flag number. When editing a map, this is the value at the map. When editing Lua code, this is the numeric literal in the code." (cond ((derived-mode-p 'pico8-gff-mode) (pico8-gff-current-position)) ;; The sprite for a map is the data at the map location, which is ;; to say, the hexadecimal interpretation of the two character ;; string beginning at the previous even column. ((derived-mode-p 'pico8-map-mode) (let ((beg (- (point) (% (min 255 (current-column)) 2)))) (string-to-number (buffer-substring beg (+ beg 2)) 16))) ;; In Lua, the sprite is a numeric literal. ((derived-mode-p 'lua-mode) (pico8--string-to-number (or (word-at-point) ""))) (t nil))) (defun pico8-goto-sprite-relevant-to-point () "Go to the sprite number relevant to the text at the point. When editing a flag, this is the flag number. When editing a map, this is the value at the map. When editing Lua code, this is the numeric literal in the code." (interactive) (let ((sprite (pico8-sprite-relevant-to-point))) (if sprite (pico8-goto-sprite sprite) (error "No sprite number was found at the point")))) (defun pico8-goto-thing-relevant-to-point () "Go to the thing relevant to the text at the point. When editing a flag or map, this is the corresponding sprite. When editing a sprite, this is the corresponding flag. When editing Lua code, how lucky are you feeling? This function needs a lot of work.." (interactive) (cond ((derived-mode-p 'pico8-gfx-mode) (pico8-goto-flag (car (pico8-gfx-current-position)))) ((or (derived-mode-p 'pico8-gff-mode) (derived-mode-p 'pico8-map-mode)) (pico8-goto-sprite-relevant-to-point)) ((derived-mode-p 'lua-mode) (let ((n (or (pico8--string-to-number (word-at-point)) 0)) (c (save-excursion (and (re-search-backward "\\" (- (point) 30) t) (char-after))))) (cond ((= c ?f) (pico8-goto-flag n)) ((= c ?s) (pico8-goto-sprite n)) (t (error "There's nothing obvious to go to"))))) (t (error "There's no obvious thing to go to")))) ;;; ;; Flycheck Integration ;; ;; This is more or less the same as the default Lua checkers, but we ;; need to write out a temporary file so it doesn't check the non-Lua ;; parts of the file. (defun pico8--lua-only (f &rest args) "If this is a PICO-8 buffer, run F(ARGS) on only the current section." (if (derived-mode-p 'pico8-lua-mode) (pico8-cartridge-with-section "lua" (let ((mainbuf (current-buffer)) (from (point-min)) (to (point-max))) (with-temp-buffer (insert "\n\n\n") ;; match line number with stripped header (insert-buffer-substring mainbuf from to) (insert " -- start __gfx__") ;; otherwise last line is ignored (apply f args)))) (apply f args))) (defconst pico8--lua-luacheckrc (expand-file-name "pico8.luacheckrc" (file-name-directory (or load-file-name buffer-file-name)))) (eval-when-compile (require 'flycheck)) (with-eval-after-load 'flycheck (advice-add 'flycheck-save-buffer-to-file :around #'pico8--lua-only) (flycheck-define-checker pico8-lua "A PICO-8 Lua syntax checker using the Lua compiler. See URL ‘http://www.lua.org/'." :command ("luac" "-p" source) :standard-input nil :error-patterns ((error line-start ;; Skip the name of the luac executable. (minimal-match (zero-or-more not-newline)) ":" line ": " (message) line-end)) :modes pico8-lua-mode) (flycheck-define-checker pico8-luacheck "A PICO-8 Lua syntax checker using luacheck. See URL ‘https://github.com/mpeterv/luacheck’." :command ("luacheck" "--formatter" "plain" "--codes" ; Show warning codes "--no-color" (option "--config" pico8--lua-luacheckrc) "--filename" source-original source) :error-patterns ((warning line-start (optional (file-name)) ":" line ":" column ": (" (id "W" (one-or-more digit)) ") " (message) line-end) (error line-start (optional (file-name)) ":" line ":" column ":" ;; ‘luacheck’ before 0.11.0 did not output codes for errors, hence ;; the ID is optional here (optional " (" (id "E" (one-or-more digit)) ") ") (message) line-end)) :modes pico8-lua-mode) (add-to-list 'flycheck-checkers 'pico8-lua) (add-to-list 'flycheck-checkers 'pico8-luacheck)) ;;; ;; Finally - pico8-mode! (defmacro pico8--defchunkmode (name) "Define a PICO-8 polymode chunk for section NAME." `(defconst ,(intern (concat "pico8--pm-inner-" name)) (pm-hbtchunkmode :mode ',(intern (format "pico8-%s-mode" name)) :head-mode 'host :head-reg ,(format "^__%s__\n" name) :tail-reg "^__[a-z]\\{3,5\\}__\n\\|^\n\\'"))) (defconst pico8--pm-poly (pm-polymode-multi :hostmode (defconst pico8--pm-host (pm-bchunkmode :mode 'pico8-cartridge-mode)) :innermodes (list (pico8--defchunkmode "lua") (pico8--defchunkmode "gfx") (pico8--defchunkmode "gff") (pico8--defchunkmode "map") (pico8--defchunkmode "sfx") (pico8--defchunkmode "music")))) (define-polymode pico8-mode pico8--pm-poly :lighter "P8" :keymap '(("\C-c\C-r" . pico8-run-cartridge) ("\C-c\C-e" . pico8-load-cartridge) ("\M-gs" . pico8-goto-sprite) ("\M-gS" . pico8-goto-sprite-relevant-to-point) ("\M-gf" . pico8-goto-flag) ("\M-g." . pico8-goto-thing-relevant-to-point)) (toggle-truncate-lines 1)) (add-to-list 'auto-mode-alist '("\\.p8$" . pico8-mode)) (add-to-list 'magic-mode-alist '("pico-8 cartridge" . pico8-mode)) (provide 'pico8) ;;; pico8.el ends here ;; Local Variables: ;; sentence-end-double-space: t ;; End: