Lightweight Writing & Coding Emacs Configuration

Table of Contents

1. Introduction

A lightweight Emacs configuration focused on writing with enough coding support for editing code embedded in documents. This is a separate, lighter alternative to the full modular configuration.

1.1. What This Config Provides

  • Distraction-free writing with Olivetti, flyspell, and word count
  • Org-mode with org-modern styling and full org-babel language support
  • Markdown editing with GFM and TOC generation
  • Workspace management via tab-bar (Doom-like M-1 to M-9 switching)
  • Project management with built-in project.el
  • Tree-sitter syntax highlighting for all major languages
  • Eglot LSP for code intelligence
  • Magit for Git integration
  • 8 external packages, everything else built-in

1.2. How to Use

1.2.1. Tangle (extract configuration files)

Open this file in Emacs and run:

C-c C-v t

This creates three files:

  • ~/.emacs-writing.d/early-init.el
  • ~/.emacs-writing.d/init.el
  • ~/.emacs-writing.d/themes/hasliberg-theme.el

1.2.2. Launch

emacs --init-directory ~/.emacs-writing.d/

Or create an alias:

alias ew='emacs --init-directory ~/.emacs-writing.d/'

On first launch, Emacs will automatically install the 8 external packages.

2. Early Initialization

The early-init.el file loads before the package system and GUI, making it the right place for performance and visual settings.

;;; early-init.el --- Writing Config Early Init  -*- lexical-binding: t; -*-
;;; Commentary:
;; Early init for the lightweight writing & coding configuration.
;;; Code:

