You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

710 lines
24 KiB

;;; tacc.el --- A pomidoro timer supporting tags and customizable export
;; Author: Brady McDonough <me@bradymcd.ca>
;; URL: git.bradymcd.ca/tacc
;; Keywords: tools, time, pomodoro, accountability
;; Version: 0.0.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 2, or version 3 at your
;; option.
;;
;; 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 GNU Emacs. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; This implements a slightly modified pomidoro timer with a focus on
;; programmer customizability.
;; By default it is just a timer and the serialization and recording of your
;; time data is up to you (if that's something you even need)
;; Each successive work and break timer may be longer than the last. Work time
;; can also be tagged for better time accounting.
;;; Index:
;; 01.1 Indexed settings variables
;; 01.2 Indexed face settings
;; 02 Error types
;; 03 Behaviour group
;; 03.1 Tag prompt text completion
;; 03.2 Sound
;; 04 Record keeping
;; 05 Timer display buffer
;; 05.2 State checks
;; 05.3 State manipulation
;; 05.4 Timer buffer rendering
;; 07 Initialization
;; 08.1 Interactive helpers
;; 08.2 Interactive functions
;; 09 Hooks registration
;; 10 Provides
;;; Code:
;;; -*- lexical-binding: t -*-
;; TODOS:
;; ERROR: mapconcat doesn't seem to respect lexical binding which leaves me unable to use a closure for a ticker
;; For now the code which relies on this will be noop'd
;; ERROR: tacc-tick is returning a (wrong-number-of-arguments) error.
;; ERROR: tag doesn't print,
;; BUG : Countdown was ??? because 0 doesn't default to zero but "now".
;; That does leave an edgecase in the countdown display I'll need to explicitly check for
;; ERROR: timer shutdown seems to leave things in a wrong state and buffer doesn't die properly
;; Optional
(require 'icicles nil t)
;; 01.1 Customs and Indexed Variables
(defconst tacc-dir (file-name-directory load-file-name))
(defgroup tacc nil
"Root group definition for tacc"
:prefix "tacc-"
:group 'applications)
(defcustom tacc-buffer-name "*Work Timer*"
"The name used for the timer display buffer"
:type 'string :group 'tacc)
(defcustom tacc-suppress-tag-prompt t
"If set nil tacc will ask for a new tag every time a work period begins"
:type 'boolean :group 'tacc)
(defvar tacc-display-buffer-settings '((display-buffer-below-selected)
(inhibit-same-window . t)
(dedicated . t))
"Passed into display-buffer. How a window should be created or reused")
(defvar tacc-display-buffer-window-settings '((window-min-height . 10)
(window-height . 0.25))
"Passed into display-buffer. The window's overall size")
(defun m:s (mins secs)
"Readability function for minute and second values"
(+ secs (* 60 mins)))
(defcustom tacc-work-seconds (m:s 30 0)
"The length of a base pomodoro round"
:type 'integer :group 'tacc)
(defcustom tacc-work-seconds-increment (m:s 5 0)
"The time to add to each successive work cycle"
:type 'integer :group 'tacc)
(defcustom tacc-break-seconds (m:s 7 0)
"The length of a base break round"
:type 'integer :group 'tacc)
(defcustom tacc-break-seconds-increment (m:s 1 30)
"The time to add to each successive break cycle"
:type 'integer :group 'tacc)
(defcustom tacc-chime-interval (m:s 0 27)
"The time between alerts for timers running past their length"
:type 'integer :group 'tacc)
;; 01.2 Faces
(defgroup tacc-faces nil
"Custom faces for tacc"
:group 'tacc
:group 'faces
:prefix "tacc-")
(defface tacc-graph-work
'((t :inherit success))
"For work graph bars"
:group 'tacc-faces)
(defface tacc-graph-break
'((t :inherit warning))
"For break graph bars"
:group 'tacc-faces)
(defface tacc-graph-void
'((t :inherit shadow))
"For filler graph bars"
:group 'tacc-faces)
(defface tacc-clock
'((t :height 3.0))
"For the clock and timer"
:group 'tacc-faces)
(defface tacc-clock-pause
'((t :inherit (tacc-clock italic ansi-slow-blink)))
"For the timer while the timer isn't running"
:group 'tacc-faces)
(defface tacc-overwork
'((t :inherit (tacc-clock error)))
"for the timer whenever we are in overwork"
:group 'tacc-faces)
(defface tacc-tag
'((t :inherit link-visited))
"The face the tag is printed in"
:group 'tacc-faces)
;; 02 Errors
(define-error 'tacc-error "Generic error from the tacc-timer module" 'error)
(define-error 'tacc-error-illegal-buffer-state
"Precondition failed, the timer buffer is in an invalid state" 'tacc-error)
(define-error 'tacc-error-serialization-registered
"Failed to register a serialization, serializations must have unique names"
'tacc-error)
;; 03 Behavior group
(defgroup tacc-behavior nil
"Low level behavior settings for tacc"
:group 'tacc
:prefix "tacc-")
;; 03.1 Tag prompts and completion
(defvar tacc-tag-history nil
"Tag name history, used for completions")
(defcustom tacc-tag-history-file
(expand-file-name "tags-history" tacc-dir)
"Tag history storage file, for completions"
:type '(file :must-match t)
:group 'tacc-behavior)
(defcustom tacc-tag-completion-functions nil
"A list of user defined functions for generating completions for tagging.
Each function must have no required arguments and return a list of strings"
:type 'hook
:group 'tacc-behavior)
(defun tacc-load-tag-history ()
"Load completions for tags"
(with-temp-buffer
(insert-file-contents tacc-tag-history-file)
(setq tacc-tag-history (split-string (buffer-string) "\n"))))
(defun tacc-save-tag-history ()
"Save completions for tags"
(with-temp-file tacc-tag-history-file
(insert (mapconcat 'identity
tacc-tag-history
"\n"))))
;; 03.2 Sound
(defcustom tacc-sound-tick
(expand-file-name "tick.wav" tacc-dir)
"The 'Tick' sound of the timer"
:type '(file :must-match t)
:group 'tacc-behavior)
(defcustom tacc-sound-chime
(expand-file-name "chime.wav" tacc-dir)
"The break and work alarm sound of the timer"
:type '(file :must-match t)
:group 'tacc-behavior)
(defun tacc-play-sound-file-emacs (file)
"Play some file using an asynchronous subprocess"
(if (fboundp 'play-sound-internal)
(start-process "tacc-play-sound"
tacc-buffer-name
(car command-line-args)
"-Q"
"--batch"
"--eval" (format "(play-sound-file \"%s\")" file))
(warn "Emacs lacks builtin sound support")))
(defcustom tacc-play-sound-file #'tacc-play-sound-file-emacs
"Function used to play sounds. Set to nil to disable sound.
It's best to use something asynchronous (like spawning a child emacs process)"
:type '(choice (const nil)
function)
:group 'tacc-behavior)
;; 04 Record keeping
(defvar tacc-serials '()
"A list of serialization functions keyed with names registered at runtime")
(defun tacc-register-serialization (name serialize)
"The supplied `serialize' function will be run every time a work session has
ended, either because `tacc-end-timer' was called by the user or when emacs
closes with a session still live. Supplied serialize function must take as an
argument a cons list of records.
A record is an alist layed out as follows:
`(:ts . <unix timestamp>)
(:end . <unix timestamp>)
(:state . <:break | :work>)
(:tag . <an assigned tag name>)'
The supplied `name' will be used to generate a new (empty) abnormal hook,
`tacc-<name>-output-functions'. Each function added to this hook will be
called with the output of the `serialize' function as an argument. Keep in
mind that this is *not* a copy, if your output function mutates its input for
any reason it should copy the argument first."
(let* ((sym-name (format "tacc-%s-output-functions" name)))
(if (intern-soft sym-name)
(error "tacc-register-serialization failed, it seems %s is already \
registered" sym-name)
(set (intern sym-name) '())
(push (cons name serialize) tacc-serials))))
(defun tacc-save-record (records)
"Calls all user-defined serializers and output functions"
(dolist (ser tacc-serials)
(let* ((sym-name (format "tacc-%s-output-functions" (car ser)))
(fn (cdr ser))
(data (funcall fn records)))
(run-hook-with-args (intern sym-name) data))))
;; 05 Timer display buffer
(defvar tacc-timer-mode-map
(let ((map (make-sparse-keymap)))
map))
(define-derived-mode tacc-timer-mode
special-mode tacc-buffer-name
"Major mode for the tacc timer"
(setq-local show-trailing-whitespace nil
tacc-locs `((clock-start . ,(copy-marker 0))
(clock-end . ,(copy-marker 0))
(graph-start . ,(copy-marker 0))
(graph-end . ,(copy-marker 0)))))
;; ERROR: It seems like this style of ticker doesn't work and I don't have time to look into it
;; more deeply. As far as I can tell I've copied the reference examples pretty closely.
;; In the meantime a "dumber" solution that works is being used
;; (defun cycle-ticker ()
;; "A closure with an internal count, only increments when fed 'break timer state"
;; (let ((ticker 0))
;; (lambda (&optional state)
;; (cond ((eq state 'break) (setq ticker (1+ ticker)))
;; ( 't ticker)))))
(defvar tacc-info `((history . ())
(cycle . 0)
(work-ts . nil)
(break-ts . nil)
(end-ts . nil)
(state . work)
(tag . ""))
"State information related to the timer")
(defvar tacc-timer nil
"Timer object controlling tacc's second-by-second update")
;; 05.2 State checks
(defun tacc-buffer ()
"Gets the tacc-timer buffer if there is one"
(get-buffer tacc-buffer-name))
(defun tacc-running? ()
"Checks if there is a live timer running"
(timerp tacc-timer))
(defun tacc-running-state? (state)
"Checks if the state supplied indicates a live timer"
(cond ((eq state 'pause) nil)
((eq state 'stop) nil)
((eq state 'work) t)
((eq state 'break) t)
('t (signal 'tacc-error-illegal-buffer-state state))))
(defun tacc-invariant ()
"Returns t if tacc is in a legal state for mutating tacc-info
Note, if there is no tacc-buffer then you shouldn't be touching tacc-info"
(if (bufferp (tacc-buffer))
(if (and (timerp tacc-timer)
(tacc-running-state? tacc-info))
t)
(signal 'tacc-error-illegal-buffer-state (tacc-buffer))))
;; 05.3 Predicates and helpers
(defun tacc-chime-p (countdown-ts)
"Should I play a chime on this second?"
(if (time-less-p countdown-ts 0)
(= 0 (mod (time-to-seconds countdown-ts)
tacc-chime-interval))))
(defun tacc-work-period (cycle)
"How long is the current work period?"
(+ tacc-work-seconds
(* cycle tacc-work-seconds-increment)))
(defun tacc-break-period (cycle)
"How long is the current break period?"
(+ tacc-break-seconds
(* cycle tacc-break-seconds-increment)))
(defun tacc-start-ts (state work-ts break-ts)
"Given the state, which timestamp did we start on?"
(if (eq state 'work) work-ts break-ts))
(defun tacc-est-ts (state cycle work-ts break-ts)
"Given the state and cycle, how long is the current time segment?"
(let ((start-ts (tacc-start-ts state work-ts break-ts)))
(time-add start-ts
(if (eq state 'work)
(tacc-work-period cycle)
(tacc-break-period cycle)))))
;; 05.3 State manipulation
(defun tacc-tick ()
"Time marches on. Called every second while a tacc timer is running"
(let* ((now (current-time))
(state (alist-get 'state tacc-info))
(cycle (alist-get 'cycle tacc-info))
(work-ts (alist-get 'work-ts tacc-info))
(break-ts (alist-get 'break-ts tacc-info))
(start-ts (tacc-start-ts state work-ts break-ts))
(est (tacc-est-ts state cycle work-ts break-ts))
(countdown (time-subtract est now)))
(tacc-timer-buffer-update state start-ts est countdown now)
(if (functionp tacc-play-sound-file)
(cond ((tacc-chime-p countdown)
(funcall tacc-play-sound-file tacc-sound-chime))
(t (funcall tacc-play-sound-file tacc-sound-tick))))))
(defun tacc-schedule ()
"Note the time and schedule the first tick"
(setcdr (assoc 'state tacc-info) 'work)
(setcdr (assoc 'work-ts tacc-info) (current-time))
(setq tacc-timer (run-with-timer 't 1 'tacc-tick)))
(defun tacc-deschedule ()
"Deschedule the timer"
(setcdr (assoc 'end-ts tacc-info) (current-time))
(setq tacc-timer (cancel-timer tacc-timer)))
(defun tacc-push-current ()
"Push the current period into the record"
(let* ((stack (assoc 'history tacc-info))
(work-ts (assoc 'work-ts tacc-info))
(break-ts (assoc 'break-ts tacc-info))
(end-ts (assoc 'end-ts tacc-info))
(tag (alist-get 'tag tacc-info)))
(if work-ts
(push `((ts . ,work-ts)
(end . ,(if (cdr break-ts)
(cdr break-ts)
(cdr end-ts)))
(state . work)
(tag . ,tag))
(cdr stack)))
(if break-ts
(push `((ts . ,(cdr break-ts))
(end . ,(cdr end-ts))
(state . break)
(tag . ,tag))
(cdr stack)))
(setcdr work-ts nil)
(setcdr break-ts nil)
(setcdr end-ts nil)))
(defun tacc-kill-buffer-confirm ()
"Ask for confirmation if a timer is still running"
(if (tacc-running?)
(yes-or-no-p "Are you sure you want to kill the timer buffer? This will\
end the current timer session as well.")))
(defun tacc-buffer-cleanup ()
"Save and cleanup the timer state if a timer is still live"
(if (tacc-running?)
(progn (tacc-deschedule)
(tacc-push-current))))
;; 06.4 Rendering
(defun space-divider (width segments)
(let ((ticker 0)
(rem (mod width segments))
(len (/ width segments)))
(lambda ()
(setf ticker (1+ ticker))
(+ len (if (<= ticker rem) 1 0)))))
(defun gradient-string (width chars)
;; (mapconcat (let ((ticker 0)
;; (rem (mod width (length chars)))
;; (len (/ width (length chars))))
;; (lambda (c)
;; (make-string (+ len (if (<= (setq ticker (1+ ticker)) rem) 1 0))
;; c)))
;; chars ""))
(make-string width (aref chars 0)))
(defun tacc-header-render (width)
"Returns a string for the header of the tacc timer"
(propertize (gradient-string width "'\"'") ;▓░ ▒█
'face 'shadow))
(defun tacc-spacer-render (width)
"Returns a string for spacing vertically"
"\n")
(defmacro tacc-render-with-buffer (&rest body)
"Executes the body in tacc-buffer with (let-alist tacc-info), suppress read only"
`(let* ((buffer (tacc-buffer))
(window (get-buffer-window buffer t)))
(with-current-buffer buffer
(let ((width (window-total-width window 'floor))
(inhibit-read-only t))
(let-alist (buffer-local-value 'tacc-locs buffer)
(goto-char (point-min))
,@body)))))
(defmacro tacc-insert-between (start end &rest strings)
"Replace the text in the region between start and end with string"
`(save-restriction
(narrow-to-region ,start ,end)
(delete-region ,start ,end)
(goto-char ,start)
(insert ,@strings)
(set-marker ,start (point-min))
(set-marker ,end (point-max))))
(defconst tacc-pause-glyph ?⏸)
(defconst tacc-run-glyph ?▶)
(defconst tacc-graph-char ?█)
(defun tacc-init-timer-buffer ()
"Initialized the timer buffer display. Sets needed edit positions"
(let* ((buffer (get-buffer-create tacc-buffer-name))
(window (display-buffer buffer
(append tacc-display-buffer-settings
tacc-display-buffer-window-settings))))
(with-current-buffer buffer
(tacc-timer-mode)
(add-hook 'kill-buffer-query-functions 'tacc-kill-buffer-confirm)
(let ((width (window-total-width window 'floor))
(inhibit-read-only t))
(goto-char (point-min))
(let-alist (buffer-local-value 'tacc-locs buffer)
(insert (tacc-header-render width))
(set-marker .clock-start (point))
(set-marker .clock-end (point-max))
(tacc-insert-between
.clock-start .clock-end
(let ((ts (current-time)))
(tacc-clock-render 'work ts ts ts)))
(insert (tacc-spacer-render width))
(set-marker .graph-start (point))
(set-marker .graph-end (point-max))
(tacc-insert-between
.graph-start .graph-end
"Tag: \n"
(tacc-graph-bar-render width 0.0 0.0 1.0)))))))
(defun tacc-clock-glyph (state)
"Returns the correct play or pause glyph for each state"
(string (cond ((eq state 'work) tacc-run-glyph)
((eq state 'break) tacc-run-glyph)
((eq state 'pause) tacc-pause-glyph)
((eq state 'stop) tacc-pause-glyph))))
(defun tacc-clock-render (state start countdown now)
"Draw the clock"
(let* ((overwork (time-less-p countdown 0))
(count-fmt (if overwork "+%M:%S" "-%M:%S"))
(count-face (cond (overwork 'tacc-overwork)
((eq state 'pause) 'tacc-clock-pause)
('t 'tacc-clock))))
(concat
(propertize (format-time-string "%T" now) 'face 'tacc-clock)
(propertize (format-time-string count-fmt countdown) 'face count-face)
(propertize (tacc-clock-glyph state) 'face 'tacc-clock))))
(defun tacc-graph-bar-render (width work break void)
"Return a graph bar width characters wide with the given proportions as \
float"
(let ((width (- width 5)))
(concat
" "
(propertize (make-string (round (* width work)) tacc-graph-char)
'face 'tacc-graph-work)
(propertize (make-string (round (* width break)) tacc-graph-char)
'face 'tacc-graph-break)
(propertize (make-string (round (* width void)) tacc-graph-char)
'face 'tacc-graph-void)
" \n")))
(defun tacc-tag-render (tag)
(concat " Tag: "
(propertize (if (string= tag "") "<None>" tag)
'face 'tacc-tag)
"\n"))
(defun tacc-timer-buffer-update (state start-ts est-ts countdown now)
"Update the timer buffer rendering"
(tacc-render-with-buffer
(tacc-insert-between
.clock-start .clock-end
(let-alist tacc-info
(tacc-clock-render state start-ts countdown now)))
(tacc-insert-between
.graph-start .graph-end
(let-alist tacc-info
(concat
(tacc-tag-render .tag)
(let* ((end (if (> now est-ts) now est-ts))
(total (- end .work-ts))
(work-prop (/ (- (if .break-ts .break-ts now) .work-ts) total))
(break-prop (/ (if .break-ts (- now .break-ts) 0) total))
(void-prop (- 1 (+ work-prop break-prop))))
(tacc-graph-bar-render width work-prop break-prop void-prop)))))))
(defun tacc-new-graph-bar-render ()
(tacc-render-with-buffer
(save-restriction
(narrow-to-region .graph-start .graph-start)
(goto-char (point-min))
(insert (tacc-tag-render .tag)
(tacc-graph-bar-render width 0 0 1)
"\n")
(set-marker .graph-start (point-min))
(set-marker .graph-end (point-max)))))
;; 07 Init
(defun tacc-init ()
"Orchestrates all initialization functions"
(let ((tag (tacc-tag-prompt)))
(tacc-init-timer-buffer)
(setcdr (assoc 'tag tacc-info) tag)
(tacc-schedule)))
;; 08.1 Interactive Helpers
(defun tacc-tag-prompt ()
"Prompt for a tag and return the user's input"
(let ((last-tag (if (bufferp (tacc-buffer))
(alist-get 'tag tacc-info)
nil))
(completions (append (flatten-tree
(apply 'eval (if tacc-tag-completion-functions
tacc-tag-completion-functions
`(,(lambda () "")))))
tacc-tag-history)))
(completing-read (concat "Enter a tag name"
(if (< (length last-tag) 0)
(format " (currently %s)" last-tag) "")
": ")
completions
'stringp
nil
nil
'tacc-tag-history)))
(defun tacc-tag-prompt-soft ()
"Prompt for a tag unless the user doesn't want us to
Returns the current tag if no prompt is made"
(if tacc-suppress-tag-prompt
(alist-get 'tag tacc-info)
(tacc-tag-prompt)))
;; 08.2 Interactive
(defun tacc-start-timer ()
"Starts the tacc timer"
(interactive)
(if (bufferp (tacc-buffer))
(message "A timer is already running")
(tacc-init)))
(defun tacc-set-tag ()
"Sets a tag for live timer sessions going forward"
(interactive)
(if (tacc-running?)
(setcdr (assoc 'tag tacc-info) (tacc-tag-prompt)))
(message "There isn't a live timer session to tag"))
(defun tacc-cycle-increment (state)
"Increments the cycle counter if the current state is a 'break"
(if (eq state 'break)
(let* ((cycle-place (assoc 'cycle tacc-info))
(cycle (cdr cycle-place)))
(setcdr cycle-place (1+ cycle)))))
(defun tacc-timer-state-forward ()
"Move the tacc timer state forward"
(interactive)
(if (tacc-running?)
(let* ((state-place (assoc 'state tacc-info))
(state (cdr state-place))
(work-ts (assoc 'work-ts tacc-info))
(break-ts (assoc 'break-ts tacc-info))
(end-ts (assoc 'end-ts tacc-info))
(ts (current-time)))
(if (eq state 'work)
(progn (setcdr break-ts ts)
(setcdr state-place 'break))
(progn (setcdr end-ts ts)
(tacc-push-current)
(setcdr work-ts ts)
(setcdr state-place 'work)
(tacc-new-graph-bar-render)))
(tacc-cycle-increment state))
(message "There is no timer to advance")))
(defun tacc-timer-state-skip ()
"Move the tacc timer state to a new work cycle"
(interactive)
(if (tacc-running?)
(let* ((state-place (assoc 'state tacc-info))
(state (cdr state-place))
(work-ts (assoc 'work-ts tacc-info))
(end-ts (assoc 'end-ts tacc-info))
(ts (current-time)))
(setcdr end-ts ts)
(tacc-push-current)
(tacc-cycle-increment 'break)
(setcdr work-ts ts)
(setcdr state-place 'work)
(tacc-new-graph-bar-render))))
(defun tacc-pause-timer ()
"Pause the tacc timer session"
(tacc-deschedule)
(tacc-push-current)
(setcdr (assoc 'state tacc-info) 'pause))
(defun tacc-unpause-timer ()
"Unpause the tacc timer session"
(tacc-new-graph-bar-render)
(tacc-schedule))
(defun tacc-hold-timer ()
"Pause/unpause the tacc timer session"
(interactive)
(if (tacc-running?)
(let ((state (alist-get 'state tacc-info)))
(if (eq state 'pause)
(tacc-unpause-timer)
(tacc-pause-timer))))
(message "There isn't a live timer session to pause/unpause"))
(defun tacc-end-timer ()
"Ends the current timer session, runs serialization and kills the buffer"
(interactive)
(tacc-deschedule)
(tacc-push-current)
(tacc-save-record (assoc 'history tacc-info))
(setcdr (assoc 'state tacc-info) 'stop)
(kill-buffer))
;; 09 Hooks setup
(add-hook 'after-init-hook 'tacc-load-tag-history)
(add-hook 'kill-emacs-hook 'tacc-save-tag-history)
;; 10 Provides
(provide 'tacc)
;;; tacc.el ends here