;;; tmr.el --- Set timers using a convenient notation -*- lexical-binding: t -*-

;; Copyright (C) 2020-2026  Free Software Foundation, Inc.

;; Author: Protesilaos <info@protesilaos.com>,
;;         Damien Cassou <damien@cassou.me>,
;;         Daniel Mendler <mail@daniel-mendler.de>
;;         Steven Allen <steven@stebalien.com>
;; Maintainer: Protesilaos <info@protesilaos.com>
;; URL: https://github.com/protesilaos/tmr
;; Version: 1.3.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: convenience, timer

;; This file is NOT part of GNU Emacs.

;; 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.
;;
;; 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 this program.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:
;;
;; TMR is an Emacs package that provides facilities for setting timers
;; using a convenient notation.
;;
;; Please read the manual for all the technicalities.  Either evaluate
;; (info "(tmr) Top") or visit <https://protesilaos.com/emacs/tmr>.

;;; Code:

(require 'seq)
(require 'format-spec)
(eval-when-compile
  (require 'cl-lib)
  (require 'subr-x))

(defgroup tmr ()
  "TMR May Ring: set timers using a simple notation."
  :link '(info-link :tag "Info Manual" "(tmr)")
  :link '(url-link :tag "Homepage" "https://protesilaos.com/emacs/tmr")
  :link '(emacs-library-link :tag "Library Source" "tmr.el")
  :group 'data)

;;;; User options

(defcustom tmr-description-list 'tmr-description-history
  "List of timer description presets.
The value can be either a list of strings or the symbol of a
variable that holds a list of strings.

The default value of `tmr-description-history', is the name of a
variable that contains input provided by the user at the relevant
prompt of the `tmr' and `tmr-with-details' commands."
  :type '(choice symbol (repeat string)))

(defcustom tmr-notification-urgency 'normal
  "The urgency level of the desktop notification.
Values can be `low', `normal' (default), or `critical'.

The desktop environment or notification daemon is responsible for
such notifications."
  :type '(choice
          (const :tag "Low" low)
          (const :tag "Normal" normal)
          (const :tag "Critical" critical)))

(defcustom tmr-sound-file
  "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga"
  "Path to sound file used by `tmr-sound-play'.
If nil, don't play any sound."
  :type '(choice
          file
          (const :tag "Off" nil)))

(make-obsolete-variable 'tmr-confirm-single-timer nil "1.4.0")

(defcustom tmr-timer-created-functions
  (list #'tmr-print-message-for-created-timer)
  "Functions to execute when a timer is created.
Each function must accept a timer as argument."
  :type 'hook
  :options '(tmr-print-message-for-created-timer))

(defcustom tmr-timer-finished-functions
  (list #'tmr-sound-play
        #'tmr-notification-notify
        #'tmr-print-message-for-finished-timer
        #'tmr-acknowledge-minibuffer)
  "Functions to execute when a timer is finished.
Each function must accept a timer as argument."
  :type 'hook
  :options (list #'tmr-sound-play
                 #'tmr-notification-notify
                 #'tmr-print-message-for-finished-timer
                 #'tmr-acknowledge-minibuffer
                 #'tmr-acknowledge-dialog))

(defcustom tmr-timer-repeat-functions
  (list #'tmr-sound-play
        #'tmr-notification-notify
        #'tmr-print-message-for-repeating-timer)
  "Functions to execute when a timer is about to repeat.
Each function must accept a timer as argument."
  :type 'hook
  :options (list #'tmr-sound-play
                 #'tmr-notification-notify
                 #'tmr-print-message-for-repeating-timer
                 #'tmr-acknowledge-minibuffer
                 #'tmr-acknowledge-dialog))

(defcustom tmr-timer-cancelled-functions
  (list #'tmr-print-message-for-cancelled-timer)
  "Functions to execute when a timer is cancelled.
Each function must accept a timer as argument."
  :type 'hook)

(defcustom tmr-timer-paused-functions
  (list #'tmr-print-message-for-paused-timer)
  "Functions to execute when a timer is paused.
Each function must accept a timer as argument."
  :package-version '(tmr . "1.3.0")
  :type 'hook)

(defcustom tmr-timer-resumed-functions
  (list #'tmr-print-message-for-resumed-timer)
  "Functions to execute when a timer is resumed.
Each function must accept a timer as argument."
  :package-version '(tmr . "1.3.0")
  :type 'hook)

(defcustom tmr-finished-indicator "✔"
  "Indicator for a finished timer, shown in `tmr-tabulated-view'."
  :package-version '(tmr . "1.0.0")
  :type 'string)

(defcustom tmr-acknowledge-timer-text "ack"
  "Text to confirm that a timer is acknowledged.
This is the input to be given to the minibuffer prompt that asks for
confirmation when a timer has to be acknowledged (acknowledgement here
means that the user confirms that the timer has elapsed)."
  :package-version '(tmr . "1.3.0")
  :type 'string)

(defcustom tmr-tabulated-refresh-interval 5
  "Refresh the `tmr-tabulated-view' buffer after these many seconds.
If the value is nil, then never automatically refresh that buffer: the
user can do that manually by invoking the command `revert-buffer'."
  :package-version '(tmr . "1.3.0")
  :type '(choice
          (natnum :tag "Seconds to auto-refresh the `tmr-tabulated-view' buffer")
          (const :tag "Never auto-refresh the `tmr-tabulated-view' buffer" nil)))

(defcustom tmr-tabulated-columns
  '( start end duration remaining paused
     repeatable remaining-repeats total-repeats
     acknowledge description)
  "List of columns used by `tmr-tabulated-view'.
This is a list of the following symbols:

- `start' for the start time;
- `end' for the end time;
- `duration' for the duration of the timer;
- `remaining' for how much time is left in the timer;
- `paused' for whether the timer is paused or not;
- `repeatable' for whether the timer is set to repeat or not;
- `remaining-repeats' for the number of repeats left;
- `total-repeats' for the total repeats specified originally;
- `ackonwledge' whether for the timer should stop only after being acknowledged;
- `description' for the description of the timer.

The order of symbols in the list is exactly how they will be displayed
by `tmr-tabulated-view'.  Duplicates are removed."
  :type '(repeat (choice
                  (const :tag "Start time" start)
                  (const :tag "End time" end)
                  (const :tag "Duration" duration)
                  (const :tag "Remaining time" remaining)
                  (const :tag "Paused status" paused)
                  (const :tag "Repeatable status" repeatable)
                  (const :tag "Remaining repeats" remaining-repeats)
                  (const :tag "Total repeats" total-repeats)
                  (const :tag "Acknowledge status" acknowledge)
                  (const :tag "Description" description)))
  :package-version '(tmr . "1.4.0")
  :group 'tmr)

(defun tmr-select-and-resize (window)
  "Select WINDOW and fit it to its buffer."
  (select-window window)
  (fit-window-to-buffer window))

(defcustom tmr-list-timers-action-alist
  '((display-buffer-reuse-mode-window display-buffer-at-bottom)
    (mode . tmr-tabulated-mode)
    (dedicated . t)
    (preserve-size . (t . t))
    (body-function . tmr-select-and-resize))
  "Action alist used by `tmr-tabulated-view' in interactive use.
This is the same data that is passed to `display-buffer-alist'.
Read Info node `(elisp) Displaying Buffers'.  As such, it is
meant for experienced users."
  :risky t
  :type `(alist
          :key-type (choice :tag "Condition" regexp
                            (function :tag "Matcher function"))
          :value-type ,display-buffer--action-custom-type)
  :package-version '(tmr . "1.1.0"))

(defcustom tmr-mode-line-format "%r%d"
  "Format string for displaying a timer in the mode-line.
Available format specifiers:
- %r: Remaining time.
- %d: Timer description (truncated to `tmr-mode-line-max-desc-length')."
  :type 'string
  :package-version '(tmr . "1.2.0")
  :group 'tmr)

(defcustom tmr-mode-line-separator " | "
  "String used to separate multiple timers in the mode-line."
  :type 'string
  :package-version '(tmr . "1.2.0")
  :group 'tmr)

(defcustom tmr-mode-line-max-timers 3
  "Maximum number of timers to display in the mode-line.
Set to nil to show all timers."
  :type '(choice (const :tag "Show all" nil)
                 (integer :tag "Maximum number"))
  :package-version '(tmr . "1.2.0")
  :group 'tmr)

(defcustom tmr-mode-line-max-desc-length 15
  "Maximum length for timer descriptions in the mode-line.
Longer descriptions will be truncated."
  :type '(choice (const :tag "Don't truncate" nil)
                 (integer :tag "Truncate"))
  :package-version '(tmr . "1.2.0")
  :group 'tmr)

(defcustom tmr-mode-line-prefix
  (cond
   ((char-displayable-p ?⏰) "⏰")
   ((char-displayable-p ?∞) "∞")
   (t "!"))
  "Prefix string displayed before the timer list."
  :type 'string
  :package-version '(tmr . "1.2.0")
  :group 'tmr)

;;;; Faces

(defgroup tmr-faces ()
  "Faces for `tmr'."
  :link '(info-link :tag "Info Manual" "(tmr)")
  :link '(url-link :tag "Homepage" "https://protesilaos.com/emacs/tmr")
  :link '(emacs-library-link :tag "Library Source" "tmr.el")
  :group 'tmr)

(defface tmr-duration nil
  "Face for styling the duration of a timer."
  :package-version '(tmr . "1.0.0")
  :group 'tmr-faces)

(defface tmr-repeat-count nil
  "Face for styling the repeat count of a timer."
  :package-version '(tmr . "1.4.0")
  :group 'tmr-faces)

(defface tmr-description '((t :inherit bold))
  "Face for styling the description of a timer."
  :package-version '(tmr . "1.0.0")
  :group 'tmr-faces)

(defface tmr-start-time '((t :inherit success))
  "Face for styling the start time of a timer."
  :package-version '(tmr . "1.0.0")
  :group 'tmr-faces)

(defface tmr-end-time '((t :inherit error))
  "Face for styling the start time of a timer."
  :package-version '(tmr . "1.0.0")
  :group 'tmr-faces)

(defface tmr-is-acknowledged '((t :inherit success))
  "Face for styling the acknowledgment confirmation."
  :package-version '(tmr . "1.0.0")
  :group 'tmr-faces)

(defface tmr-must-be-acknowledged '((t :inherit warning))
  "Face for styling the acknowledgment confirmation."
  :package-version '(tmr . "1.0.0")
  :group 'tmr-faces)

(defface tmr-finished '((t :inherit error))
  "Face for styling the description of a finished timer."
  :package-version '(tmr . "1.0.0")
  :group 'tmr-faces)

(defface tmr-paused '((t :inherit bold))
  "Face for styling the description of a paused timer."
  :package-version '(tmr . "1.3.0")
  :group 'tmr-faces)

(defface tmr-tabulated-start-time
  '((((class color) (min-colors 88) (background light))
     :foreground "#004476")
    (((class color) (min-colors 88) (background dark))
     :foreground "#c0d0ef")
    (t :foreground "cyan"))
  "Start time in the `tmr-tabulated-view'."
  :package-version '(tmr . "1.1.0")
  :group 'tmr-faces)

(defface tmr-tabulated-end-time
  '((((class color) (min-colors 88) (background light))
     :foreground "#800040")
    (((class color) (min-colors 88) (background dark))
     :foreground "#e59fc6")
    (t :foreground "magenta"))
  "End time in the `tmr-tabulated-view'."
  :package-version '(tmr . "1.1.0")
  :group 'tmr-faces)

(defface tmr-tabulated-remaining-time
  '((((class color) (min-colors 88) (background light))
     :foreground "#603f00")
    (((class color) (min-colors 88) (background dark))
     :foreground "#deba66")
    (t :foreground "yellow"))
  "Remaining time in the `tmr-tabulated-view'."
  :package-version '(tmr . "1.1.0")
  :group 'tmr-faces)

(defface tmr-tabulated-paused '((t :inherit bold))
  "Face for styling the description of a paused timer."
  :package-version '(tmr . "1.3.0")
  :group 'tmr-faces)

(defface tmr-tabulated-acknowledgement
  '((t :inherit bold))
  "Acknowledgement indicator in the `tmr-tabulated-view'."
  :package-version '(tmr . "1.1.0")
  :group 'tmr-faces)

(defface tmr-tabulated-description
  '((t :inherit font-lock-doc-face))
  "Description of timer in the `tmr-tabulated-view'."
  :package-version '(tmr . "1.1.0")
  :group 'tmr-faces)

(defface tmr-mode-line-active
  '((t :inherit mode-line-emphasis))
  "Face for active timers in the mode-line."
  :package-version '(tmr . "1.2.0")
  :group 'tmr-faces)

(defface tmr-mode-line-soon
  '((t :inherit warning))
  "Face for timers that will expire in the next 2 minutes."
  :package-version '(tmr . "1.2.0")
  :group 'tmr-faces)

(defface tmr-mode-line-urgent
  '((t :inherit error))
  "Face for timers that will expire in the next 30 seconds."
  :package-version '(tmr . "1.2.0")
  :group 'tmr-faces)

;;;; Common helpers

(cl-defstruct (tmr--timer
               (:constructor tmr--timer-create)
               (:copier tmr--timer-copy))
  (creation-date
   nil
   :read-only t
   :documentation "Time at which the timer was created.")
  (end-date
   nil
   :documentation "Time at which the timer finishes.")
  (finishedp
   nil
   :read-only nil
   :documentation "Non-nil if the timer is finished.")
  (acknowledgep
   nil
   :read-only nil
   :documentation "Non-nil if the timer must be acknowledged.")
  (repeat-count
   0
   :read-only nil
   :documentation "Remaining repetitions.")
  (original-repeat-count
   nil
   ;; NOTE 2026-07-31: My initial plan was to have a read-only entry
   ;; here, but with `tmr-edit-repeat-count' it makes sense to make
   ;; this writable.  Now the name is probably wrong, but I will leave
   ;; it for now, as there may be a better approach
   :read-only nil
   :documentation "The original repeat count with which the timer was created, or nil.")
  (duration
   nil
   :read-only t
   :documentation "Duration of this timer.")
  (timer-object
   nil
   :read-only nil
   :documentation "The object returned by `run-with-timer'.")
  (input
   nil
   :read-only t
   :documentation "The original input which is internally interpreted as a duration.")
  (description
   nil
   :read-only nil
   :documentation "Optional string describing the purpose of the timer, e.g., \"Stop the oven\".")
  (paused-remaining
   nil
   :read-only nil
   :documentation "Remaining seconds when the timer was paused or nil if not paused."))

(defun tmr--long-description-repeat (timer)
  "Format the repeat count for TIMER long description."
  (if (< 0 (tmr--timer-repeat-count timer))
      (propertize (format "repeat %d; " (tmr--timer-repeat-count timer)) 'face 'tmr-repeat-count)
    ""))

(defun tmr--long-description-duration (timer)
  "Format the duration/until for TIMER long description."
  (let ((input (tmr--timer-input timer)))
    (format "%s %s"
            (if (string-search ":" input)
                "until"
              "duration")
            (propertize input 'face 'tmr-duration))))

(defun tmr--long-description-status (timer)
  "Format the status for TIMER long description."
  (cond
   ((and (tmr--timer-acknowledgep timer)
         (tmr--timer-finishedp timer))
    (concat "; " (propertize "acknowledged" 'face 'tmr-is-acknowledged)))
   ((tmr--timer-acknowledgep timer)
    (concat "; " (propertize "acknowledge YES" 'face 'tmr-must-be-acknowledged)))
   ((tmr--timer-finishedp timer)
    (concat "; " (propertize "finished" 'face 'tmr-finished)))
   ((when-let* ((remaining (tmr--timer-paused-remaining timer)))
      (format "; %s: remaining %s" (propertize "PAUSED" 'face 'tmr-paused) (tmr--format-seconds remaining))))
   (t "")))

(defun tmr--long-description-description (timer)
  "Format the description for TIMER long description."
  (if-let* ((description (tmr--timer-description timer))
            (_ (not (string-blank-p description))))
      (concat "; " (propertize description 'face 'tmr-description))
    ""))

(defun tmr--long-description (timer)
  "Return a human-readable description for TIMER."
  (let ((start (tmr--format-creation-date timer))
        (end (tmr--format-end-date timer)))
    ;; We prefix it with TMR just so it is easier to find in
    ;; `view-echo-area-messages'.  The concise wording makes it flexible
    ;; enough to be used when starting a timer but also when cancelling
    ;; one: check `tmr-print-message-for-created-timer' and
    ;; `tmr-print-message-for-cancelled-timer'.
    (format "TMR start %s; end %s; %s%s%s%s"
            (propertize start 'face 'tmr-start-time)
            (propertize end 'face 'tmr-end-time)
            (tmr--long-description-repeat timer)
            (tmr--long-description-duration timer)
            (tmr--long-description-status timer)
            (tmr--long-description-description timer))))

(defun tmr--long-description-for-finished-timer (timer)
  "Return a human-readable description of finished TIMER.
This includes the creation and completion dates as well as the
optional `tmr--timer-description'."
  (let ((start (tmr--format-creation-date timer))
        (end (tmr--format-end-date timer))
        (description (tmr--timer-description timer)))
    ;; For the TMR prefix, see comment in `tmr--long-description'.
    (format "TMR Time is up!\n%s%s %s\n%s %s"
            (if description (concat (propertize description 'face 'tmr-description) "\n") "")
            (propertize "Started" 'face 'tmr-start-time)
            start
            (propertize "Ended" 'face 'tmr-end-time)
            end)))

(defun tmr--long-description-for-repeated-timer (timer)
  "Return a human-readable description of repeating TIMER.
This includes the creation and completion dates as well as the
optional `tmr--timer-description'."
  (let ((start (tmr--format-creation-date timer))
        (end (tmr--format-end-date timer))
        (description (tmr--timer-description timer)))
    ;; For the TMR prefix, see comment in `tmr--long-description'.
    (format "TMR Time is up! %d repetitions remain.\n%s%s %s\n%s %s"
            (tmr--timer-repeat-count timer)
            (if (and description (not (string-blank-p description)))
                (concat (propertize description 'face 'tmr-description) "\n")
              "")
            (propertize "Started" 'face 'tmr-start-time)
            start
            (propertize "Ends" 'face 'tmr-end-time)
            end)))

(defun tmr--format-creation-date (timer)
  "Return a string representing when TIMER was created."
  (tmr--format-time (tmr--timer-creation-date timer)))

(defun tmr--format-end-date (timer)
  "Return a string representing when TIMER should finish."
  (tmr--format-time (tmr--timer-end-date timer)))

(defun tmr--format-seconds (seconds)
  "Format the SECONDS in a human-readable way."
  (cond
   ((>= seconds 3600) (format "%sh %sm" (/ seconds 3600) (/ (% seconds 3600) 60)))
   ((>= seconds 60) (format "%sm %ss" (/ seconds 60) (% seconds 60)))
   (t (format "%ss" seconds))))

(defun tmr--get-seconds (timer)
  "Get the TIMER in seconds."
  (round (- (float-time (tmr--timer-end-date timer)) (float-time))))

(defun tmr--format-remaining (timer)
  "Format remaining time of TIMER."
  (cond
   ((tmr--timer-finishedp timer)
    tmr-finished-indicator)
   ((when-let* ((remaining (tmr--timer-paused-remaining timer)))
      (tmr--format-seconds remaining)))
   (t
    (let* ((seconds (tmr--get-seconds timer))
           (str (tmr--format-seconds seconds)))
      (if (< seconds 0)
          ;; Negative remaining time occurs for non-acknowledged timers with
          ;; additional duration.
          (propertize str 'face 'tmr-must-be-acknowledged)
        str)))))

(defun tmr--format-duration (timer)
  "Format duration of TIMER."
  (let ((input (tmr--timer-input timer)))
    (cond
     ((string-match-p ":" input)
      (tmr--format-seconds (tmr--get-seconds timer)))
     ((string-match-p "\\`[0-9]+\\(?:\\.[0-9]+\\)?\\'" input)
      (concat input "m"))
     (t
      input))))

(defun tmr--format-time (time)
  "Return a human-readable string representing TIME."
  (format-time-string "%T" time))

(defun tmr--parse-duration (now time)
  "Parse TIME string given current time NOW."
  (save-match-data
    (cond
     ((string-match-p "\\`[0-9]+\\(?:\\.[0-9]+\\)?\\'" time)
      (* (string-to-number time) 60))
     ((string-match "\\`\\([0-9]+\\):\\([0-9]+\\)\\(?::\\([0-9]+\\)\\)?\\'" time)
      (let ((val (decode-time now)))
        (setf (decoded-time-hour val) (string-to-number (match-string 1 time))
              (decoded-time-minute val) (string-to-number (match-string 2 time))
              (decoded-time-second val) (if (match-end 3)
                                            (string-to-number (match-string 3 time))
                                          0)
              val (encode-time val))
        (when (time-less-p val now)
          (user-error "Time %s is already over" time))
        (ceiling (float-time (time-subtract val now)))))
     ((string-match "\\`\\([0-9]+\\(?:\\.[0-9]+\\)?\\)[mhs]\\'" time)
      (let ((num (string-to-number (match-string 1 time))))
        (pcase (aref time (1- (length time)))
          (?s num)
          (?h (* num 60 60))
          (?m (* num 60)))))
     (t (user-error "TMR Made Ridiculous; append character for [m]inutes, [h]ours, [s]econds")))))

(defvar tmr--timers nil
  "List of timer objects.")

(defvar tmr--update-hook nil
  "Hooks to execute when timers are changed.")

;;;; Timer prompt

(defvar tmr--read-timer-hook nil
  "Hooks to execute to find current timer.")

(defun tmr-completion-annotate (timer)
  "Annotate TIMER completion candidate with remaining time."
  (setq timer (tmr--string-to-object timer))
  (if (tmr--timer-finishedp timer)
      " (finished)"
    (format " (%s remaining)" (tmr--format-remaining timer))))

(defun tmr--timer-object-to-string (timer)
  "Return TIMER as a propertized long description of it.
Include the TIMER as the value of property `tmr-timer-object' of the
resulting string.

Also see `tmr--string-to-object'."
  (propertize (tmr--long-description timer) 'tmr-timer-object timer))

(defun tmr--string-to-object (string)
  "Get from STRING the `tmr-timer-object' property value.
Also see `tmr--timer-object-to-string'."
  (get-text-property 0 'tmr-timer-object string))

(defun tmr-completion-sort (strings)
  "Display sort function for STRINGS completion candidates.
STRINGS are the string representation of TMR timer objects."
  (let* ((timer-objects (mapcar #'tmr--string-to-object strings))
         (timer-objects-sorted
          (sort timer-objects (lambda (timer1 timer2)
                                (time-less-p
                                 (tmr--timer-end-date timer1)
                                 (tmr--timer-end-date timer2))))))
    (mapcar #'tmr--timer-object-to-string timer-objects-sorted)))

;; NOTE 2026-07-30: I am defining it like this because someone might
;; still want to use the `identity' for sorting.  It otherwise is the
;; same as what we had before.
(defvar tmr-completion-metadata
  (list
   (cons 'category 'tmr-timer)
   (cons 'display-sort-function #'tmr-completion-sort)
   (cons 'annotation-function #'tmr-completion-annotate))
  "The `completion-metadata' for TMR prompts that select a timer.")

(define-obsolete-function-alias 'tmr--read-timer #'tmr-read-timer "1.4.0")

(defun tmr-read-timer (prompt &optional active)
  "Let the user choose a timer among all (or ACTIVE) timers.

Return the selected timer.  If there is a single timer and
`tmr-confirm-single-timer' is nil, use that.  If there are
multiple timers, prompt for one with completion with PROMPT text.
If there are no timers, throw an error."
  (or (run-hook-with-args-until-success 'tmr--read-timer-hook)
      (if-let* ((timers (if active
                            (seq-remove #'tmr--timer-finishedp tmr--timers)
                          tmr--timers))
                (strings (mapcar #'tmr--timer-object-to-string timers))
                (table (apply #'tmr-get-completion-table strings tmr-completion-metadata))
                (choice (completing-read prompt table nil t))
                (selected (car (member choice strings))))
          (or (and selected (tmr--string-to-object selected))
              (user-error "No timer selected"))
        (user-error "No timers available"))))

;;;; Commands

(defun tmr-remove (timer)
  "Cancel and remove TIMER object set with `tmr' command.
Interactively, let the user choose which timer to cancel with
completion."
  (interactive (list (tmr-read-timer "Remove timer: ")))
  (cancel-timer (tmr--timer-timer-object timer))
  (setq tmr--timers (delete timer tmr--timers))
  (run-hooks 'tmr--update-hook)
  (run-hook-with-args 'tmr-timer-cancelled-functions timer))

(defun tmr-cancel (timer)
  "Cancel TIMER object set with `tmr' command.
Interactively, let the user choose which timer to cancel with
completion.  This command is the same as `tmr-remove' but
chooses only among active timers."
  (interactive (list (tmr-read-timer "Cancel timer: " :active)))
  (tmr-remove timer))

(defun tmr-reschedule (timer)
  "Reschedule TIMER.
This is the same as cloning it, prompting for duration and
cancelling the original one."
  (interactive (list (tmr-read-timer "Reschedule timer: ")))
  (tmr-clone timer :prompt)
  (let (tmr-timer-cancelled-functions)
    (tmr-cancel timer)))

(defun tmr-edit-description (timer description)
  "Change TIMER description with that of DESCRIPTION."
  (interactive
   (list
    (tmr-read-timer "Edit description of timer: ")
    (tmr--description-prompt)))
  (setf (tmr--timer-description timer) description)
  (run-hooks 'tmr--update-hook))

(defun tmr-edit-repeat-count (timer repeat-count)
  "Change TIMER repeat count to that specified by REPEAT-COUNT."
  (interactive
   (list
    (tmr-read-timer "Edit repeat count of timer: ")
    (tmr-repeat-prompt)))
  (let ((current (tmr--timer-repeat-count timer))
        (original (tmr--timer-original-repeat-count timer)))
    (setf (tmr--timer-repeat-count timer) repeat-count)
    (cond
     ((<= repeat-count 0)
      (setf (tmr--timer-original-repeat-count timer) nil))
     (original
      (setf (tmr--timer-original-repeat-count timer)
            (max repeat-count (+ original (- repeat-count current)))))
     (t
      (setf (tmr--timer-original-repeat-count timer) repeat-count))))
  (run-hooks 'tmr--update-hook))

(defun tmr-toggle-pause (timer)
  "Toggle pause/resume state of TIMER."
  (interactive (list (tmr-read-timer "Pause/resume timer: " :active)))
  (if-let* ((remaining (tmr--timer-paused-remaining timer)))
      (progn
        (setf (tmr--timer-end-date timer) (time-add (current-time) remaining))
        (setf (tmr--timer-timer-object timer) (run-with-timer remaining nil #'tmr--complete timer))
        (setf (tmr--timer-paused-remaining timer) nil)
        (run-hooks 'tmr--update-hook)
        (run-hook-with-args 'tmr-timer-resumed-functions timer))
    (let ((remaining (tmr--get-seconds timer)))
      (when (> remaining 0)
        (cancel-timer (tmr--timer-timer-object timer))
        (setf (tmr--timer-paused-remaining timer) remaining)
        (run-hooks 'tmr--update-hook)
        (run-hook-with-args 'tmr-timer-paused-functions timer)))))


(defun tmr-toggle-acknowledge (timer)
  "Toggle ackowledge flag of TIMER."
  (interactive
   (list
    (tmr-read-timer "Toggle acknowledge flag of timer: ")))
  (setf (tmr--timer-acknowledgep timer) (not (tmr--timer-acknowledgep timer)))
  (run-hooks 'tmr--update-hook))

(defun tmr-remove-finished ()
  "Remove all finished timers."
  (interactive)
  (setq tmr--timers (seq-remove #'tmr--timer-finishedp tmr--timers))
  (run-hooks 'tmr--update-hook))

(declare-function notifications-notify "notifications" (&rest params))
(declare-function android-notifications-notify "androidselect.c" (&rest params))
(declare-function w32-notification-notify "w32fns.c" (&rest params))
(declare-function haiku-notifications-notify "haikuselect.c" (&rest params))
(defvar notifications-application-icon)

(defvar tmr--notification-notify-count 0
  "Count of how many times `tmr-notification-notify' issues a warning.")

(defun tmr-notification-notify (timer)
  "Dispatch a notification for TIMER.
Read Info node `(elisp) Desktop Notifications' for details."
  (cond
   ((or (featurep 'dbusbind)
        (seq-some #'fboundp
                  (list #'android-notifications-notify
                        #'w32-notification-notify
                        #'haiku-notifications-notify)))
    (let ((title "TMR May Ring (Emacs tmr package)")
          (body (tmr--long-description-for-finished-timer timer)))
      (setq tmr--notification-notify-count 0)
      (cond
       ((fboundp 'android-notifications-notify)
        (android-notifications-notify
         :title title
         :body body
         :urgency tmr-notification-urgency))
       ((fboundp 'w32-notification-notify)
        (w32-notification-notify
         :title title
         :body body))
       ((fboundp 'haiku-notifications-notify)
        (haiku-notifications-notify
         :title title
         :body body
         :app-icon 'emacs
         :urgency tmr-notification-urgency))
       (t
        (unless (fboundp 'notifications-notify)
          (require 'notifications))
        (notifications-notify
         :title title
         :body body
         :app-name "GNU Emacs"
         :app-icon notifications-application-icon
         :urgency tmr-notification-urgency
         :sound-file tmr-sound-file)))))
   ((> tmr--notification-notify-count 0)
    (setq tmr--notification-notify-count (+ tmr--notification-notify-count 1))
    (display-warning 'tmr "No DBUS; disable this with: (remove-hook 'tmr-timer-finished-functions #'tmr-notification-notify)"))
   (t
    (setq tmr--notification-notify-count (+ tmr--notification-notify-count 1))
    (display-warning 'tmr "Emacs has no DBUS support, TMR notifications unavailable"))))

;; NOTE 2022-04-21: Emacs has a `play-sound' function but it only
;; supports .wav and .au formats.  Also, it does not work on all
;; platforms and Emacs needs to be compiled --with-sound capabilities.
(defun tmr-sound-play (&rest _)
  "Play `tmr-sound-file' using the ffplay executable (ffmpeg)."
  (if (executable-find "ffplay")
      (when-let* ((sound (and tmr-sound-file (expand-file-name tmr-sound-file)))
                  (_ (file-exists-p sound)))
        (call-process "ffplay" nil 0 nil "-nodisp" "-autoexit" sound))
    (display-warning 'tmr "`ffplay' is not available to play back `tmr-sound-file'")))

(defun tmr-print-message-for-created-timer (timer)
  "Show a `message' informing the user that TIMER was created."
  (message "%s" (tmr--long-description timer)))

(defun tmr-print-message-for-finished-timer (timer)
  "Show a `message' informing the user that TIMER has finished."
  (if (< 0 (tmr--timer-repeat-count timer))
      (message "%s" (tmr--long-description-for-repeated-timer timer))
    (message "%s" (tmr--long-description-for-finished-timer timer))))

(defun tmr-print-message-for-cancelled-timer (timer)
  "Show a `message' informing the user that TIMER is cancelled."
  (message "Cancelled: <<%s>>" (tmr--long-description timer)))

(defun tmr-print-message-for-paused-timer (timer)
  "Show a `message' informing the user that TIMER is paused."
  (message "Paused: <<%s>> (REMAINING %s)" (tmr--long-description timer) (tmr--format-remaining timer)))

(defun tmr-print-message-for-resumed-timer (timer)
  "Show a `message' informing the user that TIMER is resumed."
  (message "Resumed: <<%s>> (REMAINING %s)" (tmr--long-description timer) (tmr--format-remaining timer)))

(defvar tmr-duration-history nil
  "Minibuffer history of `tmr' durations.")

(defun tmr--read-duration (&optional default)
  "Ask the user to type a duration.
If DEFAULT is provided, use that as a default."
  (let ((def (or default (car tmr-duration-history))))
    (read-string
     (format-prompt
      "N minutes for timer (append `h' or `s' for other units)" def)
     nil 'tmr-duration-history def)))

(defvar tmr-description-history nil
  "Minibuffer history of `tmr' descriptions.")

(defun tmr--description-prompt (&optional default)
  "Helper prompt for descriptions in `tmr'.
If optional DEFAULT is provided use it as a default candidate."
  (completing-read
   (format-prompt "Description for this tmr" default)
   (if (listp tmr-description-list)
       tmr-description-list
     (symbol-value tmr-description-list))
   nil nil nil 'tmr-description-history default))

(defvar tmr-repeat-prompt-history nil
  "Minibuffer history for `tmr-repeat-prompt'.")

(defun tmr-repeat-prompt ()
  "Prompt for a repeat count."
  (let ((default (car tmr-repeat-prompt-history)))
    (read-number
     "Repeat N times: "
     (when default
       (string-to-number default))
     'tmr-repeat-prompt-history)))

(defun tmr--acknowledge-prompt ()
  "Ask the user if a timer must be acknowledged."
  (y-or-n-p "Acknowledge timer after finish? "))

(defun tmr-acknowledge-dialog (timer)
  "Acknowledge TIMER by showing a GUI dialog."
  (when-let* (((tmr--timer-acknowledgep timer))
              (duration
               (x-popup-dialog
                t
                `(,(tmr--long-description-for-finished-timer timer)
                  ("Acknowledge" . nil)
                  ("+ 1 m" . 60)
                  ("+ 5 m" . 300)
                  ("+ 10 min" . 600)
                  ("+ 15 min" . 900)
                  nil))))
    (tmr--continue-overtime timer duration)))

(defun tmr-acknowledge-minibuffer (timer)
  "Acknowledge TIMER using the minibuffer."
  (when (tmr--timer-acknowledgep timer)
    (while
        (let ((input
               (read-from-minibuffer
                (format "%s\nAcknowledge with `%s' or additional duration: "
                        (tmr--long-description-for-finished-timer timer)
                        tmr-acknowledge-timer-text))))
          (not (or (equal input tmr-acknowledge-timer-text)
                   (when-let* ((duration
                                (ignore-errors
                                  (tmr--parse-duration (current-time) input))))
                     (tmr--continue-overtime timer duration)
                     t)))))))

(defun tmr--continue-overtime (timer duration)
  "Continue TIMER even after it expired for DURATION.
This function is used if a timer is not acknowledged."
  (setf (tmr--timer-finishedp timer) nil
        (tmr--timer-timer-object timer)
        (run-with-timer duration nil #'tmr--complete timer))
  (run-hooks 'tmr--update-hook)
  (run-hook-with-args 'tmr-timer-created-functions timer))

(defun tmr-print-message-for-repeating-timer (timer)
  "Print message for how many times TIMER repeats."
  (when-let* ((count (tmr--timer-repeat-count timer)))
    (cond
     ((= count 1)
      (if-let* ((description (tmr--timer-description timer))
                (_ (not (string-blank-p description))))
	      (message "TMR with duration `%s' and description `%s' repeats another time"
                   (propertize (tmr--format-duration timer) 'face 'tmr-duration)
                   (propertize description 'face 'tmr-description))
	    (message "TMR with duration `%s' repeats another time"
                 (propertize (tmr--format-duration timer) 'face 'tmr-duration))))
	 ((= count 0)
      (if-let* ((description (tmr--timer-description timer))
                (_ (not (string-blank-p description))))
	      (message "TMR with duration `%s' and description `%s' will not repeat again"
                   (propertize (tmr--format-duration timer) 'face 'tmr-duration)
                   (propertize description 'face 'tmr-description))
	    (message "TMR with duration `%s' will not repeat again"
                 (propertize (tmr--format-duration timer) 'face 'tmr-duration))))
	 (t
      (if-let* ((description (tmr--timer-description timer))
                (_ (not (string-blank-p description))))
          (message "TMR with duration `%s' and description `%s' repeats another `%s' times"
                   (propertize (tmr--format-duration timer) 'face 'tmr-duration)
                   (propertize description 'face 'tmr-description)
                   (propertize (number-to-string count) 'face 'tmr-repeat-count))
	    (message "TMR with duration `%s' repeats another `%s' times"
                 (propertize (tmr--format-duration timer) 'face 'tmr-duration)
                 (propertize (number-to-string count) 'face 'tmr-repeat-count)))))))

(defun tmr--complete (timer)
  "Mark TIMER as finished or repeat it, if appropriate, and execute hooks."
  (if (>= 0 (tmr--timer-repeat-count timer))
      (progn
        (setf (tmr--timer-finishedp timer) t)
        (run-hooks 'tmr--update-hook)
        (run-hook-with-args 'tmr-timer-finished-functions timer))
    (setf (tmr--timer-repeat-count timer) (1- (tmr--timer-repeat-count timer)))
    (setf (tmr--timer-end-date timer) (time-add (tmr--timer-end-date timer)
                                                (tmr--timer-duration timer)))
    (setf (tmr--timer-timer-object timer)
          (run-with-timer (tmr--timer-duration timer) nil #'tmr--complete timer))
    (run-hooks 'tmr--update-hook)
    (run-hook-with-args 'tmr-timer-repeat-functions timer)))

(defun tmr--subr (time description acknowledgep repeat-count)
  "Do the work of `tmr' and `tmr-repeat'.
TIME, DESCRIPTION, ACKNOWLEDGEP, REPEAT-COUNT have the meaning of those
functions."
  (when (natnump time)
    (setq time (number-to-string time)))
  (let* ((creation-date (current-time))
         (duration (tmr--parse-duration creation-date time))
         (timer (tmr--timer-create
                 :description description
                 :acknowledgep acknowledgep
                 :repeat-count (or repeat-count 0)
                 :original-repeat-count repeat-count
                 :duration duration
                 :creation-date creation-date
                 :end-date (time-add creation-date duration)
                 :input time)))
    (setf (tmr--timer-timer-object timer)
          (run-with-timer duration nil #'tmr--complete timer))
    (push timer tmr--timers)
    (run-hooks 'tmr--update-hook)
    (run-hook-with-args 'tmr-timer-created-functions timer)))

;;;###autoload
(defun tmr (time &optional description acknowledgep)
  "Set timer to TIME duration and notify after it elapses.

When TIME is a number, it is interpreted as a count of minutes.
Otherwise TIME must be a string that consists of a number and a
special final character denoting a unit of time: h for hours, s
for seconds.

With optional DESCRIPTION as a prefix (\\[universal-argument]),
prompt for a description among `tmr-description-list', though
allow for any string to serve as valid input.

With optional ACKNOWLEDGEP non-nil the timer must be acknowledged
after it finished, such that the timer cannot be missed.

This command also plays back `tmr-sound-file' if it is available.

To cancel the timer, use the `tmr-cancel' command.

To always prompt for a DESCRIPTION when setting a timer, use the
command `tmr-with-details' instead of this one.

Also see `tmr-repeat'."
  (interactive
   (list
    (tmr--read-duration)
    (when current-prefix-arg (tmr--description-prompt))
    (when current-prefix-arg (tmr--acknowledge-prompt))))
  (tmr--subr time description acknowledgep nil))

;;;###autoload
(defun tmr-with-details (time &optional description acknowledgep)
  "Set timer to TIME duration and notify after it elapses.

See `tmr' for a description of the arguments DESCRIPTION and
ACKNOWLEDGEP.  The difference between the two commands is that
`tmr-with-details' always asks for a description and if the timer
should be acknowledged whereas `tmr' only asks for it when the
user uses a prefix argument (\\[universal-argument]).

Also see `tmr-repeat'."
  (interactive
   (list
    (tmr--read-duration)
    (tmr--description-prompt)
    (tmr--acknowledge-prompt)))
  (tmr--subr time description acknowledgep nil))

;;;###autoload
(defun tmr-repeat (time repeat-count &optional description acknowledgep)
  "Set timer to TIME duration and repeat it REPEAT-COUNT times.

REPEAT-COUNT is an integer indicating how many times the timer shall be
repeated.  In interactive use, prompt for REPEAT-COUNT.

See `tmr' for a description of the arguments DESCRIPTION and
ACKNOWLEDGEP.  Here ACKNOWLEDGEP takes effect after all REPEAT-COUNT are
done.

Also see `tmr-with-details'."
  (interactive
   (list
    (tmr--read-duration)
    (tmr-repeat-prompt)
    (when current-prefix-arg (tmr--description-prompt))
    (when current-prefix-arg (tmr--acknowledge-prompt))))
  (tmr--subr time description acknowledgep repeat-count))

(defun tmr-clone (timer &optional prompt)
  "Create a new timer by cloning TIMER.
With optional PROMPT, such as a prefix argument, ask for
confirmation about the duration.  When PROMPT is a double prefix
argument, ask for a description as well and ask if the timer must
be acknowledged.

Without a PROMPT, clone TIMER outright."
  (interactive
   (list
    (tmr-read-timer "Clone timer: ")
    current-prefix-arg))
  (tmr
   (if prompt
       (tmr--read-duration (format "%s" (tmr--timer-input timer)))
     (format "%s" (tmr--timer-input timer)))
   (if (equal prompt '(16))
       (tmr--description-prompt (tmr--timer-description timer))
     (tmr--timer-description timer))
   (if (equal prompt '(16))
       (tmr--acknowledge-prompt)
     (tmr--timer-acknowledgep timer))))

(defun tmr-get-completion-table (candidates &rest metadata)
  "Return completion table with CANDIDATES and METADATA.
CANDIDATES is a list of strings.  METADATA is described in
`completion-metadata'."
  (lambda (string pred action)
    (if (eq action 'metadata)
        (cons 'metadata metadata)
      (complete-with-action action candidates string pred))))

;;;; Key bindings

(defvar-keymap tmr-prefix-map
  :doc "Global prefix map for TMRs.
This map should be bound to a global prefix key."
  "+" #'tmr
  "*" #'tmr-with-details
  "@" #'tmr-repeat
  "t" #'tmr
  "T" #'tmr-with-details
  "n" #'tmr-repeat
  "c" #'tmr-clone
  "s" #'tmr-reschedule
  "P" #'tmr-toggle-pause
  "a" #'tmr-toggle-acknowledge
  "e" #'tmr-edit-description
  "E" #'tmr-edit-repeat-count
  "r" #'tmr-remove
  "R" #'tmr-remove-finished
  "k" #'tmr-cancel
  "l" #'tmr-tabulated-view)

;;;###autoload (autoload 'tmr-prefix-map "tmr" nil t 'keymap)
(defalias 'tmr-prefix-map tmr-prefix-map)

(defvar-keymap tmr-tabulated-mode-map
  :doc "Keybindings for `tmr-tabulated-mode'."
  "+" #'tmr
  "*" #'tmr-with-details
  "@" #'tmr-repeat
  "t" #'tmr
  "T" #'tmr-with-details
  "c" #'tmr-clone
  "s" #'tmr-reschedule
  "P" #'tmr-toggle-pause
  "a" #'tmr-toggle-acknowledge
  "e" #'tmr-edit-description
  "E" #'tmr-edit-repeat-count
  "R" #'tmr-remove-finished
  "r" #'tmr-remove
  "k" #'tmr-remove)

;;;;; Integration with the `embark' package

(defvar-keymap tmr-action-map
  :doc "Action map for TMRs, which can be utilized by Embark."
  "k" #'tmr-remove
  "r" #'tmr-remove
  "R" #'tmr-remove-finished
  "c" #'tmr-clone
  "a" #'tmr-toggle-acknowledge
  "e" #'tmr-edit-description
  "s" #'tmr-reschedule
  "p" #'tmr-toggle-pause)

(defvar embark-keymap-alist)
(defvar embark-post-action-hooks)
(with-eval-after-load 'embark
  (add-to-list 'embark-keymap-alist '(tmr-timer . tmr-action-map))
  (cl-loop
   for cmd the key-bindings of tmr-action-map
   if (commandp cmd) do
   (add-to-list 'embark-post-action-hooks (list cmd 'embark--restart))))

;;;; Tabulated view

;;;###autoload
(defun tmr-tabulated-view (buffer action-alist)
  "Open a tabulated list buffer listing tmr timers.
BUFFER is the buffer to use and ACTION-ALIST is what `display-buffer'
uses.  Those parameters are meant for use in Lisp.  In interactive use,
they are set to reasonable default values."
  (interactive
   (list
    (get-buffer-create "*tmr-tabulated-view*")
    tmr-list-timers-action-alist))
  (with-current-buffer buffer
    (tmr-tabulated-mode))
  (display-buffer buffer action-alist))

(defalias 'tmr-list-timers 'tmr-tabulated-view
  "Alias for `tmr-tabulated-view' command.")

(defun tmr-tabulated--set-entries ()
  "Set the value of `tabulated-list-entries' with timers."
  (setq-local tabulated-list-entries
              (mapcar #'tmr-tabulated--timer-to-entry tmr--timers)))

(defun tmr-tabulated--get-columns ()
  "Return `tmr-tabulated-columns' without duplicates."
  (seq-uniq tmr-tabulated-columns))

(defun tmr-tabulated--timer-to-entry (timer)
  "Convert TIMER into an entry suitable for `tabulated-list-entries'."
  (list
   timer
   (vconcat
    (mapcar
     (lambda (column)
       (pcase column
         ('start (propertize (tmr--format-creation-date timer) 'face 'tmr-tabulated-start-time))
         ('end (propertize (tmr--format-end-date timer) 'face 'tmr-tabulated-end-time))
         ('duration (propertize (tmr--format-duration timer) 'face 'tmr-duration))
         ('remaining (propertize (tmr--format-remaining timer) 'face 'tmr-tabulated-remaining-time))
         ('paused (propertize (if (tmr--timer-paused-remaining timer) "Yes" "") 'face 'tmr-tabulated-paused))
         ('repeatable (propertize (if (tmr--timer-original-repeat-count timer) "Yes" "") 'face 'tmr-repeat-count))
         ('remaining-repeats (propertize (if (tmr--timer-original-repeat-count timer) (number-to-string (tmr--timer-repeat-count timer)) "") 'face 'tmr-repeat-count))
         ('total-repeats (propertize (if (tmr--timer-original-repeat-count timer) (number-to-string (tmr--timer-original-repeat-count timer)) "") 'face 'tmr-repeat-count))
         ('acknowledge (propertize (if (tmr--timer-acknowledgep timer) "Yes" "") 'face 'tmr-tabulated-acknowledgement))
         ('description (propertize (or (tmr--timer-description timer) "") 'face 'tmr-tabulated-description))))
     (tmr-tabulated--get-columns)))))

(defvar-local tmr-tabulated--refresh-timer nil
  "Timer used to refresh tabulated view.")

(defun tmr-tabulated--window-hook ()
  "Setup timer to refresh tabulated view."
  (if (get-buffer-window)
      (unless tmr-tabulated--refresh-timer
        (let* ((timer nil)
               (buf (current-buffer))
               (refresh
                (lambda ()
                  (if (buffer-live-p buf)
                      (with-current-buffer buf
                        (if-let* ((win (get-buffer-window)))
                            (with-selected-window win
                              (let ((end (eobp)))
                                ;; Optimized refreshing
                                (dolist (entry tabulated-list-entries)
                                  (setf (aref (cadr entry) 3)
                                        (propertize (tmr--format-remaining (car entry)) 'face 'tmr-tabulated-remaining-time)))
                                (tabulated-list-print t)
                                (when end (goto-char (point-max))))
                              ;; HACK: For some reason the hl-line highlighting gets lost here
                              (when (and (bound-and-true-p hl-line-mode)
                                         (fboundp 'hl-line-highlight))
                                (hl-line-highlight)))
                          (cancel-timer timer)
                          (setq tmr-tabulated--refresh-timer nil)))
                    (cancel-timer timer)))))
          (when (natnump tmr-tabulated-refresh-interval)
            (setq timer (run-at-time nil tmr-tabulated-refresh-interval refresh)
                  tmr-tabulated--refresh-timer timer))))
    (when tmr-tabulated--refresh-timer
      (cancel-timer tmr-tabulated--refresh-timer)
      (setq tmr-tabulated--refresh-timer nil))))

(defun tmr-tabulated--compare-remaining (a b)
  "Compare remaining time of timers A and B."
  (time-less-p (tmr--timer-end-date (car a))
               (tmr--timer-end-date (car b))))

(define-derived-mode tmr-tabulated-mode tabulated-list-mode "TMR"
  "Major mode to display tmr timers."
  (setq-local tabulated-list-format
              (vconcat
               (mapcar
                (lambda (column)
                  (pcase column
                    ('start '("Start" 10 t))
                    ('end '("End" 10 t))
                    ('duration '("Duration" 10 t))
                    ('remaining '("Remaining" 10 tmr-tabulated--compare-remaining))
                    ('paused '("Paused?" 8 t))
                    ('repeatable '("Repeatable" 12 t))
                    ('remaining-repeats '("Remaining Repeats" 18 t))
                    ('total-repeats '("Total repeats" 15 t))
                    ('acknowledge '("Acknowledge?" 14 t))
                    ('description '("Description" 0 t))))
                (tmr-tabulated--get-columns))))
  (add-hook 'window-configuration-change-hook #'tmr-tabulated--window-hook nil t)
  (add-hook 'tabulated-list-revert-hook #'tmr-tabulated--set-entries nil t)
  (tmr-tabulated--set-entries)
  (tabulated-list-init-header)
  (tabulated-list-print))

(defun tmr-tabulated--timer-at-point ()
  "Return the timer on the current line or nil."
  (and (eq major-mode #'tmr-tabulated-mode) (tabulated-list-get-id)))

(defun tmr-tabulated--refresh ()
  "Refresh *tmr-tabulated-view* buffer if it exists."
  (when-let* ((buf (get-buffer "*tmr-tabulated-view*")))
    (with-current-buffer buf
      (let ((lines (line-number-at-pos)))
        (revert-buffer)
        (when (and (bobp) (> lines 1))
          (forward-line (1- lines))
          (unless (tabulated-list-get-id)
            (forward-line -1)))))))

(add-hook 'tmr--update-hook #'tmr-tabulated--refresh)
(add-hook 'tmr--read-timer-hook #'tmr-tabulated--timer-at-point)

;;;; Mode-line indicator

(defvar tmr-mode-line-string nil
  "TMR mode-line string.")
(put 'tmr-mode-line-string 'risky-local-variable t)

(defvar tmr-mode-line--update-timer nil
  "Timer to update the mode-line.")

(defun tmr-mode-line--format-remaining (timer)
  "Format remaining time for TIMER with appropriate face."
  (if-let* ((remaining (tmr--timer-paused-remaining timer)))
      (propertize (format "PAUSED %s" (tmr--format-seconds remaining)) 'face 'tmr-paused)
    (let* ((secs (float-time (time-subtract (tmr--timer-end-date timer) nil)))
           (face (cond ((and (< secs 5) (= (% (truncate secs) 2) 0))
                        '(tmr-mode-line-urgent (:inverse-video t)))
                       ((< secs 30) 'tmr-mode-line-urgent)
                       ((= (truncate secs) 30)
                        '(tmr-mode-line-urgent (:inverse-video t)))
                       ((= (truncate secs) 60)
                        '(tmr-mode-line-soon (:inverse-video t)))
                       ((< secs 120) 'tmr-mode-line-soon)
                       ((= (truncate secs) 120)
                        '(tmr-mode-line-soon (:inverse-video t)))
                       (t 'tmr-mode-line-active)))
           (formatted (format-seconds
                       (cond ((< secs 120) "%mm %ss%z")
                             ((< secs (* 24 60 60)) "%hh %mm%z")
                             (t "%dd %hh%z"))
                       secs)))
      (propertize formatted 'face face))))

(defun tmr-mode-line--format-description (timer)
  "Format description for TIMER, truncating if necessary."
  (if-let* ((description (tmr--timer-description timer))
            (_ (not (string-blank-p description))))
      (concat
       " "
       (if tmr-mode-line-max-desc-length
           (truncate-string-to-width
            description tmr-mode-line-max-desc-length
            nil nil t)
         description))
    ""))

(defun tmr-mode-line--format-timer (timer)
  "Format a single TIMER for display in the mode-line."
  (let ((map (make-sparse-keymap)))
    (define-key map [mode-line mouse-1]
                (lambda ()
                  (interactive)
                  (call-interactively 'tmr-tabulated-view)))
    (propertize
     (format-spec tmr-mode-line-format
                  `((?r . ,(tmr-mode-line--format-remaining timer))
                    (?d . ,(tmr-mode-line--format-description timer))))
     'mouse-face 'mode-line-highlight
     'help-echo (tmr--long-description timer)
     'local-map map)))

(defun tmr-mode-line--get-active-timers ()
  "Return a sorted list of active timers."
  (thread-last tmr--timers
               (seq-remove #'tmr--timer-finishedp)
               (seq-sort-by #'tmr--timer-end-date #'time-less-p)))

(defun tmr-mode-line--update ()
  "Update `tmr-mode-line-string' based on the current timer state."
  (setq
   tmr-mode-line-string
   (if-let* ((active-timers (tmr-mode-line--get-active-timers)))
       (let* ((truncate (and tmr-mode-line-max-timers
                             (length> active-timers tmr-mode-line-max-timers)))
              (timers-to-show (if truncate
                                  (seq-take active-timers
                                            tmr-mode-line-max-timers)
                                active-timers)))
         (concat
          " " tmr-mode-line-prefix " "
          (string-join (mapcar #'tmr-mode-line--format-timer timers-to-show)
                       tmr-mode-line-separator)
          (when truncate
            (format " +%d" (- (length active-timers) tmr-mode-line-max-timers)))
          " "))
     ""))
  (force-mode-line-update t))

;;;###autoload
(define-minor-mode tmr-mode-line-mode
  "Display TMR May Ring timers in the global mode line."
  :global t
  :group 'tmr-mode-line
  (if tmr-mode-line-mode
      (progn
        (unless global-mode-string (setq global-mode-string '("")))
        (unless (memq 'tmr-mode-line-string global-mode-string)
          (setq global-mode-string
                (append global-mode-string '(tmr-mode-line-string))))
        (setq tmr-mode-line--update-timer
              (run-at-time t 1 #'tmr-mode-line--update))
        (add-hook 'tmr--update-hook #'tmr-mode-line--update))
    (when tmr-mode-line--update-timer
      (cancel-timer tmr-mode-line--update-timer)
      (setq tmr-mode-line--update-timer nil))
    (setq tmr-mode-line-string nil)
    (remove-hook 'tmr--update-hook #'tmr-mode-line--update)))

;;;; Ask if there are timers before exiting Emacs

(defun tmr-running-timers-p ()
  "Return non-nil if there are running timers."
  (and tmr--timers (seq-remove #'tmr--timer-finishedp tmr--timers)))

(defun tmr-kill-emacs-query-function ()
  "Ask before exiting Emacs if there are any active TMR timers."
  (if (not (tmr-running-timers-p))
      t
    (tmr-tabulated-view
     (get-buffer-create "*tmr-tabulated-view*")
     '((display-buffer-reuse-mode-window display-buffer-at-bottom)
       (mode . tmr-tabulated-mode)
       (window-height . fit-window-to-buffer)
       (dedicated . t)
       (preserve-size . (t . t))))
    (yes-or-no-p "TMR has running timers; exit anyway? ")))

(add-hook 'kill-emacs-query-functions #'tmr-kill-emacs-query-function)

(provide 'tmr)
;;; tmr.el ends here
