1 ;;; side-hustle.el --- Hustle through Imenu in a side window -*- lexical-binding: t; -*-
3 ;; Copyright (c) 2021-2024 Paul W. Rankin
5 ;; Author: Paul W. Rankin <rnkn@rnkn.xyz>
6 ;; Keywords: convenience
8 ;; Package-Requires: ((emacs "24.4") (seq "2.20"))
9 ;; URL: https://github.com/rnkn/side-hustle
11 ;; This program is free software; you can redistribute it and/or modify
12 ;; it under the terms of the GNU General Public License as published by
13 ;; the Free Software Foundation, either version 3 of the License, or
14 ;; (at your option) any later version.
16 ;; This program is distributed in the hope that it will be useful,
17 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
18 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 ;; GNU General Public License for more details.
21 ;; You should have received a copy of the GNU General Public License
22 ;; along with this program. If not, see <https://www.gnu.org/licenses/>.
29 ;; Hustle through a buffer's Imenu in a side window in GNU Emacs.
31 ;; Side Hustle spawns a side window linked to the current buffer, which allows
32 ;; working with multiple buffers simultaneously.
38 ;; The latest stable release of Side Hustle is available via [MELPA-stable][1].
39 ;; First, add MELPA-stable to your package archives:
41 ;; M-x customize-option RET package-archives RET
43 ;; Insert an entry named melpa-stable with URL:
44 ;; https://stable.melpa.org/packages/
46 ;; You can then find the latest stable version of side-hustle in the list
49 ;; M-x list-packages RET
51 ;; If you prefer the latest but perhaps unstable version, do the above using
54 ;; Then add a key binding to your init file:
56 ;; (define-key (current-global-map) (kbd "M-s l") #'side-hustle-toggle)
59 ;; Bugs and Feature Requests
60 ;; -------------------------
62 ;; Send me an email (address in the package header). For bugs, please
63 ;; ensure you can reproduce with:
65 ;; $ emacs -Q -l side-hustle.el
67 ;; Known issues are tracked with FIXME comments in the source.
73 ;; Side Hustle takes inspiration primarily from
74 ;; [imenu-list](https://github.com/bmag/imenu-list).
77 ;; [1]: https://stable.melpa.org/#/side-hustle
78 ;; [2]: https://melpa.org/#/side-hustle
84 (defgroup side-hustle nil
85 "Navigate Imenu in a side window."
88 :link '(info-link "(emacs) Imenu"))
91 ;;; Internal Variables ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
93 (defvar-local side-hustle--source-buffer nil)
94 (defvar-local side-hustle--hidden nil)
97 ;;; User Options ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
99 (defcustom side-hustle-display-alist
103 "Alist used to display side-hustle buffer."
106 :link '(info-link "(elisp) Buffer Display Action Alists"))
108 (defcustom side-hustle-select-window t
109 "When non-nil, select the menu window after creating it."
114 (defcustom side-hustle-persistent-window nil
115 "When non-nil, make the side-window persistent.
116 This requires either calling `quit-window' or
117 `side-hustle-toggle' to quit the side-window."
122 (defcustom side-hustle-evaporate-window nil
123 "When non-nil, quit the side window when following link."
128 (defcustom side-hustle-item-char ?\*
129 "Character to use to itemize `imenu' items."
130 :type '(choice (const nil) character)
133 (defcustom side-hustle-indent-width 4
134 "Indent width in columns for sublevels of `imenu' items."
140 ;;; Faces ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
143 '((t (:underline nil :inherit button)))
144 "Default face for side-window items."
147 ;; (defface side-hustle-highlight
148 ;; '((t (:extend t :inherit (secondary-selection))))
149 ;; "Default face for highlighted items."
150 ;; :group 'side-hustle)
153 ;;; Internal Functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
155 (defun side-hustle-button-ensure ()
156 "Ensure point is at button."
157 (or (button-at (point))
158 (and (eolp) (forward-button -1 nil nil t))
159 (forward-button 1 nil nil t)))
161 (defun side-hustle-imenu-item (button save-window)
162 "Pop up buffer containing `imenu' item for BUTTON.
163 Handle buffer according to SAVE-WINDOW or value of
164 `side-hustle-evaporate-window'."
165 (let ((buffer (current-buffer))
166 (imenu-item (button-get button 'hustle-item)))
168 (pop-to-buffer side-hustle--source-buffer)
170 (recenter-top-bottom)
172 (select-window (get-buffer-window buffer (selected-frame))))
173 (side-hustle-evaporate-window
174 (quit-window nil (get-buffer-window buffer (selected-frame))))))))
176 (defun side-hustle-switch-hide-state (label)
177 "Toggle inclusion of LABEL in `side-hustle--hidden'."
178 (setq side-hustle--hidden
179 (if (member label side-hustle--hidden)
180 (remove label side-hustle--hidden)
181 (cons label side-hustle--hidden))))
183 (defun side-hustle-show-hide (start end)
184 "Toggle invisibility of items between START and END."
185 (with-silent-modifications
186 (if (eq (get-text-property (button-end (button-at (point))) 'invisible)
188 (put-text-property start end 'invisible nil)
189 (put-text-property start end 'invisible 'hustle-invisible))))
191 (defun side-hustle-button-action (button &optional hide save-window)
192 "Call appropriate button action for BUTTON.
193 When HIDE is non-nil, always hide child items. Pass SAVE-WINDOW
194 to `side-hustle-imenu-item'."
195 (let ((level (button-get button 'hustle-level))
196 (label (button-label button))
197 (start (button-end button))
198 (end (button-end button)))
200 (while (and (forward-button 1 nil nil t)
201 (< level (button-get (button-at (point)) 'hustle-level)))
202 (setq end (button-end (button-at (point))))))
204 (side-hustle-imenu-item button save-window))
206 (side-hustle-show-hide start end))
208 (side-hustle-show-hide start end)
209 (side-hustle-switch-hide-state label)))))
211 (defun side-hustle-insert (item level)
212 "Insert ITEM at indentation level LEVEL.
213 And `imenu' marker as button property."
214 (insert (make-string level ?\t))
215 (when (characterp side-hustle-item-char)
216 (insert side-hustle-item-char "\s"))
217 (insert-text-button (car item)
221 'action #'side-hustle-button-action
222 'help-echo "mouse-1, RET: go to this item; SPC: show this item"
226 (defun side-hustle-insert-items (imenu-items level)
227 "For each item in IMENU-ITEMS, insert appropriately.
228 Either call `side-hustle-insert' at LEVEL, or if item is an
229 alist, insert alist string and increment LEVEL before calling
230 recursively with `cdr'."
233 (if (imenu--subalist-p item)
235 (side-hustle-insert item level)
236 (side-hustle-insert-items (cdr item) (1+ level)))
237 (side-hustle-insert item level)))
240 (defun side-hustle-refresh ()
241 "Rebuild and insert `imenu' entries for source buffer."
243 (with-silent-modifications
246 (when (buffer-live-p side-hustle--source-buffer)
248 (with-current-buffer side-hustle--source-buffer
249 (setq imenu--index-alist nil)
250 (imenu--make-index-alist t)
251 imenu--index-alist)))
253 (setq header-line-format (buffer-name side-hustle--source-buffer))
254 (setq tab-width side-hustle-indent-width)
255 (when imenu-items (side-hustle-insert-items imenu-items 0))
256 (goto-char (point-min))
258 (while (setq button (forward-button 1 nil nil t))
259 (when (member (button-label button) side-hustle--hidden)
260 (side-hustle-button-action button t))))
263 (defun side-hustle-find-existing (sourcebuf)
264 "Return existing `side-hustle' buffer for SOURCEBUF or nil if none."
267 (with-current-buffer buf
268 (eq side-hustle--source-buffer sourcebuf)))
271 (defun side-hustle-get-buffer-create (sourcebuf)
272 "Get or create `side-hustle' buffer for SOURCEBUF."
273 (or (side-hustle-find-existing sourcebuf)
274 (let ((new-buf (get-buffer-create
275 (concat "Side-Hustle: " (buffer-name sourcebuf)))))
276 (with-current-buffer new-buf
278 (setq side-hustle--source-buffer sourcebuf)
279 (setq side-hustle--hidden nil))
282 ;; (defun side-hustle-highlight-current ()
283 ;; "Highlight the current `imenu' item in `side-hustle'.
284 ;; Added to `window-configuration-change-hook'."
285 ;; (unless (or (minibuffer-window-active-p (selected-window))
286 ;; (eq major-mode 'side-hustle-mode))
288 ;; (buf (side-hustle-find-existing (current-buffer)))
290 ;; (when (and buf (window-live-p (get-buffer-window buf)))
291 ;; (with-current-buffer buf
292 ;; (with-silent-modifications
293 ;; (remove-text-properties (point-min) (point-max) '(face))
294 ;; (goto-char (point-min))
295 ;; (while (< (point) (point-max))
296 ;; (let ((marker (get-text-property (point) 'side-hustle-imenu-marker)))
297 ;; (when (and (markerp marker)
298 ;; (<= (marker-position marker) x)
299 ;; (or (null diff) (< (- x (marker-position marker)) diff)))
300 ;; (setq candidate (point)
302 ;; (min diff (- x (marker-position marker)))
303 ;; (- x (marker-position marker))))))
306 ;; (goto-char candidate)
307 ;; (put-text-property (line-beginning-position 1)
308 ;; (line-beginning-position 2)
309 ;; 'face 'side-hustle-highlight))))))))
312 ;;; Commands ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
314 (defun side-hustle-goto-item ()
315 "Go to the `imenu' item at point in other window."
317 (when (side-hustle-button-ensure)
318 (side-hustle-button-action (button-at (point)))))
320 (defun side-hustle-show-item ()
321 "Display the `imenu' item at point in other window."
323 (when (side-hustle-button-ensure)
324 (side-hustle-button-action (button-at (point)) nil t)))
327 (defun side-hustle-toggle ()
328 "Pop up a side window containing `side-hustle'."
330 (if (eq major-mode 'side-hustle-mode)
332 (let ((display-buffer-mark-dedicated t)
333 (buf (side-hustle-get-buffer-create (current-buffer))))
334 (if (get-buffer-window buf (selected-frame))
335 (delete-windows-on buf (selected-frame))
336 (display-buffer-in-side-window
337 buf (append side-hustle-display-alist
338 (when side-hustle-persistent-window
339 (list '(window-parameters
340 (no-delete-other-windows . t))))))
341 (with-current-buffer buf (side-hustle-refresh))
342 ;; (side-hustle-highlight-current)
343 (when side-hustle-select-window
344 (select-window (get-buffer-window buf (selected-frame))))))))
346 ;; (defalias 'toggle-side-hustle #'side-hustle-toggle)
349 ;;; Mode Definition ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
351 (defvar side-hustle-mode-map
352 (let ((map (make-sparse-keymap)))
353 (define-key map (kbd "p") #'previous-line)
354 (define-key map (kbd "n") #'next-line)
355 (define-key map (kbd "q") #'quit-window)
356 (define-key map (kbd "g") #'side-hustle-refresh)
357 (define-key map (kbd "RET") #'side-hustle-goto-item)
358 (define-key map (kbd "SPC") #'side-hustle-show-item)
359 (define-key map (kbd "TAB") #'forward-button)
360 (define-key map (kbd "S-TAB") #'backward-button)
361 (define-key map (kbd "<backtab>") #'backward-button)
364 (define-derived-mode side-hustle-mode
365 special-mode "Side-Hustle"
366 "Major mode to navigate `imenu' via a side window.
368 You should not activate this mode directly, rather, call
369 `side-hustle-toggle' \\[side-hustle-toggle] in a source buffer."
370 (add-to-invisibility-spec '(hustle-invisible . t)))
374 (provide 'side-hustle)
375 ;;; side-hustle.el ends here
378 ;; coding: utf-8-unix
380 ;; require-final-newline: t
381 ;; sentence-end-double-space: nil
382 ;; indent-tabs-mode: nil