p11k/p11k.el

361 lines
12 KiB
EmacsLisp
Raw Normal View History

;;; p11k.el --- PowerLevel10k prompt for Eshell -*- lexical-binding: t; -*-
;; Copyright (C) 2023 Mitchell Marquez
;; Author: Mitchell Marquez <dr.m.perseos@gmail.com>
;; Keywords: terminals, convenience, faces
;; Package-Requires: ((dash) (all-the-icons) (eshell-prompt-extras) (f) (shrink-path))
;;; Commentary:
;; PowerLevel10k prompt theme for Eshell. Tries to emulate everything but may
;; be broken.
;;; Bugs:
;; - styles other than Lean not implemented
;; - right-aligned elements (time display) get pushed to new line when `word-wrap' t
;;; Code:
(require 'f)
(require 'dash)
(require 'eshell-prompt-extras)
(require 'shrink-path)
(defvar p11k/eol-marker "") ;; zero width non-joiner, 8204
(defgroup p11k nil
"PowerLevel11k Prompt for Eshell."
:group 'eshell
:prefix "p11k/")
(defcustom p11k/use-transient-prompt t
"Whether to enable `transient-prompt' for p11k."
:group 'p11k
:type 'boolean) ;; or nil to disable transient-prompt
(defcustom p11k/prompt-style 'lean
"Style of PowerLevel11k Eshell prompt."
:group 'p11k
:type '(choice
(const :tag "Lean" lean)
(const :tag "Classic" classic)
(const :tag "Rainbow" rainbow)
(const :tag "Pure" pure)))
(defcustom p11k/use-unicode nil
"Whether or not to use Unicode characters in PowerLevel11k Eshell prompt."
:group 'p11k
:type 'boolean)
(defcustom p11k/prompt-color-level 2
"Darkness of the prompt background for PowerLevel11k."
:group 'p11k
:type '(choice
(const :tag "Lightest" 1)
(const :tag "Light" 2)
(const :tag "Dark" 3)
(const :tag "darkest" 4)))
(defcustom p11k/show-current-time 12
"Format of time display, or nil for no time."
:group 'p11k
:type '(choice
(const :tag "No" nil)
(const :tag "24-hour format" 24)
(const :tag "12-hour format" 12)))
(defcustom p11k/prompt-segment-separator-type 'angled
"Type of separator between p11k prompt segments."
:group 'p11k
:type '(choice
(const :tag "Angled" angled)
(const :tag "Vertical" vertical)
(const :tag "Slanted" slanted)
(const :tag "Round" round))) ;; Only when unicode=t (?)
(defcustom p11k/prompt-segment-head-type 'slanted
"Type of head character to put on the inner ends of p11k prompt."
:group 'p11k
:type '(choice
(const :tag "Sharp" sharp)
(const :tag "Blurred" blurred)
(const :tag "Slanted" slanted)
(const :tag "Round" round))) ;; Only when unicode=t (?)
(defcustom p11k/prompt-segment-tail-type 'slanted
"Type of tail character to put on the outer ends of p11k prompt."
:group 'p11k
:type '(choice
(const :tag "Flat" flat)
(const :tag "Blurred" blurred)
(const :tag "Sharp" sharp)
(const :tag "Slanted" slanted)
(const :tag "Round" round))) ;; Only when unicode=t (?)
(defcustom p11k/prompt-height 2
"Whether to use one or two lines for p11k prompt."
:group 'p11k
:type '(choice
(const :tag "One line" 1)
(const :tag "Two Lines" 2)))
(defcustom p11k/prompt-segment-connection 'disconnected
"What style of connector, if any, to draw between p11k prompt segments."
:group 'p11k
:type '(choice
(const :tag "Disconnected" disconnected)
(const :tag "Dotted" dotted)
(const :tag "Solid" solid)))
(defcustom p11k/prompt-frame 'full
"Which side of the window to put a frame between p11k prompt segments."
:group 'p11k
:type '(choice
(const :tag "No frame" nil)
(const :tag "Left" left)
(const :tag "Right" right)
(const :tag "Full" full)))
(defcustom p11k/prompt-spacing 'compact
"How much distance to put between consecutive prompts and command outputs."
:group 'p11k
:type '(choice
(const :tag "Compact" compact)
(const :tag "Sparse" sparse)))
(defcustom p11k/use-icons nil
"Whether to use icons in the p11k prompt."
:group 'p11k
:type 'boolean) ;; or t for "many" icons
(defcustom p11k/prompt-flow 'concise
"Whether to put prepositions and adjectives around p11k segments."
:group 'p11k
:type '(choice
(const :tag "Concise (fewer words)" concise)
(const :tag "Fluent (more words)" fluent)))
(defvar p11k/prompt-caret (if p11k/use-unicode
"" ">"))
(defvar p11k/prompt-caret-alist
(if p11k/use-unicode
'((insert . "")
(normal . "")
(visual . "") ;from nerd-icons
)
'((insert . ">")
(normal . "<")
(visual . "V"))))
(defvar p11k/unwritable-icon (if p11k/use-unicode
"" "!w"))
(defvar p11k/last-prompt-bound 0) ;; internal variable
(defface p11k/green '((t (:foreground "green")))
"Face for \"green\" parts of p11k prompt and git status segment.")
(defface p11k/yellow '((t (:foreground "yellow")))
"Face for \"yellow\" parts of p11k prompt and git status segment.")
(defface p11k/blue '((t (:foreground "blue")))
"Face for \"blue\" parts of p11k prompt and git status segment.")
(defface p11k/red '((t (:foreground "red")))
"Face for \"red\" parts of p11k prompt and git status segment.")
(defface p11k/cyan '((t (:foreground "cyan")))
"Face for \"cyan\" parts of p11k prompt.")
(defun p11k/setup-git-status ()
"Declare variables and faces for git info in p11k prompt."
(face-spec-set 'gitstatus-clean-face '((t (:inherit 'p11k/green))))
(face-spec-set 'gitstatus-modified-face '((t (:inherit 'p11k/yellow))))
(face-spec-set 'gitstatus-untracked-face '((t (:inherit 'p11k/blue)))))
(defun p11k/spaced-concat (&rest strings)
(string-trim-left
(mapconcat
(lambda (str) (format " %s" str))
strings)))
;; (p11k/spaced-concat "one" "two" (propertize "five" 'face 'p11k/yellow))
(defun p11k/format-directory (dir &optional maxwidth)
"Format DIR for use in p11k Eshell prompt. Returneth a string.
if MAXWIDTH is non-nil, abbreviate."
(let* ((writable-symbol (if (file-writable-p dir) "" p11k/unwritable-icon))
(dir (string-replace (getenv "HOME") "~" dir)))
(p11k/spaced-concat
writable-symbol
(if (or maxwidth (>= (* 2 (length dir)) (window-text-width)))
(shrink-path-dirs dir) dir))))
(defun p11k/caret-color-status (status)
"Return the propertized `p11k/prompt-caret', according to STATUS."
(let ((facecolor (if (zerop status) 'p11k/green 'p11k/red)))
(propertize (format "%s" (alist-get (p11k/get-modal-status) p11k/prompt-caret-alist))
'face facecolor)))
(defun p11k/get-modal-status ()
(if (bound-and-true-p meow--current-state)
meow--current-state
evil-state))
(defvar p11k/sep-str-alist
'((angled . ("" . ""))
(sharp . ("" . ""))
(vertical . ("" . ""))
(flat . ("" . ""))
(slanted . ("" . ""))
(round . ("" . ""))
(blurred . ("▓▒░" . "░▒▓")))
"Alist of separator names and their canonical string appearances.
Each string appearance is a cons cell corresponding with the left-handed form as
the car and right-handed form as the cdr.")
(defun p11k/get-sep-char (sym &optional direction)
"Given symbol SYM name of a prompt style, return matching string.
Corresponds with DIRECTION in only a specific idealized context. If DIRECTION
is omitted, just return the cons of bolth."
(let ((symcons (alist-get sym p11k/sep-str-alist))
(getter (if (eq direction 'left) #'car #'cdr)))
(if direction
(funcall getter symcons)
symcons)))
(defun p11k/status-formatter (timestamp duration)
"Return status display for `epe-status'. Replaceth `epe-status-formatter'.
TIMESTAMP is the value returned by `current-time' and DURATION is the floating
time the command took to complete in seconds.
Outputteth a string."
;; TIMESTAMP is irrelevant for us
(let ((durstring (format "%.3fs" duration)))
(p11k/spaced-concat
(if (eq p11k/prompt-flow 'fluent) "took" "")
(propertize durstring 'face 'p11k/yellow))))
(defun p11k/justificate (left-items right-items)
"Concatenate LEFT-ITEMS and RIGHT-ITEMS with a gap between.
Use 'window-width' to calculate the total final width."
(let* ((left-str (eval `(p11k/spaced-concat ,@left-items)))
(right-str (eval `(p11k/spaced-concat ,@right-items)))
(left-length (length left-str))
(right-length (length right-str))
(middle-spc (- (window-width nil 'remap) (+ left-length right-length))))
(concat left-str (make-string middle-spc (string-to-char " ")) right-str)))
;; (eval `(concat ,@'("one" "two")))
;; (p11k/justificate '("one" "wto" "three") '("twenty" "23" "forthy-three"))
(defun p11k/git-status ()
"Return propertized git information."
(if (file-exists-p ".git")
(let* ((status-alist
(--> "git status -s"
(shell-command-to-string it)
(split-string it "\n" 'omit-nulls search-whitespace-regexp)
(--map (let ((parts (split-string it)))
(cons (car parts) (cadr parts)))
it)))
(branch (--> "git branch"
(shell-command-to-string it)
(split-string it "\n" 'omit-nulls search-whitespace-regexp)
(--filter (string-match-p (rx bol "* " (+ word)) it) it)
(split-string (car it))
(cadr it)))
(modcount (--> status-alist
(--map (car it) it)
(--count (string-match-p "M" it) it)
(if (not (= it 0))
(format "!%s" it)
nil)))
(untracked (--> status-alist
(--map (car it) it)
(--count (string-match-p "\\?\\?" it) it)
(if (not (= it 0))
(format "?%s" it)
nil))))
(p11k/spaced-concat
(if (eq p11k/prompt-flow 'fluent) "on" "")
(propertize branch 'face 'p11k/green)
(if modcount (propertize modcount 'face 'p11k/yellow) "")
(if untracked (propertize untracked 'face 'p11k/blue) "")))
""))
(defun p11k/prompt ()
"Return the propertized face for p11k Eshell prompt."
(let ((sparsity (if (eq p11k/prompt-spacing 'sparse) "\n" ""))
(directory (propertize (format "%s" (p11k/format-directory default-directory))
'face 'p11k/blue))
(timedisp
(if p11k/show-current-time
(p11k/spaced-concat
(if (eq p11k/prompt-style 'fluent) "at" "")
(propertize (pcase p11k/show-current-time
(12 (format-time-string "%I:%M:%S %p"))
(24 (format-time-string "%H:%M:%S"))
(t "[bad time type]"))
'face 'p11k/cyan))
""))
(status-str (epe-status #'p11k/status-formatter)))
(p11k/justificate
`(,sparsity
,directory
,(p11k/git-status))
;; justificate after this
`(,(concat p11k/eol-marker
(p11k/spaced-concat
status-str
timedisp)
"\n"
(p11k/caret-color-status eshell-last-command-status)
" ")))))
(defun p11k/modal-caret (&optional throwaway)
"Toggle the direction that the prompt points according to `p11k/get-modal-status'."
(save-match-data
(save-mark-and-excursion
(let ((inhibit-read-only t)
(new-caret
(p11k/caret-color-status
eshell-last-command-status)))
(goto-char (point-max))
(re-search-backward eshell-prompt-regexp)
(goto-char (point-at-bol))
(delete-forward-char 1)
(insert new-caret))
)))
(defun p11k/esh-transient-prompt ()
"Delete the first line of a multi-line eshell prompt.
To be run before every eshell command."
(if p11k/use-transient-prompt
(save-match-data
(save-excursion
(setq p11k/last-prompt-bound
(or (ignore-errors (search-backward p11k/eol-marker nil t))
(point-min)))
(let ((inhibit-read-only t))
(delete-line))))))
(defun p11k/disable ()
"Turn off p11k and restore the old prompt."
(remove-hook 'eshell-pre-command-hook #'p11k/esh-transient-prompt)
(remove-hook 'eshell-mode-hook #'p11k/setup-git-status)
(setq-default eshell-prompt-function p11k/old-eshell-prompt-function)
(setq-default eshell-prompt-regexp p11k/old-eshell-prompt-regexp))
;;;###autoload
(progn
(defun p11k/init ()
"Run setup functions for p11k."
(require 'p11k)
(if p11k/use-transient-prompt
(add-hook 'eshell-pre-command-hook #'p11k/esh-transient-prompt))
(p11k/setup-git-status)
(defvar p11k/old-eshell-prompt-function eshell-prompt-function)
(defvar p11k/old-eshell-prompt-regexp eshell-prompt-regexp)
(setq eshell-prompt-function #'p11k/prompt)
(if (featurep 'meow)
(add-hook 'meow-switch-state-hook #'p11k/modal-caret nil 'local)
(if (featurep 'evil)
(progn
(add-hook 'evil-insert-state-entry-hook #'p11k/modal-caret nil 'local)
(add-hook 'evil-normal-state-entry-hook #'p11k/modal-caret nil 'local)
(add-hook 'evil-visual-state-entry-hook #'p11k/modal-caret nil 'local))))
(setq eshell-prompt-regexp
(rx bol
(regexp (eval `(rx ,(cons 'or (mapcar #'cdr p11k/prompt-caret-alist)))))
" ")))
(define-minor-mode p11k-mode
"PowerLevel11k Prompt for Eshell."
:lighter " p11k"
:require 'p11k
(p11k/init)))
(provide 'p11k)
;;; p11k.el ends here