;; elastic-tools  --- tools for variable-pitch fonts. -*- lexical-binding: t; -*-
;; Copyright (C) 2023 Jean-Philippe Bernardy

;; 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:

;;; This file provides tools for elastic-indent and elastic-table

(require 'dash)
(require 'cl-lib)

;;; Code:

(defgroup elastic nil "Customization of elastic spacing (helps alignment in presence of proportional fonts)."
  :group 'programming :group 'convenience)

(defvar-local elastic-tools-handlers nil
  "Alist mapping handler function symbols to a depth.")

(defvar-local elastic-tools-deleted-newline nil
  "Have we just deleted a newline character?")

(defun elastic-tools-add-handler (handler depth)
  "Register HANDLER at given DEPTH.
Lower DEPTH means executed first."
  (setf (alist-get handler elastic-tools-handlers) depth)
  (setq elastic-tools-handlers (-sort (-on #'< #'cdr) elastic-tools-handlers))
    (add-hook 'text-scale-mode-hook #'elastic-tools-queue-buffer t)
    (add-hook 'post-command-hook #'elastic-tools-handle-queue nil t)
    (add-hook 'before-change-functions #'elastic-tools-before-change-function nil t)
    (add-hook 'after-change-functions #'elastic-tools-after-change-function nil t)
    ;; Queue handling the buffer so it's taken care of by the new
    ;; handler.  Note that when activating the modes (e.g. upon buffer
    ;; creation, with this method, the buffer will be handled just
    ;; once, and zero times if the buffer is not active at the end of
    ;; the command. For instance, org-babel native fontification will
    ;; create a temporary buffer that is never active, and therefore
    ;; will not be handled. This is a good thing, because org-babel
    ;; creates such a buffer at each keystroke, and handling it all
    ;; every time is very slow.
    (elastic-tools-queue-buffer))

(defun elastic-tools-remove-handler (handler)
  "Unregister HANDLER."
  (setq elastic-tools-handlers (assq-delete-all handler elastic-tools-handlers))
  (unless elastic-tools-handlers
    (remove-hook 'text-scale-mode-hook #'elastic-tools-queue-buffer)
    (remove-hook 'post-command-hook #'elastic-tools-handle-queue)
    (remove-hook 'before-change-functions #'elastic-tools-before-change-function t)
    (remove-hook 'after-change-functions #'elastic-tools-after-change-function t)))

(defmacro elastic-tools-with-suitable-window (&rest body)
  "Execute BODY in a context where current buffer as a window."
  (declare (indent 0))
  (let ((temp-frame-symb (make-symbol "temp-frame"))
        (window-symb (make-symbol "window")))
    `(let* ((,window-symb (get-buffer-window nil t))
            (,temp-frame-symb
             (if ,window-symb
                 nil
               ;; the new frame will display the current buffer by default
               (make-frame '((visibility . nil))))))
       (unwind-protect
           ;; using ‘with-selected-window’ is probably inefficient for
           ;; our purposes, could rewrite to say explicitly exactly
           ;; what really needs restoration
           (with-selected-window
               (or ,window-symb
                   (frame-selected-window ,temp-frame-symb))
             (progn ,@body))
         (when ,temp-frame-symb
           (delete-frame ,temp-frame-symb))))))

(defun elastic-tools-before-change-function (start end)
  "Queue a call to `elastic-tools-do-region' for START and END."
  (setq elastic-tools-deleted-newline
        (save-excursion
          (goto-char start)
          (and (search-forward "\n" end t)
               t))) ; forget the actual position (for tidyness)
  ;; (message "etbcf: %s %s-%s" elastic-tools-deleted-newline start end)
  )

(defvar-local elastic-tools-queue nil
  "Queue of changes to handle.
We need queueing because some commands (for instance
`fill-paragraph') will cause many changes, which may individually
propagate down the buffer.  Doing all this work many times can
cause visible slowdowns.")

(defun elastic-tools-push (task hook)
  "Push TASK in the queue for HOOK."
  (push task (alist-get hook elastic-tools-queue)))

(defun elastic-tools-after-change-function (start end _len)
  "Queue a change between START and END to be handled by `elastic-tools-handlers'."
  ;; (message "etacf: %s %s-%s" elastic-tools-deleted-newline start end)
  (elastic-tools-push (list elastic-tools-deleted-newline (copy-marker start) (copy-marker end t))
                 (car (car elastic-tools-handlers))))

(defmacro elastic-tools-with-context (&rest body)
  "Run BODY in a context which is suitable for applying our adjustments."
  (declare (indent 0))
  `(save-match-data ; just in case
     (elastic-tools-with-suitable-window ; we need a window to compute the character widths.
      (save-excursion
        (without-restriction ; because changes may propagate beyond the restriction.
          (with-silent-modifications
            ,@body))))))


(defun elastic-tools-do-buffer ()
  "Call each `elastic-tools-handlers' on the whole buffer."
  (interactive)
  (elastic-tools-with-context
    (dolist (hook (-map #'car elastic-tools-handlers))
      (funcall hook t (point-min) (point-max)))))


(defun elastic-tools-queue-buffer ()
  "Queue handling the whole buffer."
  (elastic-tools-after-change-function (point-min) (point-max) nil))

(defun elastic-tools-handle-queue ()
  "Take care of intervals in queue.
If input comes before the work can be finished, then stop and
continue the work later, when idle."
  (let ((hooks (-map #'car elastic-tools-handlers)))
    (while hooks
      (let ((hook (pop hooks)))
        ;; (message "elastic-tools: dealing with %s q=%s" hook (alist-get hook elastic-tools-queue))
        (setf (alist-get hook elastic-tools-queue)
              (--sort (or (< (cadr it) (cadr other)) (car it)) (alist-get hook elastic-tools-queue)))
        (elastic-tools-with-context
          (goto-char (point-min))
          (while-no-input ;  see post-command-hook documentation.
            (while (alist-get hook elastic-tools-queue)
              (pcase (car (alist-get hook elastic-tools-queue))
                (`(,force-propagate ,start ,end)
                 (when (> end (point)) ; otherwise the change has already been taken care of.
                   (let ((actual-start (max (point) start))) ; portion before point was already done.
                     (funcall hook force-propagate actual-start end)
                     ;; (message "elastic-tools: running %s %s %s %s" hook force-propagate actual-start end)
                     (when hooks
                       ;; next layer needs to deal with the change.
                       (elastic-tools-push (list t actual-start (copy-marker (max end (point))))
                                      (car hooks)))))))
              ;; pop only when we're done so we don't forget something
              (pop (alist-get hook elastic-tools-queue))))
          (when (alist-get hook elastic-tools-queue)
            ;; input came: we continue later.
            (setq hooks nil) ; stop the outer loop
            (run-with-idle-timer 0.2 nil #'elastic-tools-handle-queue)))))))

(defun elastic-tools-clear-region-properties (start end cue-prop props-to-remove)
  "Clear PROPS-TO-REMOVE text properties in given region.
The region is the part between START and END which also has the
text property CUE-PROP be t."
  (with-silent-modifications
    (cl-do ((pos1 start) pos2) (nil)
      (setq pos1 (text-property-any pos1 end cue-prop t))
      (unless pos1 (cl-return))
      (setq pos2 (or (next-single-property-change pos1 cue-prop nil end)
                     end))
      (remove-list-of-text-properties pos1 pos2 props-to-remove)
      (setq pos1 pos2))))


(defun elastic-tools-on-col-2 (pos)
  "Is POS on 2nd column?"
  (save-excursion
    (goto-char pos)
    (and (not (bolp))
         (progn (forward-char -1)
                (bolp)))))

(defun elastic-tools-text-pixel-width (start end)
  "Return the pixel width of text between START and END in current buffer."
  (let ((c (car (window-text-pixel-size nil start end))))
    (if (> c 2) c ;; Emacs bug: sometimes the returned window-text-pixel-size is negative. In this case computing it indirectly like below seems to fix the issue.
      (- (car (window-text-pixel-size nil (1- start) end))
         (car (window-text-pixel-size nil (1- start) start))))))

(defun elastic-tools-char-pixel-width (pos)
  "Return the pixel width of char at POS."
  (if-let (p (get-text-property pos 'elastindent-width))
      p
    ;; Emacs bug: sometimes the returned window-text-pixel-size is
    ;; wrong. In this case computing it indirectly like below
    ;; seems to fix the issue.
    (let ((c (car (window-text-pixel-size nil pos (1+ pos)))))
      (if (or (<= c 1) ; suspicious
              (elastic-tools-on-col-2 pos)) ; emacs is often wrong on that column, for some reason.
          (- (car (window-text-pixel-size nil (1- pos) (1+ pos)))
             (car (window-text-pixel-size nil (1- pos) pos)))
        c))))


(defun elastic-tools-show-char-pixel-width (pos)
  "Display pixel width of region char at POS.
This is a debug utility for `elastindent-mode'"
  (interactive "d")
  (message "pixel width of region: %s, raw=%s, disp=%s, prop=%s"
           (elastic-tools-char-pixel-width pos)
           (car (window-text-pixel-size nil pos (1+ pos)))
           (get-char-property pos 'elastindent-width)
           (get-char-property pos 'display)))


(provide 'elastic-tools)
;;; elastic-tools.el ends here