511 lines
19 KiB
EmacsLisp
511 lines
19 KiB
EmacsLisp
;;; olivetti.el --- Minor mode to automatically balance window margins -*- lexical-binding: t; -*-
|
||
|
||
;; Copyright (c) 2014-2024 Paul W. Rankin
|
||
|
||
;; Author: Paul W. Rankin <rnkn@rnkn.xyz>
|
||
;; Keywords: wp, text
|
||
;; Package-Version: 20241030.542
|
||
;; Package-Revision: 845eb7a95a3c
|
||
;; Package-Requires: ((emacs "24.4"))
|
||
;; URL: https://github.com/rnkn/olivetti
|
||
|
||
;; 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:
|
||
|
||
;; Olivetti
|
||
;; ========
|
||
|
||
;; A simple Emacs minor mode for a nice writing environment.
|
||
|
||
;; Features
|
||
;; --------
|
||
|
||
;; - Set a desired text body width to automatically resize window margins
|
||
;; to keep the text comfortably in the middle of the window.
|
||
;; - Text body width can be the number of characters (an integer), a fraction of
|
||
;; the window width (a float between 0.0 and 1.0), or nil which uses the value
|
||
;; of fill-column +2.
|
||
;; - Interactively change body width with:
|
||
;; olivetti-shrink C-c { { { ...
|
||
;; olivetti-expand C-c } } } ...
|
||
;; olivetti-set-width C-c |
|
||
;; - If olivetti-body-width is an integer, the text body width will
|
||
;; scale with use of text-scale-mode, whereas if a fraction (float) then
|
||
;; the text body width will remain at that fraction.
|
||
;; - Change the way the text body margins look with option olivetti-style: use
|
||
;; margins, fringes, or both for a fancy "page" look.
|
||
;; - Customize olivetti-fringe face to affect only Olivetti buffers.
|
||
;; - Optionally remember the state of visual-line-mode on entry and
|
||
;; recall its state on exit.
|
||
|
||
;; Olivetti keeps everything it does buffer-local, so you can write prose
|
||
;; in one buffer and code in another, side-by-side in the same frame.
|
||
|
||
|
||
;; Requirements
|
||
;; ------------
|
||
|
||
;; - Emacs 24.4
|
||
|
||
|
||
;; Installation
|
||
;; ------------
|
||
|
||
;; The latest stable release of Olivetti is available via
|
||
;; [MELPA-stable][1]. First, add MELPA-stable to your package archives:
|
||
|
||
;; M-x customize-option RET package-archives RET
|
||
|
||
;; Insert an entry named melpa-stable with URL:
|
||
;; https://stable.melpa.org/packages/
|
||
|
||
;; You can then find the latest stable version of olivetti in the
|
||
;; list returned by:
|
||
|
||
;; M-x list-packages RET
|
||
|
||
;; If you prefer the latest but perhaps unstable version, do the above
|
||
;; using [MELPA][2].
|
||
|
||
|
||
;; Advanced Installation
|
||
;; ---------------------
|
||
|
||
;; Download the latest tagged release, move this file into your load-path
|
||
;; and add to your init.el file:
|
||
|
||
;; (require 'olivetti)
|
||
|
||
;; If you wish to contribute to or alter Olivetti's code, clone the
|
||
;; repository into your load-path and require as above:
|
||
|
||
;; git clone https://github.com/rnkn/olivetti.git
|
||
|
||
|
||
;; Bugs and Feature Requests
|
||
;; -------------------------
|
||
|
||
;; Use GitHub issues or send me an email (address in the package header).
|
||
;; For bugs, please ensure you can reproduce with:
|
||
|
||
;; $ emacs -Q -l olivetti.el
|
||
|
||
|
||
;; Alternatives
|
||
;; ------------
|
||
|
||
;; For those looking for a hardcore distraction-free writing mode with a much
|
||
;; larger scope, I recommend [Writeroom Mode](https://github.com/joostkremers/writeroom-mode).
|
||
|
||
|
||
;; [1]: https://stable.melpa.org/#/olivetti
|
||
;; [2]: https://melpa.org/#/olivetti
|
||
|
||
|
||
;; Donations
|
||
;; ---------
|
||
|
||
;; Donations are graciously accepted via [Github][3], or [Liberapay][4].
|
||
|
||
;; [3]: https://github.com/sponsors/rnkn
|
||
;; [4]: https://liberapay.com/rnkn/
|
||
|
||
;;; Code:
|
||
|
||
(require 'fringe)
|
||
|
||
(defgroup olivetti ()
|
||
"Minor mode for a nice writing environment."
|
||
:prefix "olivetti-"
|
||
:group 'text)
|
||
|
||
|
||
;;; Internal Variables ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||
|
||
(eval-when-compile
|
||
(require 'lisp-mnt)
|
||
(defconst olivetti-version
|
||
(lm-version load-file-name)))
|
||
|
||
(defvar-local olivetti--visual-line-mode
|
||
nil
|
||
"Value of `visual-line-mode' when when `olivetti-mode' is enabled.")
|
||
|
||
(defvar-local olivetti--split-window-preferred-function
|
||
nil
|
||
"Value of `split-window-preferred-function' at initialization.")
|
||
|
||
(defvar-local olivetti--face-remap
|
||
nil
|
||
"Saved cookie from `face-remap-add-relative' at initialization.")
|
||
|
||
|
||
;;; Options ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||
|
||
(defcustom olivetti-mode-on-hook
|
||
'(visual-line-mode)
|
||
"Hook for `olivetti-mode', run after the mode is activated."
|
||
:type 'hook
|
||
:options '(visual-line-mode)
|
||
:safe 'hook)
|
||
|
||
(defcustom olivetti-mode-off-hook
|
||
nil
|
||
"Hook for `olivetti-mode', run after the mode is deactivated."
|
||
:type 'hook
|
||
:safe 'hook)
|
||
|
||
(defcustom olivetti-body-width
|
||
nil
|
||
"Width of text body width to adjust relative margins.
|
||
If an integer, set text body width to that integer in columns; if
|
||
a floating point between 0.0 and 1.0, set text body width to that
|
||
fraction of the total window width. If nil (the default), use the
|
||
value of `fill-column' + 2. The extra 2 columns are to prevent
|
||
text files at `fill-colum ' from wrapping in the body widith.
|
||
|
||
An integer is best if you want text body width to remain
|
||
constant, while a floating point is best if you want text body
|
||
width to change with window width.
|
||
|
||
The floating point can anything between 0.0 and 1.0 (exclusive),
|
||
but use a value between about 0.33 and 0.9 for best effect.
|
||
|
||
This option does not affect file contents."
|
||
:type '(choice (const :tag "Value of fill-column + 2" nil)
|
||
(integer 72)
|
||
(float 0.5))
|
||
:safe (lambda (value)
|
||
(or (numberp value) (null value))))
|
||
(make-variable-buffer-local 'olivetti-body-width)
|
||
|
||
(defcustom olivetti-minimum-body-width
|
||
40
|
||
"Minimum width in columns of text body."
|
||
:type 'integer
|
||
:safe 'integerp)
|
||
|
||
(defcustom olivetti-lighter
|
||
" Olv"
|
||
"Mode-line indicator for `olivetti-mode'."
|
||
:type '(choice (const :tag "No lighter" "") string)
|
||
:safe 'stringp)
|
||
|
||
(defcustom olivetti-recall-visual-line-mode-entry-state
|
||
t
|
||
"Recall the state of `visual-line-mode' upon exiting.
|
||
When non-nil, remember if `visual-line-mode' was enabled or not
|
||
upon activating `olivetti-mode' and restore that state upon
|
||
exiting."
|
||
:type 'boolean
|
||
:safe 'booleanp)
|
||
|
||
(defcustom olivetti-style
|
||
nil
|
||
"Window elements used to balance the text body.
|
||
Valid options are:
|
||
|
||
nil use margins (default)
|
||
t use fringes
|
||
fancy use both margins with fringes outside
|
||
|
||
n.b. Fringes are only available on a graphical window system and
|
||
will fall back to margins on console."
|
||
:type '(choice (const :tag "Margins" nil)
|
||
(const :tag "Fringes" t)
|
||
(const :tag "Fringes and Margins" fancy))
|
||
:set (lambda (symbol value)
|
||
(set-default symbol value)
|
||
(when (featurep 'olivetti)
|
||
(olivetti-reset-all-windows))))
|
||
|
||
(defcustom olivetti-margin-width
|
||
10
|
||
"Width in columns of margin between text body and fringes.
|
||
Only has any effect when `olivetti-style' is set to `fancy'."
|
||
:type '(choice (const :tag "None" nil)
|
||
(integer :tag "Columns" 10))
|
||
:safe 'integerp
|
||
:set (lambda (symbol value)
|
||
(set-default symbol value)
|
||
(when (featurep 'olivetti)
|
||
(olivetti-reset-all-windows))))
|
||
|
||
(defface olivetti-fringe
|
||
'((t (:inherit fringe)))
|
||
"Face for the fringes when `olivetti-style' is non-nil."
|
||
:group 'olivetti)
|
||
|
||
|
||
;;; Set Windows ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||
|
||
(defun olivetti-scale-width (width)
|
||
"Scale WIDTH in accordance with the face height.
|
||
For compatibility with `text-scale-mode', if
|
||
`face-remapping-alist' includes a :height property on the default
|
||
face, scale WIDTH by that factor if it is a fraction, by (height/100)
|
||
if it is an integer, and otherwise return WIDTH."
|
||
(let ((height (plist-get (cadr (assq 'default face-remapping-alist)) :height)))
|
||
(when (integerp height)
|
||
(setq height (/ height 100.0)))
|
||
(round (* width (or height 1)))))
|
||
|
||
(defun olivetti-normalize-width (width window)
|
||
"Parse WIDTH to a safe pixel value for `olivetti-body-width' for WINDOW."
|
||
(let ((char-width (frame-char-width (window-frame window)))
|
||
(window-width-pix (window-body-width window t))
|
||
min-width-pix)
|
||
(setq min-width-pix (* char-width
|
||
(+ olivetti-minimum-body-width
|
||
(% olivetti-minimum-body-width 2))))
|
||
(if (floatp width)
|
||
(floor (max min-width-pix (* window-width-pix (min width 1.0))))
|
||
(olivetti-scale-width
|
||
(max min-width-pix (min (* width char-width) window-width-pix))))))
|
||
|
||
(defun olivetti-reset-window (window)
|
||
"Remove Olivetti's parameters and margins from WINDOW."
|
||
(when (eq (window-parameter window 'split-window) 'olivetti-split-window)
|
||
(set-window-parameter window 'split-window nil))
|
||
(if (consp fringe-mode)
|
||
(set-window-fringes window (car fringe-mode) (cdr fringe-mode))
|
||
(set-window-fringes window fringe-mode fringe-mode))
|
||
(set-window-margins window nil))
|
||
|
||
(defun olivetti-reset-all-windows ()
|
||
"Call `olivetti-reset-window' on all windows."
|
||
(walk-windows #'olivetti-reset-window nil t))
|
||
|
||
;; FIXME: these split-window functions seem to be ignored by
|
||
;; `window-toggle-side-windows'
|
||
;; WORKAROUND:
|
||
;; (with-eval-after-load 'olivetti
|
||
;; (advice-add 'window-toggle-side-windows
|
||
;; :before 'olivetti-reset-all-windows))
|
||
|
||
(defun olivetti-split-window (&optional window size side pixelwise)
|
||
"Call `split-window' after resetting WINDOW.
|
||
Pass SIZE, SIDE and PIXELWISE unchanged."
|
||
(olivetti-reset-all-windows)
|
||
(split-window window size side pixelwise))
|
||
|
||
(defun olivetti-split-window-sensibly (&optional window)
|
||
"Like `olivetti-split-window' but call `split-window-sensibly'.
|
||
Pass WINDOW unchanged."
|
||
(olivetti-reset-all-windows)
|
||
(funcall olivetti--split-window-preferred-function window))
|
||
|
||
(defun olivetti-set-window (window-or-frame)
|
||
"Balance window margins displaying current buffer.
|
||
If WINDOW-OR-FRAME is a frame, cycle through windows displaying
|
||
current buffer in that frame, otherwise only work on the selected
|
||
window."
|
||
(if (framep window-or-frame)
|
||
(mapc #'olivetti-set-window
|
||
(get-buffer-window-list nil nil window-or-frame))
|
||
;; WINDOW-OR-FRAME passed below *must* be a window
|
||
(with-selected-window window-or-frame
|
||
(olivetti-reset-window window-or-frame)
|
||
(when olivetti-mode
|
||
;; If `olivetti-body-width' is nil, we need to calculate from
|
||
;; `fill-column'
|
||
(when (null olivetti-body-width)
|
||
(setq olivetti-body-width (+ fill-column 2)))
|
||
(let ((char-width-pix (frame-char-width (window-frame window-or-frame)))
|
||
(window-width-pix (window-body-width window-or-frame t))
|
||
(safe-width-pix (olivetti-normalize-width
|
||
olivetti-body-width window-or-frame)))
|
||
;; Handle possible display of fringes
|
||
(when (and window-system olivetti-style)
|
||
(let ((fringe-total (- (window-pixel-width window-or-frame)
|
||
safe-width-pix))
|
||
fringe)
|
||
;; Account for fancy display
|
||
(when (eq olivetti-style 'fancy)
|
||
(setq fringe-total
|
||
(- fringe-total
|
||
(* olivetti-margin-width char-width-pix 2))))
|
||
;; Calculate a single fringe width
|
||
(setq fringe (max (round (/ fringe-total 2.0)) 0))
|
||
;; Set the fringes
|
||
(set-window-fringes window-or-frame fringe fringe t)))
|
||
;; Calculate margins widths as body pixel width less fringes
|
||
(let ((fringes (window-fringes window-or-frame))
|
||
(margin-total-pix (/ (- window-width-pix safe-width-pix) 2.0))
|
||
left-margin right-margin)
|
||
;; Convert to character cell columns
|
||
(setq left-margin (max (round (/ (- margin-total-pix
|
||
(car fringes))
|
||
char-width-pix))
|
||
0)
|
||
right-margin (max (round (/ (- margin-total-pix
|
||
(cadr fringes))
|
||
char-width-pix))
|
||
0))
|
||
;; Finally set the margins
|
||
(set-window-margins window-or-frame left-margin right-margin)))
|
||
;; Set remaining window parameters
|
||
(set-window-parameter window-or-frame 'split-window
|
||
'olivetti-split-window)))))
|
||
|
||
(defun olivetti-set-buffer-windows ()
|
||
"Balance window margins in all windows displaying current buffer.
|
||
Cycle through all windows in all visible frames displaying the
|
||
current buffer, and call `olivetti-set-window'."
|
||
(mapc #'olivetti-set-window (get-buffer-window-list nil nil 'visible)))
|
||
|
||
|
||
;;; Width Interaction ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||
|
||
(defun olivetti-set-width (width)
|
||
"Set text body width to WIDTH with relative margins.
|
||
WIDTH may be an integer specifying columns or a float specifying
|
||
a fraction of the window width."
|
||
(interactive
|
||
(list (if current-prefix-arg
|
||
(prefix-numeric-value current-prefix-arg)
|
||
(read-number "Set text body width (integer or float): "
|
||
olivetti-body-width))))
|
||
(setq olivetti-body-width width)
|
||
(olivetti-set-buffer-windows)
|
||
(message "Text body width set to %s" olivetti-body-width))
|
||
|
||
(defun olivetti-expand (&optional arg)
|
||
"Incrementally increase the value of `olivetti-body-width'.
|
||
If prefixed with ARG, incrementally decrease."
|
||
(interactive "P")
|
||
(let* ((p (if arg -1 1))
|
||
(n (cond ((integerp olivetti-body-width)
|
||
(+ olivetti-body-width (* 2 p)))
|
||
((floatp olivetti-body-width)
|
||
(+ olivetti-body-width (* 0.01 p))))))
|
||
(setq olivetti-body-width n))
|
||
(olivetti-set-buffer-windows)
|
||
(message "Text body width set to %s" olivetti-body-width)
|
||
(unless overriding-terminal-local-map
|
||
(let ((prefix-keys (substring (this-single-command-keys) 0 -1))
|
||
(map (cdr olivetti-mode-map)))
|
||
(when (< 0 (length prefix-keys))
|
||
(mapc (lambda (k) (setq map (assq k map))) prefix-keys)
|
||
(setq map (cdr-safe map))
|
||
(when (keymapp map) (set-transient-map map t))))))
|
||
|
||
(defun olivetti-shrink (&optional arg)
|
||
"Incrementally decrease the value of `olivetti-body-width'.
|
||
If prefixed with ARG, incrementally increase."
|
||
(interactive "P")
|
||
(let ((p (unless arg t)))
|
||
(olivetti-expand p)))
|
||
|
||
|
||
;;; Keymap ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||
|
||
(defvar olivetti-mode-map
|
||
(let ((map (make-sparse-keymap)))
|
||
(define-key map (kbd "C-c }") #'olivetti-expand)
|
||
(define-key map (kbd "C-c {") #'olivetti-shrink)
|
||
(define-key map (kbd "C-c |") #'olivetti-set-width)
|
||
(define-key map (kbd "C-c \\") #'olivetti-set-width) ;; OBSOLETE
|
||
(define-key map [left-margin mouse-1] #'mouse-set-point)
|
||
(define-key map [right-margin mouse-1] #'mouse-set-point)
|
||
(define-key map [left-fringe mouse-1] #'mouse-set-point)
|
||
(define-key map [right-fringe mouse-1] #'mouse-set-point)
|
||
;; This code is taken from https://github.com/joostkremers/visual-fill-column
|
||
(when (and (bound-and-true-p mouse-wheel-mode)
|
||
(boundp 'mouse-wheel-down-event)
|
||
(boundp 'mouse-wheel-up-event))
|
||
(define-key map (vector 'left-margin 'mouse-wheel-down-event) 'mwheel-scroll)
|
||
(define-key map (vector 'left-margin 'mouse-wheel-up-event) 'mwheel-scroll)
|
||
(define-key map (vector 'right-margin 'mouse-wheel-down-event) 'mwheel-scroll)
|
||
(define-key map (vector 'right-margin 'mouse-wheel-up-event) 'mwheel-scroll))
|
||
map)
|
||
"Mode map for `olivetti-mode'.")
|
||
|
||
|
||
;;; Mode Definition ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||
|
||
(require 'face-remap)
|
||
|
||
;;;###autoload
|
||
(define-minor-mode olivetti-mode
|
||
"Olivetti provides a nice writing environment.
|
||
Window margins are set to relative widths to accomodate a text
|
||
body width set with `olivetti-body-width'."
|
||
:init-value nil
|
||
:lighter olivetti-lighter
|
||
(if olivetti-mode
|
||
(progn
|
||
(cond ((<= emacs-major-version 24)
|
||
(add-hook 'window-configuration-change-hook
|
||
#'olivetti-set-buffer-windows t t))
|
||
((<= emacs-major-version 26)
|
||
(add-hook 'window-configuration-change-hook
|
||
#'olivetti-set-buffer-windows t t)
|
||
(add-hook 'window-size-change-functions
|
||
#'olivetti-set-window t t))
|
||
((<= 27 emacs-major-version)
|
||
(add-hook 'window-size-change-functions
|
||
#'olivetti-set-window t t)))
|
||
(add-hook 'change-major-mode-hook
|
||
#'olivetti-reset-all-windows nil t)
|
||
(add-hook 'text-scale-mode-hook
|
||
#'olivetti-set-buffer-windows t t)
|
||
(unless (bound-and-true-p olivetti--visual-line-mode)
|
||
(setq olivetti--visual-line-mode
|
||
visual-line-mode))
|
||
(unless (bound-and-true-p olivetti--split-window-preferred-function)
|
||
(setq olivetti--split-window-preferred-function
|
||
split-window-preferred-function))
|
||
(setq-local split-window-preferred-function
|
||
#'olivetti-split-window-sensibly)
|
||
(setq olivetti--face-remap
|
||
(face-remap-add-relative 'fringe 'olivetti-fringe))
|
||
(olivetti-set-buffer-windows))
|
||
(remove-hook 'window-configuration-change-hook
|
||
#'olivetti-set-buffer-windows t)
|
||
(remove-hook 'window-size-change-functions
|
||
#'olivetti-set-window t)
|
||
(remove-hook 'text-scale-mode-hook
|
||
#'olivetti-set-window t)
|
||
(olivetti-set-buffer-windows)
|
||
(set-window-margins nil left-margin-width right-margin-width)
|
||
(when olivetti--face-remap
|
||
(face-remap-remove-relative olivetti--face-remap))
|
||
(when olivetti-recall-visual-line-mode-entry-state
|
||
(if olivetti--visual-line-mode
|
||
(when (not visual-line-mode) (visual-line-mode 1))
|
||
(when visual-line-mode (visual-line-mode 0))))
|
||
(mapc #'kill-local-variable '(split-window-preferred-function
|
||
olivetti-body-width
|
||
olivetti--visual-line-mode
|
||
olivetti--face-remap
|
||
olivetti--split-window-preferred-function))))
|
||
|
||
|
||
|
||
(provide 'olivetti)
|
||
|
||
;;; olivetti.el ends here
|
||
|
||
;; Local Variables:
|
||
;; coding: utf-8
|
||
;; fill-column: 80
|
||
;; require-final-newline: t
|
||
;; sentence-end-double-space: nil
|
||
;; indent-tabs-mode: nil
|
||
;; End:
|