summary refs log tree commit diff
path: root/emacs/site-lisp/elastic-indent.el
blob: a37bacb3cb4a2f5218014807a23dfd32d2324a48 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
;; elastic-indent-mode  --- fix indentation with variable-pitch fonts. -*- lexical-binding: t; -*-
;; Copyright (C) 2023 Jean-Philippe Bernardy
;; Copyright (C) 2021 Scott Messick (tenbillionwords)

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

;;; General terminological note: elastic-indent-mode is concerned with
;;; adjusting the width of only spaces and tabs which occur before a
;;; printing character (not space or tab) on a line.  We use the word
;;; “indentation” to refer to these tabs or spaces.  It is ambiguous
;;; whether Unicode space characters other than space and (horizontal)
;;; tab should be considered part of the leading space or not, but in
;;; the code we assume it is only spaces and tabs.  Thus
;;; elastic-indent-mode treats other space characters as printing
;;; characters.
;;; The support for tabs is currently limited.  Tabs can only be first
;;; in the indentation (they cannot follows spaces).  Editting code
;;; with tabs isn't fully supported either.

;;; Code:

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

(defgroup elastic-indent nil "Customization of elastic indentation."
  :group 'elastic)

(defcustom elastic-indent-lvl-cycle-size 1 "Size of the cycle used for faces.
If N is the cycle size, then faces 0 to N-1 will be used.  See
also `elastic-indent-fst-col-faces' and `elastic-indent-rest-faces'."
  :type 'int :group 'elastic-indent)

(defcustom elastic-indent-fontify t
  "If t, fontify indent levels.
Fontification can only happen on a per-character basis.
Therefore, if indentation is implemented by a mix of space and
tabulation characters, as typical in Emacs source code, the
results will not be pretty."
  :type 'bool :group 'elastic-indent)

(defface elastic-indent '((t (:inherit lazy-highlight))) "Face for indentation highlighting.")
(defface elastic-indent-2 '((t (:inherit highlight))) "Second face for indentation highlighting.")

(defcustom elastic-indent-fst-col-faces '(elastic-indent)
  "Faces for various levels (First column)." :type '(list face) :group 'elastic-indent)

(defcustom elastic-indent-rest-faces '(nil)
  "Faces for various levels (First column)."
  :type '(list face)
  :group 'elastic-indent)

(defface elastic-indent-vertical-lines '((t ())) "Face for indentation lines.")

(defun elastic-indent-fontify-alternate ()
  "Highlight indentation by columns of alternating background color."
  (interactive)
  (setq elastic-indent-lvl-cycle-size 2)
  (setq elastic-indent-rest-faces '(elastic-indent elastic-indent-2))
  (setq elastic-indent-fst-col-faces '(elastic-indent elastic-indent-2)))

(defun elastic-indent-fontify-with-lines ()
  "Experimental way to fontify indentation."
  (interactive)
  (setq elastic-indent-lvl-cycle-size 2)
  (setq elastic-indent-rest-faces '(elastic-indent-vertical-lines default))
  (setq elastic-indent-fst-col-faces '(elastic-indent-vertical-lines default)))

(define-minor-mode elastic-indent-mode
  "Improves indentation with in variable-pitch face.
Adjust the width of indentation characters to align the indented
code to the correct position.  The correct position is defined as
the same relative position to the previous line as it were if a
fixed-pitch face was used.

More precisely, any space character on a line with no printing
characters before it will be matched up with a corresponding
character on the previous line, if there is one.  That character
may itself be a width-adjusted space, meaning the width
ultimately comes from some other kind of character higher up.

Due to technical limitations, this mode does not try to detect
situations where the font has changed but the text hasn't, which
will mess up the alignment.  You can put
‘elastic-indent-do-buffer-if-enabled’ in appropriate hooks to
mitigate the problem."
  :init-value nil :lighter nil :global nil
  (if elastic-indent-mode
      (elastic-tools-add-handler 'elastic-indent-do-region 50)
    (elastic-tools-remove-handler 'elastic-indent-do-region)
    (elastic-indent-clear-buffer)))

(defun elastic-indent-char-lvl (pos l-pos)
  "Return the indentation level at POS.
An indentation level is not the colum, but rather defined as the
number of times an indentation occured.  Level is negative for the
spaces which aren't in the first column of any given level.  If
this cannot be determined locally by what happens at POS, then
look at L-POS, which is a position just to the left of the
position for which we want the level."
  (or (get-text-property pos 'elastic-indent-lvl)
      (if (or (eq pos (point-min)) (eq (char-after (1- pos)) ?\n))
          1 ;; first char in the line. creates an indentation level by default.
        (let ((lvl-before (get-text-property (1- pos) 'elastic-indent-lvl)))
          (if (and lvl-before (<= lvl-before 0))
              ;; it's a space before this position. Thus this character creates a new indentation level.
              (1+ (abs lvl-before))
            (- (abs (or (get-text-property l-pos 'elastic-indent-lvl) 0)))))))) ; in case of tabs we have to come up with some number. Use 0.

(defun elastic-indent-set-char-pixel-width (pos w)
  "Set the width of character at POS to be W.
This only works if the character in question is a space or a tab.
Also add text properties to remember that we did this change and
by what."
  (if w (add-text-properties pos (1+ pos)
                             (list 'display (list 'space :width (list w))
                                   'elastic-indent-adjusted t
                                   'elastic-indent-width w))
    (remove-text-properties pos (1+ pos) '(display elastic-indent-width elastic-indent-adjusted))))

(defun elastic-indent-combine-info (std-width lst)
  "Return the combination of infos in LST.
STD-WIDTH is the width of a `tab-width' fraction of a tab."
  (cons (-sum (--map (or (car it) std-width) lst))
        (-some 'cdr lst)))

(defun elastic-indent-set-char-info (pos i)
  "Set width and face I for space at POS.
The car of I is the width, and the cdr of I is the level."
  (when i
    (elastic-indent-set-char-pixel-width pos (car i))
    (let* ((lvl (or (cdr i)))
           (face-set (if (> lvl 0) elastic-indent-fst-col-faces elastic-indent-rest-faces))
           (face (nth (mod lvl elastic-indent-lvl-cycle-size) face-set)))
      (put-text-property pos (1+ pos) 'elastic-indent-lvl lvl)
      (when (and elastic-indent-fontify lvl)
        (if face (put-text-property pos (1+ pos) 'font-lock-face face)
          (remove-text-properties  pos (1+ pos) '(font-lock-face)))))))

(defun elastic-indent-column-leaves-indent (target)
  "Return t if by advancing TARGET columns one reaches the end of the indentation."
  (let ((col 0))
    (while (and (not (eobp)) (< col target))
      (pcase (char-after)
        (?\s (setq col (1+ col)))
        (?\t (setq col (+ 8 col)))
        (_ (setq target -1)))
      (when (<= 0 target) (forward-char)))
    (not (looking-at (rx (any "\s\t"))))))

(defun elastic-indent-in-indent ()
  "Return t iff all characters to the left are indentation chars."
  (save-excursion
    (while (and (not (bolp))
                (pcase (char-before)
                  ((or ?\s ?\t) (or (backward-char 1) t)))))
    (bolp)))

(defun elastic-indent-do-1 (force-propagate start-col change-end)
  "Adjust width of indentations.
This is in response to a change starting at point and ending at
CHANGE-END.  START-COL is the minimum column where a change
occured.  Start at point and continue until line which cannot be
impacted by the change is found.  Such a line occurs if its
indentation level is less than START-COL and starts after
CHANGE-END.

Thus a large value of START-COL means that little work needs to
be done by this function.  This optimisation is important,
because otherwise one needs to find a line with zero indentation,
which can be much further down the buffer.

If a change spans several lines, then START-COL is ignored, and
changes are propagated until indentation level reaches 0.
FORCE-PROPAGATE forces even single-line changes to be treated
this way."
  (let (prev-widths ; the list of widths of each *column* of indentation of the previous line
        (reference-pos 0) ; the buffer position in the previous line of 1st printable char
        (std-width (window-font-width))
        space-widths) ; accumulated widths of columns for current line
    ;; (message "elastic-indent-do: %s [%s, %s]" start-col (point) change-end)
    (cl-flet*
        ((get-next-column-width () ; find reference width in the previous line. (effectful)
           (let* ((l-pos (1- (point)))
                  (w (if prev-widths (pop prev-widths) ; we have a cached width: use it.
                       (if (eql (char-after reference-pos) ?\n) ; we're at the end of the reference line.
                           (cons nil (if (bolp)
                                         (- 1) ; we're at a space on the 1st column with an empty line above. No indentation here.
                                       (- (abs (elastic-indent-char-lvl l-pos nil))))) ; leave width as is. Level is the same as char on the left; but not 1st column.
                         (prog1
                             (cons (elastic-tools-char-pixel-width reference-pos) (elastic-indent-char-lvl reference-pos l-pos))
                           (setq reference-pos (1+ reference-pos)))))))
             (push w space-widths) ; cache width for next line
             ;; (message "char copy: %s->%s (w=%s) %s" (1- reference-pos) (point) w prev-widths)
             w))
         (char-loop () ; take care of one line.
           ;; copy width from prev-widths, then reference-pos to (point). Loop to end of indentation.
           ;; Preconditions: cur col = start-col.  prev-widths=nil or contains cached widths
           ;; (message "@%s %s=>%s %s" (line-number-at-pos) (- reference-pos (length prev-widths)) (point) prev-widths)
           (while-let ((cur-line-not-ended-c (not (eolp)))
                       (char (char-after))
                       (char-is-indent-c (or (eql char ?\s) (eql char ?\t))))
             (pcase char
               (?\s (elastic-indent-set-char-info (point) (get-next-column-width)))
               (?\t (elastic-indent-set-char-info (point)
                                               (elastic-indent-combine-info std-width
                                                (--map (get-next-column-width) (-repeat tab-width ()))))))
             (forward-char)))
         (next-line () ; advance to next line, maintaining state.
           (setq prev-widths (nreverse space-widths))
           (setq space-widths nil)
           (setq reference-pos (point)) ; we go to next line exactly after we reached the last space
           (forward-line)))
      (save-excursion
        (when (eq (forward-line -1) 0)
          (setq reference-pos (progn (move-to-column start-col) (point)))))
      ;; (message "%s: first line. update from startcol=%s curcol=%s" (line-number-at-pos) start-col (current-column))
      (when (elastic-indent-in-indent) ; if not characters are not to be changed.
        (char-loop))
      (next-line)
      (when (or force-propagate (<= (point) change-end))
        ;; this is a multiline change. It influences column 0.
        ;; (message "%s: main phase; update lines from col 0" (line-number-at-pos))
        (when (> start-col 0)  ; reference is wrong now
          (setq start-col 0)
          (setq prev-widths nil)
          (setq reference-pos (save-excursion (forward-line -1) (point))))
        (while (<= (point) change-end)
          (char-loop)
          (next-line)))
      ;; (message "%s: propagate changes and stop if indentation is too small" (line-number-at-pos))
      (while (not (elastic-indent-column-leaves-indent start-col))
        (char-loop)
        (next-line))
      ;; (message "%s: propagation complete" (line-number-at-pos))
      (beginning-of-line)))) ; we did not in fact propagate on this line yet.

(defun elastic-indent-change-extend (end)
  "Return the first position after END which does not contain a space."
  (max (point)
       (save-excursion
         (goto-char end)
         ;; if we are changing something then the spaces just after the changes are
         ;; invalid and must be updated.
         (search-forward-regexp "[^\t\s]" nil t)
         (1- (point)))))

(defun elastic-indent-do-region (force-propagate start end)
  "Adjust width of indentation characters in given region and propagate.
The region is between START and END in current
buffer.  Propagation means to also fix the indentation of the
lines which follow, if their indentation widths might be impacted
by changes in given region.  See `elastic-indent-do' for the
explanation of FORCE-PROPAGATE."
  ;; (message "edr: (%s) %s-%s" force-propagate start end)
  (let ((e (elastic-indent-change-extend end)))
    (elastic-indent-clear-region start e)
    (goto-char start)
    (elastic-indent-do-1 force-propagate (current-column) e)))

(defun elastic-indent-clear-region (start end)
  "Remove all `elastic-indent-mode' properties between START and END."
  (interactive "r")
  (elastic-tools-clear-region-properties
   start end 'elastic-indent-adjusted '(elastic-indent-adjusted
                                        elastic-indent-width
                                        elastic-indent-lvl
                                        display
                                        font-lock-face
                                        mouse-face)))

(defun elastic-indent-clear-buffer ()
  "Remove all `elastic-indent-mode' properties in buffer."
  (interactive)
  (elastic-indent-clear-region (point-min) (point-max)))

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