;; Native compilation settings
(when (and (fboundp 'native-comp-available-p)
           (native-comp-available-p))
  (setq native-comp-async-report-warnings-errors nil
        native-comp-deferred-compilation t))

;; Startup performance: disable GC during init
(setq gc-cons-threshold most-positive-fixnum
      gc-cons-percentage 0.6
      vc-handled-backends '(Git))

;; Prevent visual flash
(defun fu/avoid-initial-flash-of-light ()
  "Avoid flash of light when starting Emacs."
  (setq mode-line-format nil))
(fu/avoid-initial-flash-of-light)

;; Frame configuration
(setq frame-resize-pixelwise t
      frame-inhibit-implied-resize t
      frame-title-format '("Emacs Writing")
      inhibit-compacting-font-caches t)

;; Disable unused UI elements early
(menu-bar-mode 0)
(scroll-bar-mode 0)
(tool-bar-mode 0)
(tooltip-mode 0)

(provide 'early-init)
;;; early-init.el ends here

3. Core Settings

Package management, sensible defaults, and UTF-8 everywhere.

;;; init.el --- Writing Config Init  -*- lexical-binding: t; -*-
;;; Commentary:
;; Lightweight writing & coding configuration.
;;; Code:

(use-package emacs
  :ensure nil
  :bind
  (("M-o"     . other-window)
   ("C-x C-r" . recentf-open-files)
   ("C-x k"   . (lambda () "Kill current buffer." (interactive)
                   (kill-buffer (buffer-name))))
   ("RET"     . newline-and-indent)
   ("C-z"     . nil)
   ("C-x C-z" . nil))

  :custom
  ;; Cursor and display
  (cursor-type '(bar . 3))
  (inhibit-startup-message t)
  (initial-scratch-message nil)
  (initial-major-mode 'org-mode)
  (visible-bell nil)
  (ring-bell-function 'ignore)
  (use-dialog-box nil)
  (use-file-dialog nil)
  (use-short-answers t)

  ;; Editing behavior
  (delete-selection-mode 1)
  (tab-always-indent 'complete)
  (tab-width 4)
  (create-lockfiles nil)
  (make-backup-files nil)
  (backup-inhibited t)

  ;; Scrolling
  (scroll-margin 0)
  (scroll-step 1)
  (scroll-conservatively 101)
  (pixel-scroll-precision-mode t)
  (pixel-scroll-precision-use-momentum nil)

  ;; Window behavior
  (split-width-threshold 170)
  (split-height-threshold nil)
  (window-combination-resize t)
  (help-window-select t)
  (switch-to-buffer-obey-display-actions t)

  ;; File handling
  (delete-by-moving-to-trash t)
  (global-auto-revert-non-file-buffers t)
  (auto-revert-verbose nil)

  ;; History and recent files
  (history-length 300)
  (recentf-max-saved-items 300)
  (recentf-max-menu-items 15)
  (savehist-save-minibuffer-history t)
  (savehist-additional-variables
   '(kill-ring register-alist mark-ring global-mark-ring
     search-ring regexp-search-ring))
  (save-place-file (expand-file-name "saveplace" user-emacs-directory))
  (save-place-limit 600)

  ;; Display
  (display-line-numbers-width 6)
  (display-line-numbers-widen t)
  (truncate-lines t)
  (treesit-font-lock-level 4)

  ;; UTF-8 everywhere
  (locale-coding-system 'utf-8)
  (keyboard-coding-system 'utf-8)
  (savehist-coding-system 'utf-8)
  (file-name-coding-system 'utf-8)
  (buffer-file-coding-system 'utf-8)

  ;; Search
  (completion-ignore-case t)
  (completions-detailed t)
  (completions-format 'one-column)

  :init
  (add-to-list 'default-frame-alist '(alpha-background . 100))
  (global-auto-revert-mode 1)
  (global-goto-address-mode 1)
  (indent-tabs-mode nil)
  (recentf-mode 1)
  (repeat-mode 1)
  (savehist-mode 1)
  (save-place-mode 1)
  (winner-mode)
  (file-name-shadow-mode 1)
  (prefer-coding-system 'utf-8)
  (put 'narrow-to-region 'disabled nil)
  (message (emacs-init-time))

  :config
  (modify-coding-system-alist 'file "" 'utf-8)
  (setq custom-file (locate-user-emacs-file "custom-vars.el"))
  (load custom-file 'noerror 'nomessage)

  ;; Line numbers in prog-mode
  (setq display-line-numbers-type 'absolute)
  (add-hook 'prog-mode-hook #'display-line-numbers-mode)

  ;; Visual line mode for text modes
  (add-hook 'text-mode-hook #'visual-line-mode)

  ;; Display table for terminal
  (set-display-table-slot standard-display-table 'vertical-border ?\u2502)
  (set-display-table-slot standard-display-table 'truncation ?\u2192)

  ;; Reset GC after startup
  (add-hook 'emacs-startup-hook
            (lambda ()
              (setq gc-cons-threshold (* 16 1024 1024)
                    gc-cons-percentage 0.1)))

  ;; Load private config if exists
  (add-hook 'after-init-hook
            (lambda ()
              (let ((private-file (expand-file-name "private.el" user-emacs-directory)))
                (when (file-exists-p private-file)
                  (load private-file)))))

  ;; Package archives
  (use-package use-package
    :custom
    (package-archives '(("melpa"  . "https://melpa.org/packages/")
                        ("elpa"   . "https://elpa.gnu.org/packages/")
                        ("nongnu" . "https://elpa.nongnu.org/nongnu/")))
    (use-package-always-ensure t)
    (use-package-enable-at-startup nil)
    (package-native-compile t)
    (warning-minimum-level 'error)))

4. Theme

The Hasliberg theme provides a serene, dark color scheme inspired by the Swiss alps. It supports both dark and light variants with LuvLCh-based color management.

4.1. Theme Loading

;; Load Hasliberg theme
(let ((theme-file (expand-file-name "themes/hasliberg-theme.el" user-emacs-directory)))
  (if (file-exists-p theme-file)
      (progn
        (load-file theme-file)
        (load-theme 'hasliberg t))))

4.2. Hasliberg Theme Source

;;; hasliberg-theme.el --- Serene theme inspired by Swiss alps. -*- lexical-binding:t -*-

;; Copyright (C) 2024  Free Software Foundation, Inc.

;; Author: Ryota Sawada <rytswd@gmail.com>
;; Maintainer: Ryota Sawada <rytswd@gmail.com>
;; URL: https://github.com/rytswd/hasliberg-theme
;; Keywords: theme
;; Version: 0.1

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

;; The theme is only meant to provide my own prefered colour setup.

;;; Acknowledgments:

;; A lot of configurations were inspired by ef-themes by Prot.

;;; Code:

;;;###theme-autoload
(deftheme hasliberg
  "Theme inspired by Swiss alps"
  :background-mode 'dark
  :kind 'color-scheme)

;;;;----------------------------------------
;;;   Configuration
;;------------------------------------------
(defgroup hasliberg-theme nil
  "Options for hasliberg-theme."
  :group 'hasliberg-theme
  :prefix "hasliberg-theme-")
(defconst hasliberg-theme-lch-type
  '(plist :options ((:luminance float)
                    (:chroma float)
                    (:hue float)))
  "A plist defining LuvLCh input.")
(defun hasliberg-theme--validate-and-set-lch (symbol value)
  "Set SYMBOL to VALUE if it is a valid LCH colour.
VALUE must be a plist containing :luminance, :chroma, and :hue with float values.
Luminance should be between 0 and 100, chroma should be non-negative, and hue should be between 0 and 360."
  (let ((luminance (plist-get value :luminance))
        (chroma (plist-get value :chroma))
        (hue (plist-get value :hue))
        (errors '()))
    (unless (and luminance chroma hue)
      (push "LCH value must include :luminance, :chroma, and :hue" errors))
    (unless (and (floatp luminance) (floatp chroma) (floatp hue))
      (push (format "LCH components must be float values: %S" value) errors))
    (when (and luminance (or (< luminance 0) (> luminance 100)))
      (push (format "Luminance value %f must be between 0 and 100" luminance) errors))
    (when (and chroma (or (< chroma 0) (> chroma 220)))
      (push (format "Chroma value %f must be non-negative" chroma) errors))
    (when (and hue (or (< hue 0) (>= hue 360)))
      (push (format "Hue value %f must be between 0 and 360" hue) errors))
    (if errors
        (error "Invalid LuvLCh value: %s" (string-join (reverse errors) "; "))
      (set-default symbol value))))
(defcustom hasliberg-theme-dark-or-light 'dark
  "The theme variant, either `dark` or `light`."
  :type '(choice (const :tag "Dark" dark)
                 (const :tag "Light" light))
  :group 'hasliberg-theme
  :set (lambda (symbol value)
         (set-default symbol value)
         (hasliberg-theme--update))
  :initialize 'custom-initialize-default)

(defcustom hasliberg-theme-colour-background
  '(:luminance 17.877  :chroma  1.800  :hue 236.421)    ;; #2A2C2E
  "The background colour, in LuvLCh values."
  :type hasliberg-theme-lch-type
  :set 'hasliberg-theme--validate-and-set-lch
  :group 'hasliberg-theme)
(defcustom hasliberg-theme-colour-background-variant
  '(:luminance  6.265  :chroma 11.827  :hue 252.428)    ;; #00142D
  "Another background colour with slight variation, in LuvLCh values."
  :type hasliberg-theme-lch-type
  :set 'hasliberg-theme--validate-and-set-lch
  :group 'hasliberg-theme)
(defcustom hasliberg-theme-colour-neutral
  '(:luminance 95.074  :chroma 12.330  :hue 252.652)    ;; #ECF1FF
  "The neutral / default font colour, in LuvLCh values."
  :type hasliberg-theme-lch-type
  :set 'hasliberg-theme--validate-and-set-lch
  :group 'hasliberg-theme)
(defcustom hasliberg-theme-colour-primary
  '(:luminance 80.335  :chroma 40.438  :hue 241.234)    ;; #A7CBF1
  "The primary font colour, in LuvLCh values."
  :type hasliberg-theme-lch-type
  :set 'hasliberg-theme--validate-and-set-lch
  :group 'hasliberg-theme)
(defcustom hasliberg-theme-colour-secondary
  '(:luminance 63.743  :chroma 48.347  :hue 251.617)    ;; #809BCE
  "The secondary font colour, in LuvLCh values."
  :type hasliberg-theme-lch-type
  :set 'hasliberg-theme--validate-and-set-lch
  :group 'hasliberg-theme)
(defcustom hasliberg-theme-colour-accent
  '(:luminance 77.610  :chroma 86.648  :hue  47.245)    ;; #FBB151
  "The accent font colour, used sparingly for call-to-action, etc., in LuvLCh values."
  :type hasliberg-theme-lch-type
  :set 'hasliberg-theme--validate-and-set-lch
  :group 'hasliberg-theme)
(defcustom hasliberg-theme-colour-accent-variant
  '(:luminance 67.236  :chroma 86.052  :hue 335.603)    ;; #FB74C3
  "Another accent font colour with slight variation, used sparingly for call-to-action, etc., in LuvLCh values."
  :type hasliberg-theme-lch-type
  :set 'hasliberg-theme--validate-and-set-lch
  :group 'hasliberg-theme)
(defcustom hasliberg-theme-colour-subtle
  '(:luminance 77.751  :chroma 14.617  :hue 235.776)    ;; #B4C2CF
  "The subtle font colour to slightly mix up, in LuvLCh values."
  :type hasliberg-theme-lch-type
  :set 'hasliberg-theme--validate-and-set-lch
  :group 'hasliberg-theme)
(defcustom hasliberg-theme-colour-subtle-variant
  '(:luminance 73.823  :chroma 11.749  :hue 180.830)    ;; #A4BAB7
  "Another subtle font colour with slight variation to mix up even more, in LuvLCh values."
  :type hasliberg-theme-lch-type
  :set 'hasliberg-theme--validate-and-set-lch
  :group 'hasliberg-theme)
(defcustom hasliberg-theme-colour-info
  '(:luminance 71.365 :chroma 21.506 :hue 257.597)      ;; #A8AEC7
  "The info font colour, in LuvLCh values."
  :type hasliberg-theme-lch-type
  :set 'hasliberg-theme--validate-and-set-lch
  :group 'hasliberg-theme)
(defcustom hasliberg-theme-colour-warning
  '(:luminance 67.236  :chroma 86.052  :hue 335.603)    ;; #FB74C3
  "The warning font colour, in LuvLCh values."
  :type hasliberg-theme-lch-type
  :set 'hasliberg-theme--validate-and-set-lch
  :group 'hasliberg-theme)
(defun hasliberg-theme-use-dark-standard-colour-palette ()
  "Use dark standard colour palette for Hasliberg Theme setup."
  (interactive)
  (setopt
   hasliberg-theme-dark-or-light 'dark
   hasliberg-theme-colour-background '(:luminance 17.877  :chroma  1.800  :hue 236.421)
   hasliberg-theme-colour-background-variant '(:luminance  6.265  :chroma 11.827  :hue 252.428)
   hasliberg-theme-colour-neutral '(:luminance 95.074  :chroma 12.330  :hue 252.652)
   hasliberg-theme-colour-primary '(:luminance 80.335  :chroma 40.438  :hue 241.234)
   hasliberg-theme-colour-secondary '(:luminance 63.743  :chroma 48.347  :hue 251.617)
   hasliberg-theme-colour-accent '(:luminance 77.610  :chroma 86.648  :hue  47.245)
   hasliberg-theme-colour-accent-variant '(:luminance 67.236  :chroma 86.052  :hue 335.603)
   hasliberg-theme-colour-subtle '(:luminance 77.751  :chroma 14.617  :hue 235.776)
   hasliberg-theme-colour-subtle-variant '(:luminance 73.823  :chroma 11.749  :hue 180.830)
   hasliberg-theme-colour-info '(:luminance 62.814  :chroma 70.124  :hue 123.247)
   hasliberg-theme-colour-warning '(:luminance 67.236  :chroma 86.052  :hue 335.603))
  (hasliberg-theme--update))
(defun hasliberg-theme-use-dark-orange-colour-palette ()
  "Use dark orange colour palette for Hasliberg Theme setup."
  (interactive)
  (setopt
   hasliberg-theme-dark-or-light 'dark
   hasliberg-theme-colour-background '(:luminance 17.877  :chroma  1.800  :hue 236.421)
   hasliberg-theme-colour-background-variant '(:luminance  6.265  :chroma 11.827  :hue 252.428)
   hasliberg-theme-colour-neutral '(:luminance 95.074  :chroma 12.330  :hue 252.652)
   hasliberg-theme-colour-primary '(:luminance 80.335  :chroma 40.438  :hue  41.234)
   hasliberg-theme-colour-secondary '(:luminance 63.743  :chroma 48.347  :hue  51.617)
   hasliberg-theme-colour-accent '(:luminance 77.610  :chroma 86.648  :hue  47.245)
   hasliberg-theme-colour-accent-variant '(:luminance 67.236  :chroma 86.052  :hue  35.603)
   hasliberg-theme-colour-subtle '(:luminance 77.751  :chroma 14.617  :hue  35.776)
   hasliberg-theme-colour-subtle-variant '(:luminance 73.823  :chroma 11.749  :hue  80.830)
   hasliberg-theme-colour-info '(:luminance 62.814  :chroma 70.124  :hue  23.247)
   hasliberg-theme-colour-warning '(:luminance 67.236  :chroma 86.052  :hue  35.603))
  (hasliberg-theme--update))
(defun hasliberg-theme-use-dark-monotonic-colour-palette ()
  "Use dark monotonic colour palette for Hasliberg Theme setup."
  (interactive)
  (setopt
   hasliberg-theme-dark-or-light 'dark
   hasliberg-theme-colour-background '(:luminance 17.877  :chroma  1.800  :hue 236.421)
   hasliberg-theme-colour-background-variant '(:luminance  6.265  :chroma 11.827  :hue 252.428)
   hasliberg-theme-colour-neutral '(:luminance 95.074  :chroma 12.330  :hue 252.652)
   hasliberg-theme-colour-primary '(:luminance 93.380 :chroma 2.848 :hue 192.490)
   hasliberg-theme-colour-secondary '(:luminance 93.535 :chroma 3.844 :hue 169.497)
   hasliberg-theme-colour-accent '(:luminance 76.373 :chroma 32.215 :hue 238.249)
   hasliberg-theme-colour-accent-variant '(:luminance 76.373 :chroma 32.215 :hue 238.249)
   hasliberg-theme-colour-subtle '(:luminance 77.751  :chroma 14.617  :hue  35.776)
   hasliberg-theme-colour-subtle-variant '(:luminance 73.823  :chroma 11.749  :hue  80.830)
   hasliberg-theme-colour-info '(:luminance 67.245 :chroma 10.890 :hue 19.862)
   hasliberg-theme-colour-warning '(:luminance 67.236  :chroma 86.052  :hue  35.603))
  (hasliberg-theme--update))
(defun hasliberg-theme-use-dark-nature-colour-palette ()
  "Use dark nature colour palette for Hasliberg Theme setup."
  (interactive)
  (setopt
   hasliberg-theme-dark-or-light 'dark
   hasliberg-theme-colour-background '(:luminance 17.736 :chroma 13.978 :hue 146.889)
   hasliberg-theme-colour-background-variant '(:luminance 25.925 :chroma 49.830 :hue 153.821)
   hasliberg-theme-colour-neutral '(:luminance 95.146 :chroma 12.143 :hue 253.229)
   hasliberg-theme-colour-primary '(:luminance 91.651 :chroma 82.416 :hue 128.406)
   hasliberg-theme-colour-secondary '(:luminance 84.833 :chroma 52.641 :hue 148.810)
   hasliberg-theme-colour-accent '(:luminance 75.501 :chroma 82.984 :hue 110.610)
   hasliberg-theme-colour-accent-variant '(:luminance 56.134 :chroma 27.575 :hue 241.601)
   hasliberg-theme-colour-subtle '(:luminance 95.771 :chroma 17.004 :hue 91.625)
   hasliberg-theme-colour-subtle-variant '(:luminance 73.828 :chroma 11.880 :hue 80.278)
   hasliberg-theme-colour-info '(:luminance 36.998 :chroma 22.023 :hue 101.245)
   hasliberg-theme-colour-warning '(:luminance 80.484 :chroma 80.188 :hue 67.991))
  (hasliberg-theme--update))
(defun hasliberg-theme-use-light-standard-colour-palette ()
  "Use light standard colour palette for Hasliberg Theme setup."
  (interactive)
  (setopt
   hasliberg-theme-dark-or-light 'light
   hasliberg-theme-colour-background '(:luminance 95.074  :chroma 12.330  :hue 252.652)
   hasliberg-theme-colour-background-variant '(:luminance 90.425  :chroma  3.975  :hue 192.174)
   hasliberg-theme-colour-neutral '(:luminance 17.877  :chroma  1.800  :hue 236.421)
   hasliberg-theme-colour-primary '(:luminance 30.335  :chroma 40.438  :hue 241.234)
   hasliberg-theme-colour-secondary '(:luminance 13.743  :chroma 48.347  :hue 251.617)
   hasliberg-theme-colour-accent '(:luminance 52.814  :chroma 70.124  :hue  23.247)
   hasliberg-theme-colour-accent-variant '(:luminance 17.236  :chroma 86.052  :hue 335.603)
   hasliberg-theme-colour-subtle '(:luminance 27.751  :chroma 14.617  :hue 235.776)
   hasliberg-theme-colour-subtle-variant '(:luminance 23.823  :chroma 11.749  :hue 180.830)
   hasliberg-theme-colour-info '(:luminance 12.814  :chroma 70.124  :hue 123.247)
   hasliberg-theme-colour-warning '(:luminance 17.236  :chroma 86.052  :hue 335.603))
  (hasliberg-theme--update))

(defun hasliberg-theme--update ()
  "Based on the base colour input, update the shades, faces, and then reload."
  (hasliberg-theme--update-shades)
  (hasliberg-theme--update-all-faces)
  (load-theme 'hasliberg t))
(defvar hasliberg-theme--load-path nil
  "Variable to store the load path of Hasliberg Theme.")
(unless hasliberg-theme--load-path
  (setq hasliberg-theme--load-path load-file-name))

(defun hasliberg-theme-reload ()
  "Re-evaluate the file and reload the config."
  (interactive)
  (load-file hasliberg-theme--load-path)
  (load-theme 'hasliberg t))

(defun hasliberg-theme--lch-to-luv (lch)
  "Convert a colour from LCH to Luv.
LCH is a plist with properties :luminance, :chroma, and :hue."
  (let* ((L (plist-get lch :luminance))
         (C (plist-get lch :chroma))
         (H-degree (plist-get lch :hue))
         (H-radians (* pi (/ H-degree 180.0))))
    (list :l L
          :u (* C (cos H-radians))
          :v (* C (sin H-radians)))))
(defun hasliberg-theme--luv-to-xyz (luv)
  "Convert a colour from Luv to XYZ.
Luv is a plist with properties :l, :u and :v."
  (let* ((L (plist-get luv :l))
         (u (plist-get luv :u))
         (v (plist-get luv :v))
         (ref-u 0.19783000664283)
         (ref-v 0.46831999493879)
         (up (/ (+ u (* 13 L ref-u)) (* 13 L)))
         (vp (/ (+ v (* 13 L ref-v)) (* 13 L)))
         (Y (if (> L 7.9996)
                (expt (/ (+ L 16) 116.0) 3)
              (/ L 903.3)))
         (X (if (zerop vp) 0
              (/ (* 9 Y up) (* 4 vp))))
         (Z (if (zerop vp) 0
              (/ (* (- 12 (* 3 up) (* 20 vp)) Y) (* 4 vp)))))
    (list :x (* 100 X) :y (* 100 Y) :z (* 100 Z))))
(defun hasliberg-theme--xyz-to-rgb (xyz)
  "Convert a colour from XYZ to RGB.
XYZ is a plist with properties :x, :y, and :z."
  (let* ((X (/ (plist-get xyz :x) 100.0))
         (Y (/ (plist-get xyz :y) 100.0))
         (Z (/ (plist-get xyz :z) 100.0))
         (R-linear (+ (* X 3.2406) (* Y -1.5372) (* Z -0.4986)))
         (G-linear (+ (* X -0.9689) (* Y 1.8758) (* Z 0.0415)))
         (B-linear (+ (* X 0.0557) (* Y -0.2040) (* Z 1.0570)))
         (R (if (<= R-linear 0.0031308)
                (* 12.92 R-linear)
              (- (* 1.055 (expt R-linear (/ 1.0 2.4))) 0.055)))
         (G (if (<= G-linear 0.0031308)
                (* 12.92 G-linear)
              (- (* 1.055 (expt G-linear (/ 1.0 2.4))) 0.055)))
         (B (if (<= B-linear 0.0031308)
                (* 12.92 B-linear)
              (- (* 1.055 (expt B-linear (/ 1.0 2.4))) 0.055))))
    (list :r (min (max R 0.0) 1.0)
          :g (min (max G 0.0) 1.0)
          :b (min (max B 0.0) 1.0))))
(defun hasliberg-theme--rgb-to-hex (rgb)
  "Convert a colour from RGB to Hex.
RGB is a plist with properties :r, :g, and :b, where each value is in the range [0, 1]."
  (let* ((r (round (* (plist-get rgb :r) 255)))
         (g (round (* (plist-get rgb :g) 255)))
         (b (round (* (plist-get rgb :b) 255))))
    (format "#%02X%02X%02X" r g b)))
(defun hasliberg-theme--lch-to-rgb (lch)
  "Convert a colour from LCH to RGB in Hex.
LCH is a plist with properties :luminance, :chroma, and :hue."
  (let* ((luv (hasliberg-theme--lch-to-luv lch))
         (xyz (hasliberg-theme--luv-to-xyz luv))
         (rgb (hasliberg-theme--xyz-to-rgb xyz)))
    (hasliberg-theme--rgb-to-hex rgb)))
(defun hasliberg-theme--hex-to-rgb (hex)
  "Convert a colour from Hex to RGB.
HEX can be a string in the form \"#RRGGBB\", \"RRGGBB\", \"#RGB\", or \"RGB\"."
  (let* ((normalized-hex (if (eq (aref hex 0) ?#)
                             (substring hex 1)
                           hex))
         (expanded-hex (if (= (length normalized-hex) 3)
                           (apply 'concat (mapcar (lambda (c) (make-string 2 c)) normalized-hex))
                         normalized-hex)))
    (when (not (= (length expanded-hex) 6))
      (error "Invalid hex colour format, expected 3 or 6 characters"))
    (list :r (/ (string-to-number (substring expanded-hex 0 2) 16) 255.0)
          :g (/ (string-to-number (substring expanded-hex 2 4) 16) 255.0)
          :b (/ (string-to-number (substring expanded-hex 4 6) 16) 255.0))))
(defun hasliberg-theme--rgb-to-xyz (rgb)
  "Convert a colour from RGB to XYZ.
RGB is a plist with properties :r, :g, and :b,
where each value is in the range [0, 1]."
  (let* ((linearize (lambda (c)
                      (if (<= c 0.04045)
                          (/ c 12.92)
                        (expt (/ (+ c 0.055) 1.055) 2.4))))
         (R-linear (funcall linearize (plist-get rgb :r)))
         (G-linear (funcall linearize (plist-get rgb :g)))
         (B-linear (funcall linearize (plist-get rgb :b))))
    (list :x (* 100 (+ (* R-linear 0.4124) (* G-linear 0.3576) (* B-linear 0.1805)))
          :y (* 100 (+ (* R-linear 0.2126) (* G-linear 0.7152) (* B-linear 0.0722)))
          :z (* 100 (+ (* R-linear 0.0193) (* G-linear 0.1192) (* B-linear 0.9505))))))
(defun hasliberg-theme--xyz-to-luv (xyz)
  "Convert a colour from XYZ to Luv.
XYZ is a plist with properties :x, :y, and :z."
  (let* ((X (/ (plist-get xyz :x) 100.0))
         (Y (/ (plist-get xyz :y) 100.0))
         (Z (/ (plist-get xyz :z) 100.0))
         (ref-X 0.95047)
         (ref-Y 1.00000)
         (ref-Z 1.08883)
         (ref-u (/ (* 4 ref-X) (+ ref-X (* 15 ref-Y) (* 3 ref-Z))))
         (ref-v (/ (* 9 ref-Y) (+ ref-X (* 15 ref-Y) (* 3 ref-Z))))
         (u (/ (* 4 X) (+ X (* 15 Y) (* 3 Z))))
         (v (/ (* 9 Y) (+ X (* 15 Y) (* 3 Z))))
         (L (if (> Y 0.008856)
                (- (* 116 (expt Y (/ 1.0 3))) 16)
              (* 903.3 Y)))
         (u-prime (* 13 L (- u ref-u)))
         (v-prime (* 13 L (- v ref-v))))
    (list :l L :u u-prime :v v-prime)))
(defun hasliberg-theme--luv-to-lch (luv)
  "Convert a colour from Luv to LCH.
LUV is a plist with properties :l, :u, and :v."
  (let* ((L (plist-get luv :l))
         (u (plist-get luv :u))
         (v (plist-get luv :v))
         (C (sqrt (+ (* u u) (* v v))))
         (H (atan v u))
         (H-degree (mod (/ (* H 180.0) pi) 360.0))
         (format-3dp (lambda (num)
                       (string-to-number (format "%.3f" num)))))
    (list :luminance (funcall format-3dp L)
          :chroma (funcall format-3dp C)
          :hue (funcall format-3dp H-degree))))
(defun hasliberg-theme--rgb-to-lch (hex)
  "Convert a colour from RGB Hex to LCH in Luv space.
HEX is a string in the form \"#RRGGBB\"."
  (let* ((rgb (hasliberg-theme--hex-to-rgb hex))
         (xyz (hasliberg-theme--rgb-to-xyz rgb))
         (luv (hasliberg-theme--xyz-to-luv xyz)))
    (hasliberg-theme--luv-to-lch luv)))

(defvar hasliberg-theme-shades nil
  "All shades for the Hasliberg theme colours based on LuvLCh input.
The values here are not meant to be updated manually.")
(defvar hasliberg-theme-shades-hash (make-hash-table :test 'equal)
  "Hash table of all Hasliberg theme shades for fast lookup.
The values here are not meant to be updated manually.")
(defun hasliberg-theme--generate-lch-shades (base-lch)
  "Generate a list of shades for a given LCH base colour. This takes in the
dark / light theme variable into account, and changes the way it generates
the shades. The higher values (e.g. 600, 700, so on) are meant to provide
more contrast and appear brighter based on the background.

In case of dark background, the higher values would result in brighter, more
white colours. In case of light background, they would result in darker, more
black colours."
  (let* ((l (plist-get base-lch :luminance))
         (c (plist-get base-lch :chroma))
         (h (plist-get base-lch :hue))
         (shades '(50 100 200 300 400 500 600 700 800 900 950))
         (dark-or-light hasliberg-theme-dark-or-light)
         (luminance-steps
          (mapcar (lambda (step)
                    (if (eq dark-or-light 'dark)
                        (+ l (* (- step 500) 0.1))
                      (- l (* (- step 500) 0.1))))
                  shades)))
    (mapcar
     (lambda (lum)
       (hasliberg-theme--lch-to-rgb
        (list :luminance lum :chroma c :hue h)))
     luminance-steps)))
(defun hasliberg-theme--generate-all-shades ()
  "Generate all shades for the colours defined with customization with the prefix of `hasliberg-theme-colour-'."
  (let* ((prefix "hasliberg-theme-colour-")
         (pflen (length prefix))
         (customs '(hasliberg-theme-colour-background
                    hasliberg-theme-colour-background-variant
                    hasliberg-theme-colour-neutral
                    hasliberg-theme-colour-primary
                    hasliberg-theme-colour-secondary
                    hasliberg-theme-colour-accent
                    hasliberg-theme-colour-accent-variant
                    hasliberg-theme-colour-subtle
                    hasliberg-theme-colour-subtle-variant
                    hasliberg-theme-colour-info
                    hasliberg-theme-colour-warning))
         (colours (cl-loop for c in customs
                           collect (cons (intern (substring (symbol-name c) pflen))
                                         (symbol-value c)))))
    (mapcar
     (lambda (colour)
       (let* ((name (car colour))
              (base-lch (cdr colour))
              (shades (hasliberg-theme--generate-lch-shades base-lch)))
         `(,name . ,(cl-pairlis '(50 100 200 300 400 500 600 700 800 900 950) shades))))
     colours)))
(defun hasliberg-theme--update-shades ()
  "Update the shades and hash table based on the colour variables."
  (setq hasliberg-theme-shades (hasliberg-theme--generate-all-shades))
  (clrhash hasliberg-theme-shades-hash)
  (cl-loop for (name . shades) in hasliberg-theme-shades
           do (cl-loop for (shade-name . shade-value) in shades
                       do (puthash (format "%s-%s" name shade-name)
                                   shade-value
                                   hasliberg-theme-shades-hash))))
(defun hasliberg-theme-hex-for (key)
  "Retrieve the Hex colour using a hashed KEY."
  (gethash (symbol-name key) hasliberg-theme-shades-hash))

;;;;----------------------------------------
;;;   Faces (Fonts)
;;------------------------------------------
(defface oblique-only
  '((t :inherit default
       :slant oblique))
  "A custom face only to give oblique look")
(defun hasliberg-theme--update-standard-faces ()
  (custom-theme-set-faces
   'hasliberg
   ;;;;----------------------------------------
   ;;;   Basic Faces
   ;;------------------------------------------
   `(default
     ((t :background ,(hasliberg-theme-hex-for 'background-500)
         :foreground ,(hasliberg-theme-hex-for 'neutral-500))))
   `(bold ((t :weight bold)))
   `(italic
     ((t :inherit (default oblique-only))))
   `(bold-italic ((t :inherit (bold oblique-only))))
   `(link
     ((t :inherit (default oblique-only)
         :foreground ,(hasliberg-theme-hex-for 'neutral-400))))
   `(highlight
     ((t :background ,(hasliberg-theme-hex-for 'background-variant-700))))

   `(cursor ((t :background ,(hasliberg-theme-hex-for 'accent-700))))
   `(region
     ((t :background ,(hasliberg-theme-hex-for 'background-700)
         :foreground ,(hasliberg-theme-hex-for 'subtle-600))))
   `(secondary-selection
     ((t :background ,(hasliberg-theme-hex-for 'background-variant-600)
         :foreground ,(hasliberg-theme-hex-for 'subtle-600))))
   `(whitespace-space ((t :foreground ,(hasliberg-theme-hex-for 'background-600))))
   `(whitespace-tab ((t :foreground ,(hasliberg-theme-hex-for 'background-600))))

   `(isearch ((t :background ,(hasliberg-theme-hex-for 'primary-300))))
   `(success ((t :foreground ,(hasliberg-theme-hex-for 'info-700))))
   `(warning ((t :foreground ,(hasliberg-theme-hex-for 'warning-500))))
   `(minibuffer-prompt ((t :foreground ,(hasliberg-theme-hex-for 'primary-500)
                           :weight semibold)))

   ;;;;----------------------------------------
   ;;;   Visual Elements
   ;;------------------------------------------
   `(fringe
     ((t :background ,(hasliberg-theme-hex-for 'background-500)
         :foreground ,(hasliberg-theme-hex-for 'accent-100))))
   `(menu
     ((t :background ,(hasliberg-theme-hex-for 'background-300)
         :foreground ,(hasliberg-theme-hex-for 'neutral-500))))
   `(scroll-bar
     ((t :background ,(hasliberg-theme-hex-for 'background-300)
         :foreground ,(hasliberg-theme-hex-for 'neutral-500))))
   `(tool-bar
     ((t :background ,(hasliberg-theme-hex-for 'background-300)
         :foreground ,(hasliberg-theme-hex-for 'neutral-500))))
   `(vertical-border
     ((t :foreground ,(hasliberg-theme-hex-for 'background-300))))
   `(header-line
     ((t :inherit oblique-only
         :background ,(hasliberg-theme-hex-for 'background-500)
         :foreground ,(hasliberg-theme-hex-for 'neutral-300))))
   `(tab-bar
     ((t :foreground ,(hasliberg-theme-hex-for 'neutral-500))))
   `(tab-bar-tab
     ((t :foreground ,(hasliberg-theme-hex-for 'neutral-500))))
   `(tab-bar-inactive
     ((t :foreground ,(hasliberg-theme-hex-for 'neutral-700))))
   `(tab-bar-tab-group-current
     ((t :foreground ,(hasliberg-theme-hex-for 'neutral-500)
         :underline t)))
   `(tab-bar-tab-group-inactive
     ((t :foreground ,(hasliberg-theme-hex-for 'secondary-400))))

   `(child-frame-border ((t :background ,(hasliberg-theme-hex-for 'primary-300))))
   ;; Mode Line
   `(mode-line
     ((t :background ,(hasliberg-theme-hex-for 'background-500)
         :foreground ,(hasliberg-theme-hex-for 'neutral-300))))
   `(mode-line-active
     ((t :background ,(hasliberg-theme-hex-for 'background-500)
         :foreground ,(hasliberg-theme-hex-for 'neutral-300)
         :overline   ,(hasliberg-theme-hex-for 'neutral-500))))
   `(mode-line-inactive
     ((t :background ,(hasliberg-theme-hex-for 'background-500)
         :foreground ,(hasliberg-theme-hex-for 'neutral-200)
         :underline  nil
         :overline   nil)))

   ;;;;----------------------------------------
   ;;;   Line Numbers
   ;;------------------------------------------
   `(line-number
     ((t :inherit default
         :height 0.85
         :foreground ,(hasliberg-theme-hex-for 'neutral-50))))
   `(line-number-current-line
     ((t :inherit line-number
         :foreground ,(hasliberg-theme-hex-for 'accent-500)
         :weight semibold)))
   `(line-number-major-tick
     ((t :inherit line-number
         :foreground ,(hasliberg-theme-hex-for 'neutral-600))))
   `(line-number-minor-tick
     ((t :inherit line-number)))

   ;;;;----------------------------------------
   ;;;   Font lock
   ;;------------------------------------------
   `(font-lock-string-face
     ((t :foreground ,(hasliberg-theme-hex-for 'accent-500)
         :weight regular)))
   `(font-lock-function-name-face
     ((t :foreground ,(hasliberg-theme-hex-for 'primary-400)
         :weight regular)))
   `(font-lock-variable-name-face
     ((t :foreground ,(hasliberg-theme-hex-for 'primary-400))))
   `(font-lock-constant-face
     ((t :foreground ,(hasliberg-theme-hex-for 'primary-700)
         :weight semibold)))
   `(font-lock-type-face
     ((t :foreground ,(hasliberg-theme-hex-for 'primary-600)
         :weight regular)))
   `(font-lock-keyword-face
     ((t :foreground ,(hasliberg-theme-hex-for 'neutral-700)
         :weight semibold)))
   `(font-lock-builtin-face
     ((t :foreground ,(hasliberg-theme-hex-for 'neutral-400))))
   `(font-lock-property-name-face
     ((t :foreground ,(hasliberg-theme-hex-for 'secondary-700))))

   `(font-lock-negation-char-face
     ((t :inherit bold
         :foreground ,(hasliberg-theme-hex-for 'secondary-500))))
   `(font-lock-preprocessor-face
     ((t :foreground ,(hasliberg-theme-hex-for 'neutral-300))))

   `(font-lock-comment-face
     ((t :inherit (fixed-pitch oblique-only)
         :foreground ,(hasliberg-theme-hex-for 'neutral-200)
         :weight light)))
   `(font-lock-comment-delimiter-face
     ((t :inherit (fixed-pitch oblique-only)
         :foreground ,(hasliberg-theme-hex-for 'primary-200)
         :weight light)))
   `(font-lock-doc-face
     ((t :inherit (fixed-pitch oblique-only)
         :foreground ,(hasliberg-theme-hex-for 'info-700))))

   `(font-lock-property-use-face
     ((t :foreground ,(hasliberg-theme-hex-for 'primary-600))))
   `(font-lock-regexp-grouping-backslash
     ((t :inherit bold
         :foreground ,(hasliberg-theme-hex-for 'subtle-variant-500))))
   `(font-lock-regexp-grouping-construct
     ((t :inherit bold
         :foreground ,(hasliberg-theme-hex-for 'subtle-variant-500))))
   `(font-lock-warning-face
     ((t :foreground ,(hasliberg-theme-hex-for 'warning-500))))

   ;;;;----------------------------------------
   ;;;   Org Mode
   ;;------------------------------------------
   `(org-document-title
     ((t :foreground ,(hasliberg-theme-hex-for 'neutral-700)
         :height 3.0)))
   `(org-level-1
     ((t :inherit default
         :foreground ,(hasliberg-theme-hex-for 'accent-500)
         :height 1.5)))
   `(org-level-2
     ((t :inherit default
         :foreground ,(hasliberg-theme-hex-for 'accent-600)
         :height 1.25)))
   `(org-level-3
     ((t :inherit default
         :foreground ,(hasliberg-theme-hex-for 'accent-700)
         :height 1.125)))
   `(org-level-4
     ((t :inherit default
         :foreground ,(hasliberg-theme-hex-for 'accent-variant-800)
         :height 1.0625)))
   `(org-level-5
     ((t :inherit default
         :foreground ,(hasliberg-theme-hex-for 'accent-variant-900)
         :height 1.0625)))
   `(org-level-6
     ((t :inherit default
         :foreground ,(hasliberg-theme-hex-for 'accent-variant-900))))
   `(org-special-keyword
     ((t :inherit fixed-pitch
         :weight thin
         :foreground ,(hasliberg-theme-hex-for 'neutral-200))))
   `(org-code
     ((t :inherit fixed-pitch
         :foreground ,(hasliberg-theme-hex-for 'primary-500))))
   `(org-verbatim
     ((t :inherit fixed-pitch
         :background ,(hasliberg-theme-hex-for 'background-400)
         :foreground ,(hasliberg-theme-hex-for 'primary-400))))
   `(org-ellipsis
     ((t :inherit fixed-pitch
         :foreground ,(hasliberg-theme-hex-for 'accent-700)
         :underline nil
         :height 0.7)))
   `(org-hide ((t :height 1.2)))
   `(org-block
     ((t :inherit (default)
         :foreground ,(hasliberg-theme-hex-for 'subtle-variant-500))))
   `(org-block-begin-line
     ((t :inherit (fixed-pitch oblique-only)
         :background ,(hasliberg-theme-hex-for 'background-500)
         :foreground ,(hasliberg-theme-hex-for 'primary-400)
         :overline   ,(hasliberg-theme-hex-for 'background-700)
         :height 0.80)))
   `(org-block-end-line
     ((t :inherit (fixed-pitch oblique-only)
         :background ,(hasliberg-theme-hex-for 'background-500)
         :foreground ,(hasliberg-theme-hex-for 'primary-400)
         :underline (:color ,(hasliberg-theme-hex-for 'background-700) :position 0)
         :height 0.75)))
   `(org-quote
     ((t :foreground ,(hasliberg-theme-hex-for 'accent-900) :slant oblique :height 1.1)))
   `(org-verse
     ((t :foreground ,(hasliberg-theme-hex-for 'neutral-600))))
   `(org-table
     ((t :inherit fixed-pitch
         :background ,(hasliberg-theme-hex-for 'background-600)
         :foreground ,(hasliberg-theme-hex-for 'neutral-600))))

   `(org-drawer ((t :inherit (fixed-pitch font-lock-comment-face))))
   `(org-property-value ((t :inherit (fixed-pitch font-lock-comment-face))))
   `(org-tag ((t :inherit fixed-pitch :height 0.7)))
   `(org-document-info-keyword
     ((t :inherit fixed-pitch
         :foreground ,(hasliberg-theme-hex-for 'neutral-300)
         :height 0.8)))
   `(org-meta-line ((t :inherit org-document-info-keyword)))
   `(org-checkbox ((t :inherit fixed-pitch :box nil)))

   `(org-headline-done ((t :foreground ,(hasliberg-theme-hex-for 'neutral-200))))
   `(org-agenda-structure ((t :foreground ,(hasliberg-theme-hex-for 'primary-200))))
   `(org-agenda-done ((t :foreground ,(hasliberg-theme-hex-for 'neutral-100))))
   `(org-scheduled-today ((t :foreground ,(hasliberg-theme-hex-for 'neutral-500))))
   ))
(defun hasliberg-theme--update-echo-buffer ()
  (dolist (buffer (list " *Minibuf-0*" " *Echo Area 0*"
                        " *Minibuf-1*" " *Echo Area 1*"))
    (when (get-buffer buffer)
      (with-current-buffer buffer
        (face-remap-add-relative 'default 'font-lock-preprocessor-face)))))

(defun hasliberg-theme--update-org-related-faces ()
  (custom-theme-set-faces
   'hasliberg
   `(org-modern-todo ((t :inherit fixed-pitch
                         :background ,(hasliberg-theme-hex-for 'primary-600))))
   `(org-modern-symbol ((t :inherit fixed-pitch)))))
(defun hasliberg-theme--update-markdown-faces ()
  (custom-theme-set-faces
   'hasliberg
   `(markdown-header-face-1 ((t :inherit org-level-1)))
   `(markdown-header-face-2 ((t :inherit org-level-2)))
   `(markdown-header-face-3 ((t :inherit org-level-3)))
   `(markdown-header-face-4 ((t :inherit org-level-4)))
   `(markdown-header-face-5 ((t :inherit org-level-5)))
   `(markdown-header-delimiter-face
     ((t :inherit fixed-pitch
         :foreground ,(hasliberg-theme-hex-for 'accent-200)
         :height 1.1)))
   `(markdown-language-keyword-face ((t :inherit org-block)))
   `(markdown-table-face ((t :inherit org-table)))
   `(markdown-pre-face ((t :inherit org-block)))))
(defun hasliberg-theme--update-language-specific-faces ()
  (custom-theme-set-faces
   'hasliberg
   `(sh-quoted-exec ((t :foreground ,(hasliberg-theme-hex-for 'accent-600))))
   `(sh-heredoc ((t :foreground ,(hasliberg-theme-hex-for 'accent-700))))))

(defun hasliberg-theme--update-all-faces ()
  "Update all faces."
  (hasliberg-theme--update-standard-faces)
  (hasliberg-theme--update-echo-buffer)
  (hasliberg-theme--update-org-related-faces)
  (hasliberg-theme--update-markdown-faces)
  (hasliberg-theme--update-language-specific-faces))

;;;;----------------------------------------
;;;   Finalise
;;------------------------------------------
(hasliberg-theme--update-shades)
(hasliberg-theme--update-all-faces)
(when load-file-name
  (add-to-list 'custom-theme-load-path
               (file-name-as-directory (file-name-directory load-file-name))))

(provide-theme 'hasliberg)

5. Workspaces & Projects

5.1. Display Buffer Rules

Control where special buffers appear.

;; Display buffer rules for side windows
(use-package window
  :ensure nil
  :custom
  (display-buffer-alist
   '(("\\*\\(Backtrace\\|Warnings\\|Compile-Log\\|Messages\\|Occur\\|eldoc\\)\\*"
      (display-buffer-in-side-window)
      (window-height . 0.25)
      (side . bottom)
      (slot . 0))
     ("\\*\\([Hh]elp\\)\\*"
      (display-buffer-in-side-window)
      (window-width . 75)
      (side . right)
      (slot . 0))
     ("\\*\\(Flymake diagnostics\\|xref\\|Completions\\)"
      (display-buffer-in-side-window)
      (window-height . 0.25)
      (side . bottom)
      (slot . 1))
     ("\\*\\(grep\\|find\\)\\*"
      (display-buffer-in-side-window)
      (window-height . 0.25)
      (side . bottom)
      (slot . 2)))))

5.2. Tab Bar (Workspaces)

Each tab is an independent workspace with its own window layout. Use M-1 through M-9 to switch instantly between workspaces.

;; Tab Bar Mode - workspace tabs
(use-package tab-bar
  :ensure nil
  :config
  (tab-bar-mode 1)

  :custom
  (tab-bar-show 1)
  (tab-bar-close-button-show nil)
  (tab-bar-new-button-show nil)
  (tab-bar-tab-hints t)
  (tab-bar-tab-name-function 'tab-bar-tab-name-truncated)
  (tab-bar-tab-name-truncated-max 20)
  (tab-bar-new-tab-choice "*scratch*")
  (tab-bar-new-tab-to 'rightmost)
  (tab-bar-close-tab-select 'recent)
  (tab-bar-position 'top)
  (tab-bar-format '(tab-bar-format-tabs tab-bar-separator))
  (tab-bar-history-mode 1)

  :bind
  (("C-x t n" . tab-bar-new-tab)
   ("C-x t c" . tab-bar-close-tab)
   ("C-x t o" . tab-bar-switch-to-next-tab)
   ("C-<tab>" . tab-bar-switch-to-next-tab)
   ("C-x t O" . tab-bar-switch-to-prev-tab)
   ("C-S-<tab>" . tab-bar-switch-to-prev-tab)
   ("C-x t r" . tab-bar-rename-tab)
   ("C-x t m" . tab-bar-move-tab)
   ;; Quick workspace switching (M-1 through M-9)
   ("M-1" . (lambda () (interactive) (tab-bar-select-tab 1)))
   ("M-2" . (lambda () (interactive) (tab-bar-select-tab 2)))
   ("M-3" . (lambda () (interactive) (tab-bar-select-tab 3)))
   ("M-4" . (lambda () (interactive) (tab-bar-select-tab 4)))
   ("M-5" . (lambda () (interactive) (tab-bar-select-tab 5)))
   ("M-6" . (lambda () (interactive) (tab-bar-select-tab 6)))
   ("M-7" . (lambda () (interactive) (tab-bar-select-tab 7)))
   ("M-8" . (lambda () (interactive) (tab-bar-select-tab 8)))
   ("M-9" . (lambda () (interactive) (tab-bar-select-tab 9)))
   ;; History
   ("C-x t <left>" . tab-bar-history-back)
   ("C-x t <right>" . tab-bar-history-forward)))

;; Custom tab naming with project context
(defun fu/tab-bar-tab-name-with-project ()
  "Generate tab name showing project and buffer."
  (let ((project-name (when (project-current)
                        (file-name-nondirectory
                         (directory-file-name
                          (project-root (project-current))))))
        (buffer-name (buffer-name)))
    (if project-name
        (format "%s: %s" project-name buffer-name)
      buffer-name)))
(setq tab-bar-tab-name-function 'fu/tab-bar-tab-name-with-project)

;; Tab bar appearance matching current theme
(defun fu/tab-bar-setup-appearance ()
  "Dynamically customize tab-bar to match the current theme."
  (let* ((mode-line-bg (face-attribute 'mode-line :background nil 'default))
         (mode-line-fg (face-attribute 'mode-line :foreground nil 'default))
         (mode-line-inactive-bg (face-attribute 'mode-line-inactive :background nil 'default))
         (mode-line-inactive-fg (face-attribute 'mode-line-inactive :foreground nil 'default))
         (default-bg (face-attribute 'default :background nil 'default))
         (default-fg (face-attribute 'default :foreground nil 'default))
         (highlight-bg (or (and (face-attribute 'highlight :background nil 'default)
                                (not (string= (face-attribute 'highlight :background nil 'default) "unspecified"))
                                (face-attribute 'highlight :background nil 'default))
                           mode-line-bg))
         (has-mode-line (and (stringp mode-line-bg)
                             (not (string= mode-line-bg "unspecified")))))
    (if has-mode-line
        (set-face-attribute 'tab-bar-tab nil
                            :foreground mode-line-fg
                            :background highlight-bg
                            :weight 'bold
                            :box `(:line-width 2 :color ,highlight-bg :style nil)
                            :inherit 'unspecified)
      (set-face-attribute 'tab-bar-tab nil
                          :inherit 'mode-line
                          :weight 'bold
                          :box nil))
    (if (and (stringp mode-line-inactive-bg)
             (not (string= mode-line-inactive-bg "unspecified")))
        (set-face-attribute 'tab-bar-tab-inactive nil
                            :foreground mode-line-inactive-fg
                            :background mode-line-inactive-bg
                            :weight 'normal
                            :box `(:line-width 2 :color ,mode-line-inactive-bg :style nil)
                            :inherit 'unspecified)
      (set-face-attribute 'tab-bar-tab-inactive nil
                          :inherit 'mode-line-inactive
                          :weight 'normal
                          :box nil))
    (set-face-attribute 'tab-bar nil
                        :background (if (and (stringp default-bg)
                                             (not (string= default-bg "unspecified")))
                                        default-bg
                                      (face-attribute 'mode-line-inactive :background nil 'default))
                        :foreground default-fg
                        :box nil
                        :inherit 'unspecified)))

;; Refresh tab-bar appearance when theme changes
(defun fu/tab-bar-refresh-appearance (&rest _)
  "Refresh tab-bar appearance after theme changes."
  (when (fboundp 'fu/tab-bar-setup-appearance)
    (fu/tab-bar-setup-appearance)))
(advice-add 'load-theme :after #'fu/tab-bar-refresh-appearance)
(advice-add 'enable-theme :after #'fu/tab-bar-refresh-appearance)

(if (daemonp)
    (add-hook 'after-make-frame-functions
              (lambda (frame)
                (with-selected-frame frame
                  (fu/tab-bar-setup-appearance))))
  (fu/tab-bar-setup-appearance))

5.3. Project Management

Built-in project.el with custom root markers and project-aware tab creation.

;; Project Management
(use-package project
  :ensure nil
  :custom
  (project-list-file (expand-file-name "projects" user-emacs-directory))
  (project-switch-commands
   '((project-find-file "Find file" ?f)
     (project-find-regexp "Find regexp" ?g)
     (project-find-dir "Find directory" ?d)
     (project-dired "Dired" ?D)
     (project-vc-dir "VC-Dir" ?v)
     (project-shell "Shell" ?s)
     (project-eshell "Eshell" ?e)
     (magit-project-status "Magit" ?m)
     (project-compile "Compile" ?c)
     (project-switch-to-buffer "Switch buffer" ?b)))
  (project-remember-projects-under "~/Projects" t)
  :bind
  (("C-x p C" . project-recompile)
   ("C-x p m" . magit-project-status))
  :config
  ;; Custom project root markers
  (defun fu/project-try-local (dir)
    "Try to find a project root in DIR by looking for marker files."
    (let ((markers '(".project" ".projectile" "package.json" "Cargo.toml"
                     "setup.py" "requirements.txt" "pom.xml" "build.gradle")))
      (cl-some (lambda (marker)
                 (when-let* ((root (locate-dominating-file dir marker)))
                   (cons 'transient root)))
               markers)))
  (add-hook 'project-find-functions #'fu/project-try-local)

  ;; Ignore common build directories
  (add-to-list 'project-vc-ignores "node_modules")
  (add-to-list 'project-vc-ignores "target")
  (add-to-list 'project-vc-ignores "build")
  (add-to-list 'project-vc-ignores "dist")
  (add-to-list 'project-vc-ignores ".venv")
  (add-to-list 'project-vc-ignores "__pycache__"))

;; Open project in a new workspace tab
(defun fu/project-tab-bar-new ()
  "Switch to a project in a new tab."
  (interactive)
  (let* ((project-dir (project-prompt-project-dir))
         (project-name (file-name-nondirectory
                       (directory-file-name project-dir))))
    (tab-bar-new-tab)
    (let ((project-current-directory-override project-dir))
      (project-switch-project project-dir))))
(global-set-key (kbd "C-x p t") #'fu/project-tab-bar-new)

5.4. Workspace Presets

;; Workspace presets
(defun fu/tab-bar-workspace-writing ()
  "Create a focused writing workspace."
  (interactive)
  (tab-bar-new-tab)
  (tab-bar-rename-tab "Writing")
  (delete-other-windows)
  (when (file-exists-p "~/org-roam")
    (dired "~/org-roam")))

(defun fu/tab-bar-workspace-coding ()
  "Create a coding workspace with three-pane layout."
  (interactive)
  (tab-bar-new-tab)
  (tab-bar-rename-tab "Coding")
  (delete-other-windows)
  (when (file-exists-p "~/Projects")
    (dired "~/Projects"))
  (split-window-right)
  (other-window 1)
  (split-window-below))

(global-set-key (kbd "C-x t w w") #'fu/tab-bar-workspace-writing)
(global-set-key (kbd "C-x t w c") #'fu/tab-bar-workspace-coding)

5.5. Session Persistence

;; Desktop save mode for session persistence
(use-package desktop
  :ensure nil
  :custom
  (desktop-restore-frames t)
  (desktop-restore-in-current-display t)
  (desktop-restore-forces-onscreen nil)
  (desktop-dirname user-emacs-directory)
  (desktop-path (list user-emacs-directory))
  :config
  (desktop-save-mode 1))

6. Completion

Built-in completion using icomplete-vertical-mode with flex matching. Zero external packages.

;;; Minibuffer Completion UI
(use-package icomplete
  :ensure nil
  :init
  (icomplete-vertical-mode 1)
  :custom
  (icomplete-delay-completions-threshold 0)
  (icomplete-compute-delay 0)
  (icomplete-show-matches-on-no-input t)
  (icomplete-scroll t)
  (icomplete-prospects-height 10)
  :bind (:map icomplete-minibuffer-map
         ("RET" . icomplete-force-complete-and-exit)
         ("TAB" . icomplete-force-complete)
         ("C-n" . icomplete-forward-completions)
         ("C-p" . icomplete-backward-completions)))

;;; Completion Styles
(use-package minibuffer
  :ensure nil
  :custom
  (completion-styles '(flex basic partial-completion emacs22))
  (completion-category-defaults nil)
  (completion-category-overrides '((file (styles partial-completion))))
  (completion-ignore-case t)
  (read-buffer-completion-ignore-case t)
  (read-file-name-completion-ignore-case t)
  (completions-detailed t)
  (completions-format 'one-column)
  (completions-max-height 20))

;;; Recent Files
(use-package recentf
  :ensure nil
  :init
  (recentf-mode 1)
  :custom
  (recentf-max-saved-items 100)
  :bind ("C-x C-r" . recentf-open-files))

;;; Completion History
(use-package savehist
  :ensure nil
  :init
  (savehist-mode 1))

;;; Built-in search commands
(global-set-key (kbd "M-g i") 'imenu)
(global-set-key (kbd "C-c s l") 'occur)
(global-set-key (kbd "C-c s g") 'grep)

7. Distraction-Free Writing

Olivetti centers text for a focused writing experience. Toggle with F9.

;; Distraction-Free Writing
(use-package olivetti
  :bind ("<f9>" . olivetti-mode)
  :custom
  (olivetti-body-width 80)
  (olivetti-recall-visual-line-mode-entry-state t))

8. Spell Checking

Flyspell with automatic detection of aspell or hunspell.

;; Spell Checking
(use-package flyspell
  :ensure nil
  :custom
  (ispell-program-name
   (cond ((executable-find "aspell") "aspell")
         ((executable-find "hunspell") "hunspell")
         (t "ispell")))
  :bind
  (("<f5>" . flyspell-mode)
   ("<f6>" . flyspell-goto-next-error)
   ("<f7>" . ispell-word))
  :hook
  ((text-mode . flyspell-mode)
   (org-mode  . flyspell-mode)
   (prog-mode . flyspell-prog-mode)))

9. Org-mode

9.1. Core Configuration

;; Org-mode
(use-package org
  :ensure nil
  :commands (org-capture org-agenda)
  :custom
  (org-catch-invisible-edits 'show)
  (org-edit-timestamp-down-means-later t)
  (org-src-window-setup 'current-window)
  (org-export-coding-system 'utf-8)
  (org-html-validation-link nil)
  (org-hide-emphasis-markers t)
  (org-pretty-entities nil)
  (org-startup-indented nil)
  (org-startup-with-inline-images t)
  (org-image-actual-width '(450))
  (org-tags-column 80)
  :hook
  ((org-mode . olivetti-mode))
  :config
  ;; Load org-tempo for easy template expansion (< + TAB)
  (require 'org-tempo)

  ;; Structure templates for all supported languages
  (add-to-list 'org-structure-template-alist '("sh"   . "src shell"))
  (add-to-list 'org-structure-template-alist '("bash" . "src bash"))
  (add-to-list 'org-structure-template-alist '("el"   . "src emacs-lisp"))
  (add-to-list 'org-structure-template-alist '("py"   . "src python"))
  (add-to-list 'org-structure-template-alist '("js"   . "src js"))
  (add-to-list 'org-structure-template-alist '("ts"   . "src typescript"))
  (add-to-list 'org-structure-template-alist '("cc"   . "src C"))
  (add-to-list 'org-structure-template-alist '("rb"   . "src ruby"))
  (add-to-list 'org-structure-template-alist '("lua"  . "src lua"))
  (add-to-list 'org-structure-template-alist '("sql"  . "src sql"))
  (add-to-list 'org-structure-template-alist '("txt"  . "src text"))

  ;; Babel languages (only load available ones)
  (org-babel-do-load-languages
   'org-babel-load-languages
   (seq-filter
    (lambda (pair)
      (locate-library (concat "ob-" (symbol-name (car pair)))))
    '((shell      . t)
      (emacs-lisp . t)
      (python     . t)
      (js         . t)
      (C          . t)
      (lua        . t)
      (ruby       . t)
      (sql        . t))))

  ;; Fix electric-pair-mode for org-mode angle brackets
  (defun fu/org-electric-pair-inhibit (c)
    (if (char-equal c ?<) t (electric-pair-inhibit-predicate c)))
  (add-hook 'org-mode-hook
            (lambda ()
              (setq-local electric-pair-inhibit-predicate
                          #'fu/org-electric-pair-inhibit))))

9.2. Org Modern

Modern visual styling for Org-mode with beautiful typography.

;; Modern Org-mode styling
(use-package org-modern
  :hook
  (org-mode . org-modern-mode)
  (org-agenda-finalize . org-modern-agenda)
  :custom
  (org-modern-star 'replace)
  (org-modern-hide-stars t)
  (org-modern-table t)
  (org-modern-table-vertical 1)
  (org-modern-table-horizontal 0.2)
  (org-modern-list '((43 . "➤")   ; +
                     (45 . "–")   ; -
                     (42 . "•"))) ; *
  (org-modern-block-name '(("src" "»" "«")
                           ("example" "»–" "–«")
                           ("quote" "❝" "❞")
                           ("export" "⏩" "⏪")))
  (org-modern-keyword t)
  (org-modern-checkbox nil)
  (org-modern-horizontal-rule t)
  (org-modern-timestamp t)
  (org-modern-statistics t)
  (org-modern-tag t)
  (org-modern-priority t)
  (org-modern-todo t)
  (org-modern-todo-faces
   '(("TODO" :background "#ff6c6b" :foreground "#282c34" :weight bold)
     ("DOING" :background "#51afef" :foreground "#282c34" :weight bold)
     ("REVIEW" :background "#ecbe7b" :foreground "#282c34" :weight bold)
     ("DONE" :background "#98be65" :foreground "#282c34" :weight bold)
     ("CANCEL" :background "#5B6268" :foreground "#282c34" :weight bold))))

10. Markdown Support

GitHub-Flavored Markdown editing with TOC generation and distraction-free writing.

;; Markdown
(use-package markdown-mode
  :mode ("README\\.md\\'" . gfm-mode)
  :init (setq markdown-command "multimarkdown")
  :bind (:map markdown-mode-map
              ("C-c C-e" . markdown-do)
              ("C-c C-t" . markdown-toc-generate-or-refresh-toc))
  :custom
  (markdown-header-scaling t)
  (markdown-enable-wiki-links t)
  (markdown-italic-underscore t)
  (markdown-fontify-code-blocks-natively t)
  (markdown-gfm-use-electric-backquote nil)
  :hook
  ((markdown-mode . olivetti-mode)))

(use-package markdown-toc
  :after markdown-mode)

11. Tree-sitter

Automatic tree-sitter grammar installation and mode remapping for rich syntax highlighting.

;; Tree-sitter: automatic grammar install + mode remapping
(use-package treesit-auto
  :custom
  (treesit-auto-install 'prompt)
  :config
  (treesit-auto-add-to-auto-mode-alist 'all)
  (global-treesit-auto-mode))

12. Eglot (LSP)

Lightweight Language Server Protocol support (built-in since Emacs 29). Install language servers separately (e.g., pyright, typescript-language-server, clangd, lua-language-server).

;; LSP - Eglot (built-in)
(use-package eglot
  :ensure nil
  :custom
  (eglot-autoshutdown t)
  (eglot-events-buffer-size 0)
  (eglot-events-buffer-config '(:size 0 :format full))
  (eglot-prefer-plaintext t)
  (jsonrpc-event-hook nil)
  (eglot-code-action-indications nil)
  (eglot-ignored-server-capabilities '(:workspaceFolders))
  :init
  (fset #'jsonrpc--log-event #'ignore)
  :bind (:map eglot-mode-map
         ("C-c l a" . eglot-code-actions)
         ("C-c l r" . eglot-rename)
         ("C-c l f" . eglot-format))
  :hook
  ((python-ts-mode    . eglot-ensure)
   (js-ts-mode        . eglot-ensure)
   (typescript-ts-mode . eglot-ensure)
   (c-ts-mode         . eglot-ensure)
   (ruby-ts-mode      . eglot-ensure)
   (lua-mode          . eglot-ensure)))

13. Light Programming Support

13.1. Editing Utilities

;; Auto bracket pairing
(use-package elec-pair
  :ensure nil
  :hook (after-init . electric-pair-mode))

;; Matching parentheses
(use-package paren
  :ensure nil
  :hook (after-init . show-paren-mode)
  :custom
  (show-paren-delay 0)
  (show-paren-style 'mixed)
  (show-paren-context-when-offscreen t))

13.2. Syntax Checking

;; Flymake syntax checking
(use-package flymake
  :ensure nil
  :defer t
  :hook ((prog-mode . flymake-mode)
         (emacs-lisp-mode . flymake-mode))
  :bind (:map flymake-mode-map
         ("M-8" . flymake-goto-next-error)
         ("M-7" . flymake-goto-prev-error)
         ("C-c ! l" . flymake-show-buffer-diagnostics))
  :custom
  (flymake-show-diagnostics-at-end-of-line nil)
  (flymake-indicator-type 'margins)
  (flymake-margin-indicators-string
   `((error   "!" compilation-error)
     (warning "?" compilation-warning)
     (note    "i" compilation-info))))

13.3. Language Modes

;; Lua (no built-in tree-sitter mode)
(use-package lua-mode :defer t)

;; YAML
(use-package yaml-mode :defer t)

;; Configuration files
(use-package conf-mode
  :ensure nil
  :mode ("\\.env\\..*\\'" "\\.env\\'")
  :init
  (add-to-list 'auto-mode-alist '("\\.env\\'" . conf-mode)))

;; Documentation
(use-package eldoc
  :ensure nil
  :init
  (global-eldoc-mode))

13.4. Version Control

;; Git interface
(use-package magit
  :bind (("C-x g" . magit-status)
         ("C-c g" . magit-file-dispatch))
  :custom
  (magit-display-buffer-function #'magit-display-buffer-same-window-except-diff-v1)
  (magit-save-repository-buffers 'dontask)
  (magit-bury-buffer-function #'magit-mode-quit-window)
  (magit-diff-refine-hunk t)
  (magit-diff-refine-ignore-whitespace t))

;; Merge conflict resolution
(use-package smerge-mode
  :ensure nil
  :bind (:map smerge-mode-map
         ("C-c ^ u" . smerge-keep-upper)
         ("C-c ^ l" . smerge-keep-lower)
         ("C-c ^ n" . smerge-next)
         ("C-c ^ p" . smerge-previous)))

13.5. Diff and Merge

;; Diff
(use-package diff-mode
  :ensure nil
  :defer t
  :config
  (setq diff-default-read-only t
        diff-advance-after-apply-hunk t
        diff-update-on-the-fly t
        diff-font-lock-syntax 'hunk-also))

;; Ediff with horizontal split
(use-package ediff
  :ensure nil
  :commands (ediff-buffers ediff-files)
  :init
  (setq ediff-split-window-function 'split-window-horizontally
        ediff-window-setup-function 'ediff-setup-windows-plain)
  :config
  (setq ediff-keep-variants nil
        ediff-merge-revisions-with-ancestor t
        ediff-show-clashes-only t))

14. Word Count & Writing Utilities

;; Word count for buffer
(defun fu/word-count-buffer ()
  "Display the word count for the current buffer."
  (interactive)
  (message "Words in buffer: %d" (count-words (point-min) (point-max))))

;; Word count for region
(defun fu/word-count-region ()
  "Display the word count for the current region."
  (interactive)
  (if (use-region-p)
      (message "Words in region: %d" (count-words (region-beginning) (region-end)))
    (message "No active region")))

;; Insert timestamp
(defun fu/insert-timestamp ()
  "Insert current timestamp."
  (interactive)
  (insert (format-time-string "%Y-%m-%d %H:%M:%S")))

;; Insert date
(defun fu/insert-date ()
  "Insert current date."
  (interactive)
  (insert (format-time-string "%Y-%m-%d")))

;; Combined writing mode toggle
(defun fu/toggle-writing-mode ()
  "Toggle writing mode: olivetti + flyspell + visual-line."
  (interactive)
  (if (bound-and-true-p olivetti-mode)
      (progn
        (olivetti-mode -1)
        (flyspell-mode -1)
        (visual-line-mode -1)
        (message "Writing mode OFF"))
    (olivetti-mode 1)
    (flyspell-mode 1)
    (visual-line-mode 1)
    (message "Writing mode ON")))

;; Mode-line word count (updated on idle)
(defvar fu/word-count-string ""
  "Word count string displayed in mode line.")
(defun fu/update-word-count ()
  "Update word count for mode line display."
  (when (derived-mode-p 'text-mode 'org-mode 'markdown-mode)
    (setq fu/word-count-string
          (format " W:%d" (count-words (point-min) (point-max))))))
(run-with-idle-timer 2 t #'fu/update-word-count)
(add-to-list 'global-mode-string '(:eval fu/word-count-string) t)

;; Key bindings
(global-set-key (kbd "C-c w c") #'fu/word-count-buffer)
(global-set-key (kbd "C-c w s") #'fu/word-count-region)
(global-set-key (kbd "C-c w t") #'fu/insert-timestamp)
(global-set-key (kbd "C-c w d") #'fu/insert-date)
(global-set-key (kbd "<f8>")    #'display-line-numbers-mode)
(global-set-key (kbd "<f10>")   #'fu/toggle-writing-mode)

15. File Management

Minimal dired configuration with human-readable sizes.

;; Dired
(use-package dired
  :ensure nil
  :commands (dired dired-jump)
  :bind (("C-x C-j" . dired-jump))
  :custom
  (dired-listing-switches "-ahl --group-directories-first")
  (dired-auto-revert-buffer t)
  (dired-recursive-copies 'always)
  (dired-recursive-deletes 'always)
  (dired-kill-when-opening-new-dired-buffer t)
  :config
  (put 'dired-find-alternate-file 'disabled nil))

16. End of Init

(provide 'init)
;;; init.el ends here

17. Key Bindings Reference Card

17.1. Workspaces & Projects

Key Action
M-1 to M-9 Jump to workspace by number
C-TAB Next workspace
C-S-TAB Previous workspace
C-x p p Switch project
C-x p f Find file in project
C-x p b Project buffers
C-x p t Open project in new tab
C-x t n New workspace
C-x t c Close workspace
C-x t r Rename workspace
C-x t w w Writing workspace preset
C-x t w c Coding workspace preset

17.2. Writing

Key Action
F5 Toggle spell checking
F6 Next spelling error
F7 Correct word at point
F8 Toggle line numbers
F9 Toggle olivetti (distraction-free)
F10 Toggle writing mode (combined)
C-c w c Word count (buffer)
C-c w s Word count (region)
C-c w t Insert timestamp
C-c w d Insert date
C-x C-r Open recent file
M-o Other window

17.3. Coding

Key Action
C-c l a Eglot code actions
C-c l r Eglot rename symbol
C-c l f Eglot format
M-7 Previous flymake error
M-8 Next flymake error
C-x g Magit status

17.4. Org-mode (Babel)

Template Language
< sh Shell
< el Emacs Lisp
< py Python
< js JavaScript
< ts TypeScript
< cc C
< lua Lua
< rb Ruby
< sql SQL

Type the template prefix then press TAB to expand.

18. Tangling & Launch Instructions

18.1. Tangle the Configuration

Open this file in Emacs and run C-c C-v t to extract all source blocks to their target files:

  • ~/.emacs-writing.d/early-init.el - Early initialization
  • ~/.emacs-writing.d/init.el - Main configuration
  • ~/.emacs-writing.d/themes/hasliberg-theme.el - Color theme

18.2. Launch

# Launch with the writing configuration
emacs --init-directory ~/.emacs-writing.d/

# Or create a shell alias
alias ew='emacs --init-directory ~/.emacs-writing.d/'

18.3. First Launch

On first launch, Emacs will automatically download and install the 8 external packages. This requires an internet connection and may take a minute.

18.4. External Packages

Package Purpose
olivetti Distraction-free centered writing
org-modern Modern org-mode visual styling
markdown-mode Markdown editing with GFM
markdown-toc TOC generation for Markdown
treesit-auto Auto tree-sitter grammar install
lua-mode Lua language support
yaml-mode YAML editing
magit Git interface

18.5. Language Servers (install separately)

For LSP features via Eglot, install the appropriate language server:

Language Server Install
Python pyright npm i -g pyright
JavaScript typescript-language-server npm i -g typescript-language-server
TypeScript typescript-language-server npm i -g typescript typescript-language-server
C/C++ clangd brew install llvm or system package manager
Ruby solargraph gem install solargraph
Lua lua-language-server brew install lua-language-server