Keeping track of state is hard!
21 KiB
Demonstrations in Emacs
A literate programming file for creating and running demonstrations
Introduction
Once I made demonstrations within Emacs with my demo-it project. While on MELPA, I wanted to use my own cloned version to make sure I can keep debugging it.
(use-package demo-it
:straight (:local-repo "~/src/demo-it")
;; :straight (:host github :repo "howardabrams/demo-it")
:commands (demo-it-create demo-it-start demo-it-hide-mode-line
demo-it--presentation-display-set)
:custom (demo-it--insert-test-speed :faster))
But I feel I should replace it, and this project encapsulates the following goals:
- Flexible presentation that can use either
org-present
or continue toorg-tree-slide
- Simpler support functions for showing side windows and whatnot
- Most importantly, a more flexible demonstration where trigger events based on the current state.
Presentations with Org
A demonstration begins with an Org file where the screen shows a single heading with a larger font. Not much more. I have two projects that I like to use.
Org Present
Converted to use org-present. I love the hooks as that makes it easier to handle. My problem with org-present
is that it doesn’t always display images.
(use-package org-present
:config
(defvar ha-org-present-mode-line mode-line-format
"Cache previous mode-line format state")
(defun ha-org-blocks-hide-headers ()
"Make the headers and other block metadata invisible.
See `ha-org-blocks-show-headers'."
(let ((pattern (rx bol (zero-or-more space)
(or ":" "#")
(zero-or-more any) eol)))
(save-excursion
(goto-char (point-min))
(while (re-search-forward pattern nil t)
(let* ((start (1+ (match-beginning 0))) (end (1+ (match-end 0)))
(ovlay (make-overlay start end)))
(overlay-put ovlay 'invisible t))))))
(defun ha-org-blocks-show-headers ()
"Un-invisibilize the headers and other block metadata invisible.
In other words, this undoes what `ha-org-blocks-hide-headers' did."
(delete-all-overlays))
(defun ha-org-present-start ()
"Hook to run when starting a presentation.
This happens _after_ `org-present' has started."
(unless ha-org-present-mode-line
(setq ha-org-present-mode-line mode-line-format))
(goto-char (point-min)) (re-search-forward (rx bol "*"))
(ha-org-blocks-hide-headers)
(org-present-big)
(org-display-inline-images)
(org-present-read-only)
(jinx-mode -1) ; Turn off spell checking
(evil-normal-state)
(setq org-image-actual-width nil)
;; Clear the demonstration state cache:
(clrhash ha-demo-prev-state)
(setq mode-line-format nil)
(org-present-hide-cursor))
(defun ha-org-present-end ()
"Hook to run when ending a presentation.
This happens _after_ `org-present-quit' has occurred,
and attempts to _undo_ effects of `ha-org-present-start'."
(org-present-small)
(org-present-read-write)
(ha-org-blocks-show-headers)
(setq mode-line-format ha-org-present-mode-line)
(jinx-mode) ; Turn on spell checking
(org-present-show-cursor))
:bind
(:map org-present-mode-keymap
("<f5>" . org-present-next)
("S-<f5>" . org-present-previous)
("C-<f5>" . org-present-quit))
:general
(:states 'normal :keymaps 'org-present-mode-keymap
"+" #'org-present-big
"-" #'org-present-small
"<" #'org-present-beginning
">" #'org-present-end
"c" #'org-present-hide-cursor
"C" #'org-present-show-cursor
"n" #'org-present-next
"j" #'org-present-next
"k" #'org-present-prev
"p" #'org-present-prev
"r" #'org-present-read-only
"w" #'org-present-read-write
"Q" #'org-present-quit)
:hook
(org-present-mode . ha-org-present-start)
(org-present-mode-quit . ha-org-present-end))
Org Tree Slide
I’ve used org-tree-slide for years for showing org files as presentations. I like the simple presentation and it seems to shows all the images.
(use-package org-tree-slide
:config
(setq org-tree-slide-heading-emphasis nil
org-tree-slide-activate-message "† This demonstration is running in Emacs"
org-tree-slide-indicator '(:next nil :previous nil :content nil)
org-tree-slide-cursor-init nil)
(org-tree-slide-simple-profile)
(defun ha-org-tree-slide-start ()
"Configure the presentation display.
See `ha-org-tree-slide-stop' that undoes this."
(setq org-hide-emphasis-markers t)
(set-face-attribute 'org-quote nil
:inherit 'variable-pitch :slant 'italic)
(ha-demo-hide-cursor)
(ha-demo-presentation-frame)
(demo-it--presentation-display-set)
(ha-demo-hide-mode-line)
(git-gutter-mode -1)
(text-scale-set 4)
(flycheck-mode -1)
(jinx-mode -1))
(defun ha-org-tree-slide-stop ()
"Reset the display after a presentation.
See `ha-org-tree-slide-start' for what's set."
(demo-it--presentation-display-restore) ; Restore previous changes
(setq org-hide-emphasis-markers t)
(ha-demo-show-mode-line)
(ha-demo-show-cursor)
(ha-demo-normalize-frame)
(git-gutter-mode)
(text-scale-set 0)
(flycheck-mode)
(jinx-mode))
:bind
(("S-<f6>" . org-tree-slide-skip-done-toggle)
:map org-tree-slide-mode-map
("<f5>" . org-tree-slide-move-next-tree)
("S-<f5>" . org-tree-slide-move-previous-tree)
("M-<f5>" . org-tree-slide-content)
("C-<f5>" . (lambda () (interactive) (org-tree-slide-mode -1))))
:general
(:states 'normal :keymaps 'org-tree-slide-mode-map
"c" #'ha-demo-hide-cursor
"C" #'ha-demo-show-cursor
"n" #'org-tree-slide-move-next-tree
"j" #'org-tree-slide-move-next-tree
"k" #'org-tree-slide-move-previous-tree
"p" #'org-tree-slide-move-previous-tree
"Q" (lambda () (interactive) (org-slide-tree-mode -1)))
:hook
((org-tree-slide-play . ha-org-tree-slide-start)
(org-tree-slide-stop . ha-org-tree-slide-stop)))
New Demonstration
Instead of executing a sequence of demonstration steps, demonstrations key on “state”, that is, the active buffer or major-mode, or the heading of an Org file, etc. I described the guts of writing this code, but we bind a key to calling ha-demo-step
with a list of state matchers to functions to call when matched. For instance:
(define-ha-demo ha-simple-demo
(:heading "New Demonstration" :i 0) (message "Howdy")
(:heading "New Demonstration" :i 1) (message "Hi there"))
(global-set-key (kbd "<f6>") 'ha-simple-demo)
To make the contents of the expression easier to write, the define-ha-demo
as a macro. Otherwise we write a complicated cond
with lots of duplicated calls to ha-demo-state-match
(defined later). This macro creates a function, so the first parameter is the name of the function:
(defmacro define-ha-demo (demo-name &rest forms)
"Create a demonstration sequence from FORMS as DEMO-NAME function.
Call DEMO-NAME (as an interactive function), executes a function
based matching list of states at point. Where FORMS is an even
number of _matcher_ and _function_ to call.
Probably best to explain this in an example:
\(define-demo demo1
\(:buffer \"demonstrations.py\") \(message \"In a buffer\"\)
\(:mode 'dired-mode\) \(message \"In a dired\"\)
\(:heading \"Raven Civilizations\"\) \(message \"In an org file\"\)\)
Calling `(demo1)' displays a message based on position of the
point in a particular buffer or place in a heading in an Org file.
You can use the `:i' to specify different forms to call when
the trigger matches the first time, versus the second time, etc.
\(define-demo demo2
\(:buffer \"demonstrations.org\" :i 0\) \(message \"First time\"\)
\(:buffer \"demonstrations.org\" :i 1\) \(message \"Second time\"\)\)"
`(defun ,demo-name ()
(interactive)
(let ((state (list :buffer (buffer-name)
:mode major-mode
:heading (when (eq major-mode 'org-mode)
(org-get-heading)))))
(cond
,@(seq-map (lambda (tf-pair)
(seq-let (trigger func) tf-pair
(list
`(ha-demo-state-match ',trigger state)
func)))
(seq-partition forms 2))))))
The matching function, ha-demo-state-match
looks in a cache, the demo-prev-state
hash table, for the number of times we have triggered that state, and add that value into a new state variable we use to match, :itful-state
(yeah, naming is hard).
Note: If we match, we want to return non-nil, and update this new incremented value back in our cache:
(defun ha-demo-state-match (triggers state)
"Return non-nil if STATE contains all TRIGGERS.
The state also includes the number of times the triggers
matched during previous calls. We do this by keeping track
of the number of successful calls, and incrementing
the iteration... if this function returns non-nil."
;; If the first element is either parameter is NOT a list,
;; we group it into a list of tuples:
(when (not (listp (car triggers)))
(setq triggers (seq-partition triggers 2)))
(when (not (listp (car state)))
(setq state (seq-partition state 2)))
(let* ((iteration (gethash state ha-demo-prev-state 0))
(itful-state (cons `(:i ,iteration) state)))
(when (ha-demo-match triggers itful-state)
(puthash state (1+ iteration) ha-demo-prev-state))))
Notice the two when
expressions for using seq-partition
for converting a property-style list like (:a 1 :b 2 :c 3)
into an more standard associative list, like ((:a 1) (:b 2) (:c 3))
.
Let’s test:
(ert-deftest ha-demo-state-match-test ()
;; Not specifying a state should always work:
(should (ha-demo-state-match
'(:a 1) '((:a 1) (:b 2) (:c 4))))
(should (ha-demo-state-match
'(:a 1) '((:a 1) (:b 2) (:c 4))))
;; Reset number of iterations of possible states:
(clrhash ha-demo-prev-state)
;; With a clear hash, we should match on the
;; first (0) iteration:
(should (ha-demo-state-match
'(:a 1 :i 0) '((:a 1) (:b 3) (:c 4))))
;; Which should then match the next state:
(should (ha-demo-state-match
'(:a 1 :i 1) '((:a 1) (:b 3) (:c 4))))
;; But should not match any other state:
(should (not (ha-demo-state-match
'(:a 1 :i 5) '((:a 1) (:b 2) (:c 3))))))
But can I check if I have triggered a state once before? Let’s keep track of the states that have returned true before, in a hash table where the key is the state (a list of :buffer
, :mode
, :heading
, etc.) and the value is the number of times triggered at that state:
(defvar ha-demo-prev-state (make-hash-table :test 'equal)
"Matched states in keys, and store number of matches as values.")
Now, we have a new match function takes the state and triggers, where the trigger could include an iteration, :i
that limits a match. For instance:
-
(:buffer "foobar.txt" :i 0)
- triggers the first time we call this function in this buffer.
-
(:buffer "foobar.txt" :i 1)
- triggers the second time we call this function in this buffer.
If the triggers
doesn’t contain an :i
, it matches every time when meeting the other conditions.
Let’s create a function that could accept a list of triggering keys, and then compare that with another list representing the “current state” of the point, including the buffer, the mode, or the heading in an Org file. In this case, the magic happens by calling seq-difference
:
(defun ha-demo-match (triggers state)
"Return t if all elements of TRIGGERS are in STATE.
Where TRIGGERS and STATE are lists of key/value tuple
pairs, e.g. `((:a 1) (:b 2))'."
;; If difference returns anything, we've failed:
(not (seq-difference triggers state)))
Demonstration Support
What sort of functions will I often be doing?
Hide and Show the Cursor
The typical presentation software has an issue for hiding the cursor when working with Evil mode, and since setting cursor-type
to nil
doesn’t work in a graphical display (where we typically run a presentation), the following functions turn on/off the displayed cursor.
(defvar ha-demo-cursor nil
"List of cursor states stored during `ha-demo-hide-cursor' and
restored with `ha-demo-show-cursor'.")
(defun ha-demo-hide-cursor ()
"Hide the cursor for the current frame."
(interactive)
(unless ha-demo-cursor
(setq ha-demo-cursor
(list cursor-type
t ; (when (boundp 'evil-default-cursor) evil-default-cursor)
(when (boundp 'evil-emacs-state-cursor) evil-emacs-state-cursor)
(when (boundp 'evil-normal-state-cursor) evil-normal-state-cursor)
(default-value blink-cursor-mode)
(when (display-graphic-p)
(frame-parameter (selected-frame) 'cursor-type))))
;; Turn off the cursor blinking minor mode:
(blink-cursor-mode -1)
;; Change the cursor types for normal and Evil states:
(setq-local cursor-type nil)
(when (boundp 'evil-default-cursor)
(setq-local
evil-default-cursor nil
evil-emacs-state-cursor nil
evil-normal-state-cursor nil))
;; And most importantly, turn off the cursor for the selected frame:
(set-frame-parameter (selected-frame) 'cursor-type nil)))
(defun ha-demo-show-cursor ()
"Restore cursor properties turned off by `ha-demo-hide-cursor'."
(interactive)
(when ha-demo-cursor
(setq cursor-type (car ha-demo-cursor))
(when (boundp 'evil-default-cursor)
(setq-local
evil-default-cursor (nth 1 ha-demo-cursor)
evil-emacs-state-cursor (nth 2 ha-demo-cursor)
evil-normal-state-cursor (nth 3 ha-demo-cursor)))
(when (nth 4 ha-demo-cursor) (blink-cursor-mode 1))
(set-frame-parameter (selected-frame)
'cursor-type (nth 5 ha-demo-cursor))
(setq ha-demo-cursor nil)))
Hide and Show the Modeline
For Org file displayed as presentations as well as images, we probably don’t want the distraction associated with the modeline, but when we finish the presentation, let’s turn it back on …
(defvar ha-demo-mode-line nil)
(make-variable-buffer-local 'ha-demo-mode-line)
(defun ha-demo-hide-mode-line ()
"Hide mode line for a particular buffer."
(interactive)
(when mode-line-format
(setq ha-demo-mode-line mode-line-format)
(setq mode-line-format nil)))
(defun ha-demo-show-mode-line ()
"Restore mode hidden with `ha-demo-hide-mode-line'."
(interactive)
(if ha-demo-mode-line
(setq mode-line-format ha-demo-mode-line)))
Presentation Frame Properties
Like the work I’m doing to the mode-line, can we make the frame cleaner for a presentation?
(defvar ha-demo-frame-state nil
"Store frame properties during `ha-demo-presentation-frame' before
altering them, and then restore them with `ha-demo-normalize-frame'.")
(defun ha-demo-presentation-frame (&optional name)
"Remove the fringe and other frame settings.
See `ha-demo-normalize-frame' for restoration.
The NAME, if given, is the name of the frame."
(interactive)
(setq ha-demo-frame-state
(list
(frame-parameter (selected-frame) 'left-fringe)
(frame-parameter (selected-frame) 'right-fringe)))
(when name
(set-frame-parameter (selected-frame) 'name name)))
(defun ha-demo-normalize-frame ()
"Restore frame state from `ha-demo-presentation-frame'."
(interactive)
(set-frame-parameter (selected-frame) 'left-fringe (nth 0 ha-demo-frame-state))
(set-frame-parameter (selected-frame) 'right-fringe (nth 1 ha-demo-frame-state)))
Display File
Displaying a File with:
- On the side or covering the entire frame
- Larger font size
- Modeline or no modeline
- Going to a particular text or line
- Moving the cursor to the top or middle of the buffer window
All options? Should I use Common Lisp’s cl-defun
for the keyword parameters?
(cl-defun ha-demo-show-file (filename &key position size modeline
line heading shift commands)
"Show a file, FILENAME, in a buffer based on keyed parameters.
POSITION can be 'full 'right or 'below and positions the window.
SIZE is an integer for the font size based on the default size.
MODELINE is shown if non-line, default is to hide it.
LINE is either a line number or a regular expression to match.
HEADING is a headline from the currently display Org file.
SHIFT is the number of lines above the point to show, in case
the LINE shouldn't be at the top of the window.
COMMANDS is a lambda expression that can contain any other
instructions to happen to the buffer display."
(unless position
(setq position :right))
;; Step 1: Create a window
(pcase position
('full )
('right (progn (split-window-horizontally) (other-window 1)))
('below (progn (split-window-vertically) (other-window 1))))
;; We could do :left and :top by not doing the other window bit...
;; Step 2: Load the file or switch to the buffer:
(if (file-exists-p filename)
(find-file filename)
(switch-to-buffer filename))
(goto-char (point-min))
;; Step 3: Increase the font size
(when size
(text-scale-set size))
(when line
(if (integerp line)
(forward-line line)
(re-search-forward line nil t)))
(when heading
(re-search-forward (rx bol (one-or-more "*") (one-or-more space)
(literal heading))
nil t))
;; If SHIFT is positive integer, left that many line above point,
;; otherwise don't do anything to leave it in the middle.
;; If SHIFT is null, move it to the top of the buffer window:
(if shift
(when (integerp shift)
(recenter-top-bottom shift))
(recenter-top-bottom 0))
(unless modeline
(setq-local mode-line-format nil))
(when commands (funcall commands))
)
(funcall (lambda () (message "Hello")))
Let try it all together:
(ha-demo-show-file "ha-config.org" :position 'right :size 1 :modeline nil :line 418 :shift 4)
Or:
(ha-demo-show-file "ha-config.org" :modeline t
:heading "Text Expanders"
:commands (lambda () (jinx-mode -1)))