;;; tacc.el --- A pomidoro timer supporting tags and customizable export ;; Author: Brady McDonough ;; 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 . ;;; 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 Tag prompt text completion ;; 04 Sound ;; 05 Record keeping apparatus ;; 06.1 Timer buffer definiton ;; 06.2 Timer buffer state predicates, sanity checks ;; 06.3 Timer buffer state manipulators ;; 06.4 Timer buffer rendering ;; 07 Initialization ;; 08.1 Interactive helpers ;; 08.2 Interactive functions ;; 09 Hooks registration ;; 10 Provides ;;; Code: ;;; -*- lexical-binding: t -*- ;; 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 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) ;; Behavior group (defgroup tacc-behavior nil "Low level behavior settings for tacc" :group 'tacc :prefix "tacc-") ;; 03 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")))) ;; 04 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) ;; 05 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 . ) (:end . ) (:state . <:break | :work>) (:tag . )' The supplied `name' will be used to generate a new (empty) abnormal hook, `tacc--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)))) ;; 06.1 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 lexical-binding t tacc-locs `((clock-start . ,(copy-marker 0)) (clock-end . ,(copy-marker 0)) (graph-start . ,(copy-marker 0)) (graph-end . ,(copy-marker 0))) tacc-info `((history . ()) (cycle . ,(let ((ticker 0)) (lambda (&optional state) (cond ((eq state 'break) (setq ticker (1+ ticker))) ('t ticker))))) (work-ts . nil) (break-ts . nil) (end-ts . nil) (state . work) (tag . "")) tacc-timer '())) ;; 06.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 (buffer-local-value 'tacc-timer (tacc-buffer)))) (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 () "Signals an illegal-buffer-state error or returns t" (if (let ((buffer (tacc-buffer))) (if (bufferp buffer) (with-current-buffer buffer (let ((timer (buffer-local-value 'tacc-timer buffer)) (tacc-info (buffer-local-value 'tacc-info buffer))) (eq (timerp (buffer-local-value 'tacc-timer buffer)) (tacc-running-state? (alist-get 'state tacc-info)))) t) nil)) t (signal 'tacc-error-illegal-buffer-state (tacc-buffer)))) (defun tacc-chime-p (countdown-ts) (if (time-less-p countdown-ts 0) (mod (time-to-seconds countdown-ts) tacc-chime-interval))) (defun tacc-work-period (cycle) (+ tacc-work-seconds (* cycle tacc-work-seconds-increment))) (defun tacc-break-period (cycle) (+ tacc-break-seconds (* cycle tacc-break-seconds-increment))) (defun tacc-start-ts (state work-ts break-ts) (if (eq state 'work) work-ts break-ts)) (defun tacc-est-ts (state cycle work-ts break-ts) (let ((start-ts (tacc-start-ts state work-ts break-ts))) (if (eq state 'work) (tacc-work-period cycle) (tacc-break-period cycle)))) ;; 06.3 State manipulation (defmacro tacc-with-buffer (&rest body) "Execute the body in the tacc buffer. Expose tacc-info" `(let ((buffer (tacc-buffer))) (with-current-buffer buffer (let ((tacc-info (buffer-local-value 'tacc-info buffer))) ,@body)))) (defun tacc-tick () "Time marches on. Called every second while a tacc timer is running" (let* ((now (current-time)) (tacc-info (buffer-local-value 'tacc-info (tacc-buffer))) (state (alist-get 'state tacc-info)) (cycle (funcall (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 now ) (if 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" (let ((tacc-info (buffer-local-value 'tacc-info (tacc-buffer)))) (setcdr (assoc 'state tacc-info) 'work) (setcdr (assoc 'work-ts tacc-info) (current-time)) (setq-local tacc-timer (run-with-timer 't 1 'tacc-tick :timer-max-repeats 1)))) (defun tacc-deschedule () "Deschedule the timer" (let ((tacc-info (buffer-local-value 'tacc-info (tacc-buffer)))) (setcdr (assoc 'end-ts tacc-info) (current-time)) (setq-local tacc-timer (cancel-timer tacc-timer)))) (defun tacc-push-current () "Push the current period into the record" (let* ((tacc-info (buffer-local-value 'tacc-info (tacc-buffer))) (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 . ,(cdr 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)) (lambda (c) (let ((rem (mod width (length chars))) (len (/ width (length chars)))) (make-string (progn (setf ticker (1+ ticker)) (+ len (if (<= ticker rem) 1 0))) c))))) chars "")) (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" (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 est now) "Draw the clock" (let* ((elapsed (time-subtract now start)) (overwork (time-less-p elapsed est)) (countdown (if overwork (time-subtract elapsed est) (time-subtract est elapsed))) (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) 'tacc-clock) (propertize (format-time-string count-fmt count-face)) (propertize (tacc-clock-glyph state) 'tacc-clock)))) (defun tacc-current-tag-render () "Draw the tag name" (concat "Tag: " (propertize (alist-get 'tag (buffer-local-value 'tacc-info (tacc-buffer)) "") 'tacc-tag))) (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 2))) (concat "" (propertize (make-string (round (* width work)) tacc-graph-char) 'tacc-graph-work) (propertize (make-string (round (* width break)) tacc-graph-char) 'tacc-graph-break) (propertize (make-string (round (* width void)) tacc-graph-char) 'tacc-graph-void) " \n"))) (defun tacc-timer-buffer-update (state now) "Update the timer buffer rendering" (tacc-render-with-buffer (tacc-insert-between .clock-start .clock-end (let-alist (buffer-local-value 'tacc-info (tacc-buffer)) (tacc-clock-render state .work-ts .end-ts now))) (tacc-insert-between .graph-start .graph-end (tacc-current-tag-render) (let-alist (buffer-local-value 'tacc-info (tacc-buffer)) (let* ((end (if (> now .end-ts) now .end-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-current-tag-render) (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) (tacc-with-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 (buffer-local-value 'tacc-info (tacc-buffer))) 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 (buffer-local-value 'tacc-info (tacc-buffer))) (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?) (tacc-with-buffer (setcdr (assoc 'tag tacc-info) (tacc-tag-prompt))) (message "There isn't a live timer session to tag"))) (defun tacc-timer-state-forward () "Move the tacc timer state forward" (interactive) (if (tacc-running?) (tacc-with-buffer (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))) (funcall (alist-get 'cycle tacc-info) 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?) (tacc-with-buffer (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) (funcall (alist-get 'cycle tacc-info) '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 (buffer-local-value 'tacc-info (tacc-buffer))) '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?) (tacc-with-buffer (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-with-buffer (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