;;; p11k.el --- PowerLevel10k prompt for Eshell -*- lexical-binding: t; -*- ;; Copyright (C) 2023 Mitchell Marquez ;; Author: Mitchell Marquez ;; 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) (if it (--map (let ((parts (split-string it))) (cons (car parts) (cadr parts))) it) nil))) (branch (--> "git branch" (shell-command-to-string it) (split-string it "\n" 'omit-nulls search-whitespace-regexp) (if it (progn (--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