;;; command-log-mode.el --- log keyboard commands to buffer

;; homepage: https://github.com/lewang/command-log-mode

;; Copyright (C) 2013 Nic Ferrier
;; Copyright (C) 2012 Le Wang
;; Copyright (C) 2004  Free Software Foundation, Inc.

;; Author: Michael Weber <michaelw@foldr.org>
;; Keywords: help
;; Package-Version: 20160413.447
;; Initial-version: <2004-10-07 11:41:28 michaelw>
;; Time-stamp: <2004-11-06 17:08:11 michaelw>

;; This file 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 2, or (at your option)
;; any later version.

;; This file 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 GNU Emacs; see the file COPYING.  If not, write to
;; the Free Software Foundation, Inc., 59 Temple Place - Suite 330,
;; Boston, MA 02111-1307, USA.

;;; Commentary:

;; This add-on can be used to demo Emacs to an audience.  When
;; activated, keystrokes get logged into a designated buffer, along
;; with the command bound to them.

;; To enable, use e.g.:
;;
;; (require 'command-log-mode)
;; (add-hook 'LaTeX-mode-hook 'command-log-mode)
;;
;; To see the log buffer, call M-x clm/open-command-log-buffer.

;; The key strokes in the log are decorated with ISO9601 timestamps on
;; the property `:time' so if you want to convert the log for
;; screencasting purposes you could use the time stamp as a key into
;; the video beginning.

;;; Code:

(eval-when-compile (require 'cl))

(defvar clm/log-text t
  "A non-nil setting means text will be saved to the command log.")

(defvar clm/log-repeat nil
  "A nil setting means repetitions of the same command are merged into the single log line.")

(defvar clm/recent-history-string ""
  "This string will hold recently typed text.")

(defun clm/recent-history ()
  (setq clm/recent-history-string
	(concat clm/recent-history-string
		(buffer-substring-no-properties (- (point) 1) (point)))))

(add-hook 'post-self-insert-hook 'clm/recent-history)

(defun clm/zap-recent-history ()
  (unless (or (member this-original-command
		      clm/log-command-exceptions*)
	      (eq this-original-command #'self-insert-command))
    (setq clm/recent-history-string "")))

(add-hook 'post-command-hook 'clm/zap-recent-history)

(defvar clm/time-string "%Y-%m-%dT%H:%M:%S"
  "The string sent to `format-time-string' when command time is logged.")

(defvar clm/logging-dir "~/log/"
  "Directory in which to store files containing logged commands.")

(defvar clm/log-command-exceptions*
  '(nil self-insert-command backward-char forward-char
        delete-char delete-backward-char backward-delete-char
        backward-delete-char-untabify
        universal-argument universal-argument-other-key
        universal-argument-minus universal-argument-more
        beginning-of-line end-of-line recenter
        move-end-of-line move-beginning-of-line
        handle-switch-frame
        newline previous-line next-line)
  "A list commands which should not be logged, despite logging being enabled.
Frequently used non-interesting commands (like cursor movements) should be put here.")

(defvar clm/command-log-buffer nil
  "Reference of the currenly used buffer to display logged commands.")
(defvar clm/command-repetitions 0
  "Count of how often the last keyboard commands has been repeated.")
(defvar clm/last-keyboard-command nil
  "Last logged keyboard command.")


(defvar clm/log-command-indentation 11
  "*Indentation of commands in command log buffer.")

(defgroup command-log nil
  "Customization for the command log.")

(defcustom command-log-mode-auto-show nil
  "Show the command-log window or frame automatically."
  :group 'command-log
  :type 'boolean)

(defcustom command-log-mode-window-size 40
  "The size of the command-log window."
  :group 'command-log
  :type 'integer)

(defcustom command-log-mode-window-font-size 2
  "The font-size of the command-log window."
  :group 'command-log
  :type 'integer)

(defcustom command-log-mode-key-binding-open-log "C-c o"
  "The key binding used to toggle the log window."
  :group 'command-log
  :type '(radio
          (const :tag "No key" nil)
          (key-sequence "C-c o"))) ;; this is not right though it works for kbd

(defcustom command-log-mode-open-log-turns-on-mode nil
  "Does opening the command log turn on the mode?"
  :group 'command-log
  :type 'boolean)

(defcustom command-log-mode-is-global nil
  "Does turning on command-log-mode happen globally?"
  :group 'command-log
  :type 'boolean)

;;;###autoload
(define-minor-mode command-log-mode
  "Toggle keyboard command logging."
  :init-value nil
  :lighter " command-log"
  :keymap nil
  (if command-log-mode
      (when (and
             command-log-mode-auto-show
             (not (get-buffer-window clm/command-log-buffer)))
        (clm/open-command-log-buffer))
      ;; We can close the window though
      (clm/close-command-log-buffer)))

(define-global-minor-mode global-command-log-mode command-log-mode
  command-log-mode)

(defun clm/buffer-log-command-p (cmd &optional buffer)
  "Determines whether keyboard command CMD should be logged.
If non-nil, BUFFER specifies the buffer used to determine whether CMD should be logged.
If BUFFER is nil, the current buffer is assumed."
  (let ((val (if buffer
		 (buffer-local-value command-log-mode buffer)
	       command-log-mode)))
    (and (not (null val))
	 (null (member cmd clm/log-command-exceptions*)))))

(defmacro clm/save-command-environment (&rest body)
  (declare (indent 0))
  `(let ((deactivate-mark nil) ; do not deactivate mark in transient
                                        ; mark mode
	 ;; do not let random commands scribble over
	 ;; {THIS,LAST}-COMMAND
	 (this-command this-command)
	 (last-command last-command))
     ,@body))

(defun clm/open-command-log-buffer (&optional arg)
  "Opens (and creates, if non-existant) a buffer used for logging keyboard commands.
If ARG is Non-nil, the existing command log buffer is cleared."
  (interactive "P")
  (with-current-buffer 
      (setq clm/command-log-buffer
            (get-buffer-create " *command-log*"))
    (text-scale-set 1))
  (when arg
    (with-current-buffer clm/command-log-buffer
      (erase-buffer)))
  (let ((new-win (split-window-horizontally
                  (- 0 command-log-mode-window-size))))
    (set-window-buffer new-win clm/command-log-buffer)
    (set-window-dedicated-p new-win t)))

(defun clm/close-command-log-buffer ()
  "Close the command log window."
  (interactive)
  (with-current-buffer
      (setq clm/command-log-buffer
            (get-buffer-create " *command-log*"))
    (let ((win (get-buffer-window (current-buffer))))
      (when (windowp win)
        (delete-window win)))))

;;;###autoload
(defun clm/toggle-command-log-buffer (&optional arg)
  "Toggle the command log showing or not."
  (interactive "P")
  (when (and command-log-mode-open-log-turns-on-mode
             (not command-log-mode))
    (if command-log-mode-is-global
        (global-command-log-mode)
        (command-log-mode)))
  (with-current-buffer
      (setq clm/command-log-buffer
            (get-buffer-create " *command-log*"))
    (let ((win (get-buffer-window (current-buffer))))
      (if (windowp win)
          (clm/close-command-log-buffer)
          ;; Else open the window
          (clm/open-command-log-buffer arg)))))

(defun clm/scroll-buffer-window (buffer &optional move-fn)
  "Updates `point' of windows containing BUFFER according to MOVE-FN.
If non-nil, MOVE-FN is called on every window which displays BUFFER.
If nil, MOVE-FN defaults to scrolling to the bottom, making the last line visible.

Scrolling up can be accomplished with:
\(clm/scroll-buffer-window buf (lambda () (goto-char (point-min))))
"
  (let ((selected (selected-window))
	(point-mover (or move-fn
			 (function (lambda () (goto-char (point-max)))))))
    (walk-windows (function (lambda (window)
			      (when (eq (window-buffer window) buffer)
				(select-window window)
				(funcall point-mover)
				(select-window selected))))
		  nil t)))

(defmacro clm/with-command-log-buffer (&rest body)
  (declare (indent 0))
  `(when (and (not (null clm/command-log-buffer))
	      (buffer-name clm/command-log-buffer))
     (with-current-buffer clm/command-log-buffer
       ,@body)))

(defun clm/log-command (&optional cmd)
  "Hook into `pre-command-hook' to intercept command activation."
  (clm/save-command-environment
    (setq cmd (or cmd this-command))
    (when (clm/buffer-log-command-p cmd)
      (clm/with-command-log-buffer
        (let ((current (current-buffer)))
          (goto-char (point-max))
          (cond ((and (not clm/log-repeat) (eq cmd clm/last-keyboard-command))
                 (incf clm/command-repetitions)
                 (save-match-data
                   (when (and (> clm/command-repetitions 1)
                              (search-backward "[" (line-beginning-position -1) t))
                     (delete-region (point) (line-end-position))))
                 (backward-char) ; skip over either ?\newline or ?\space before ?\[
                 (insert " [")
                 (princ (1+ clm/command-repetitions) current)
                 (insert " times]"))
                (t ;; (message "last cmd: %s cur: %s" last-command cmd)
                 ;; showing accumulated text with interleaved key presses isn't very useful
		 (when (and clm/log-text (not clm/log-repeat))
		   (if (eq clm/last-keyboard-command 'self-insert-command)
		       (insert "[text: " clm/recent-history-string "]\n")))
                 (setq clm/command-repetitions 0)
                 (insert
                  (propertize
                   (key-description (this-command-keys))
                   :time  (format-time-string clm/time-string (current-time))))
                 (when (>= (current-column) clm/log-command-indentation)
                   (newline))
                 (move-to-column clm/log-command-indentation t)
                 (princ (if (byte-code-function-p cmd) "<bytecode>" cmd) current)
                 (newline)
                 (setq clm/last-keyboard-command cmd)))
          (clm/scroll-buffer-window current))))))

(defun clm/command-log-clear ()
  "Clear the command log buffer."
  (interactive)
  (with-current-buffer clm/command-log-buffer
    (erase-buffer)))

(defun clm/save-log-line (start end)
  "Helper function for `clm/save-command-log' to export text properties."
  (save-excursion
    (goto-char start)
    (let ((time (get-text-property (point) :time)))
      (if time
	  (list (cons start (if time 
				(concat "[" (get-text-property (point) :time) "] ")
			      "")))))))

(defun clm/save-command-log ()
  "Save commands to today's log.
Clears the command log buffer after saving."
  (interactive)
  (save-window-excursion
    (set-buffer (get-buffer " *command-log*"))
    (goto-char (point-min))
    (let ((now (format-time-string "%Y-%m-%d"))
	  (write-region-annotate-functions '(clm/save-log-line)))
      (while (and (re-search-forward "^.*" nil t)
		  (not (eobp)))
	(append-to-file (line-beginning-position) (1+ (line-end-position)) (concat clm/logging-dir now))))
    (clm/command-log-clear)))

(add-hook 'pre-command-hook 'clm/log-command)

(eval-after-load 'command-log-mode
  '(when command-log-mode-key-binding-open-log
    (global-set-key
     (kbd command-log-mode-key-binding-open-log)
     'clm/toggle-command-log-buffer)))

(provide 'command-log-mode)

;;; command-log-mode.el ends here