commit 7774e58c79dc490561c2252a0c3734febd600928 Author: Jason Hilder Date: Fri Jul 24 09:47:24 2026 +0200 Initial commit for v1.0 of Simply Emacs. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bedf722 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Auto-generated by Custom (M-x customize) - not hand-edited, don't track +custom.el + +# Local state / caches +backups/ +auto-save/ +auto-save-list/ +eln-cache/ +elpa/ +transient/ +tree-sitter/ +.cache/ + +# Byte-compiled / elc +*.elc + +places +projects +recentf +history +todo \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..52802bb --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# SimplyEmacs + +> Vanilla Emacs at heart, with Evil scoped strictly to buffer editing and basic motion. + +## Philosophy + +SimplyEmacs is my personal, versioned Emacs setup — built up piece by piece and +treated like a project with tags and releases. + +Simple idea: stick as close to vanilla Emacs as possible, but keep movement comfortable. +Evil handles normal modal editing inside regular text buffers, while in modes like +Dired or Magit it's stripped down to a paper-thin navigation layer (hjkl for moving +around) so all the native keybinds and features keep working as intended. +It gives you comfortable motion without burying how Emacs actually works. + +## Layout + +``` +. +├── init.el # bootstrap, general UI, built-in settings, module loader +├── lisp/ +│ ├── init-evil.el # evil-mode, ex commands, buffer-editing keybinds +│ ├── init-dired.el # evil-style navigation for dired +│ ├── init-completion.el # vertico, orderless, marginalia, corfu, consult, which-key +│ ├── init-project.el # project.el scoped to ~/Projects, with a sticky project pin +│ ├── init-build.el # simple build system around build.sh scripts +│ ├── init-dev.el # per-language tooling (Odin, Lua, TOML) + eglot +│ ├── init-git.el # version control with Magit +│ ├── init-theme.el # gruvbox-dark-hard + a few face tweaks +│ └── init-cheatsheet.el # in-Emacs keybinding reference (toggle with C-c /) +└── custom.el # auto-generated by M-x customize, gitignored +``` + +`init.el` is meant to stay readable as "what does bare Emacs look and feel +like" — general UI, backups/autosave, scrolling, removing confirmations, etc. +Everything else is one file per concern under `lisp/`, `require`d at the +bottom of `init.el`. Custom's auto-generated output is redirected to its +own `custom.el` (gitignored) so a stray `M-x customize` never shows up +as noise in a diff. + +## Included + +- **Completion** — vertico + vertico-posframe, orderless, marginalia, corfu, consult +- **Discovery** — which-key +- **Editing** — evil (buffer editing only) +- **Files** — dired, tuned with evil-style `hjkl` navigation +- **Dev tooling** — tree-sitter + odin-ts-mode + eglot (OLS) for Odin, plus lua-mode, toml-mode, and markdown-mode +- **Theme** — gruvbox-dark-hard, darkened background/fringe/line-numbers to `#111111` + +## Install + +```sh +git clone ~/.config/emacs +emacs +``` + +First launch will refresh package archives and install everything declared +via `use-package`. + +## Adding a language + +Add a new `;;;; ` section to `lisp/init-dev.el` following the Odin +example (tree-sitter grammar source, mode package, eglot hook). + +## Versioning + +Tagged releases (`v1.0`, `v1.1`, ...) mark points where the config is in a +known-good, "I'd hand this to past-me" state. +Day-to-day tweaks happen over time and, once they've proven themselves, +eventually land in a version. diff --git a/early-init.el b/early-init.el new file mode 100644 index 0000000..79c96a3 --- /dev/null +++ b/early-init.el @@ -0,0 +1,44 @@ +;;; early-init.el --- Pre-init: GC ceiling and frame chrome -*- lexical-binding: t; -*- + +;;; Commentary: +;; Runs before package.el initialises and before the first frame is drawn, so +;; it is the only place two things can take effect: +;; 1. Raising the GC ceiling, so the whole startup (package load + init.el) +;; runs without garbage-collection pauses. +;; 2. Removing the menu/tool/scroll bars via `default-frame-alist', so they +;; are never drawn — toggling the minor modes in init.el instead draws +;; them and then removes them, a visible flicker on launch. +;; +;; Nothing here may depend on a package: none are loaded yet. +;; +;; IMPORTANT: the GC bump below is only half the pair. Restore a working value +;; once startup is done by adding this to init.el: +;; +;; (add-hook 'emacs-startup-hook +;; (lambda () (setq gc-cons-threshold (* 16 1024 1024)))) +;; +;; Without it, GC never runs again and Emacs slowly eats memory. + +;;; Code: + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Garbage Collection +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Hold GC off for the whole startup; init.el lowers it back on +;; `emacs-startup-hook' (see the Commentary above). + +(setq gc-cons-threshold most-positive-fixnum) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Frame Chrome (no flicker) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Disable the bars before the first frame exists. + +(push '(menu-bar-lines . 0) default-frame-alist) +(push '(tool-bar-lines . 0) default-frame-alist) +(push '(vertical-scroll-bars) default-frame-alist) + +;; Don't resize the frame when the UI/fonts change during startup. +(setq frame-inhibit-implied-resize t) + +;;; early-init.el ends here diff --git a/init.el b/init.el new file mode 100755 index 0000000..1742da2 --- /dev/null +++ b/init.el @@ -0,0 +1,217 @@ +;;; init.el --- SimplyEmacs: bootstrap, UI, built-in settings -*- lexical-binding: t; -*- + +;;; Commentary: +;; The "bare Emacs" layer: package bootstrap, general UI, fonts, built-in +;; behaviour, and the module loader. Anything beyond vanilla UI/built-in +;; settings lives in its own lisp/ file and is required near the bottom, so +;; this file stays readable as "what does bare Emacs look and feel like". + +;;; Code: + +;; Make Emacs find programs installed in ~/.local/bin +(add-to-list 'exec-path (expand-file-name "~/.local/bin")) +(setenv "PATH" + (concat (expand-file-name "~/.local/bin") + path-separator + (getenv "PATH"))) + +;; GC restore - see early-init.el + (add-hook 'emacs-startup-hook + (lambda () (setq gc-cons-threshold (* 16 1024 1024)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Package Setup +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(require 'package) + +(setq package-archives + '(("gnu" . "https://elpa.gnu.org/packages/") + ("nongnu" . "https://elpa.nongnu.org/nongnu/") + ("melpa" . "https://melpa.org/packages/"))) + +(package-initialize) + +(unless package-archive-contents + (package-refresh-contents)) + +(unless (package-installed-p 'use-package) + (package-install 'use-package)) + +(require 'use-package) + +(setq use-package-always-ensure t) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Module Loader +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Anything beyond vanilla UI/built-in behaviour lives in its own lisp/ file +;; and is required at the bottom, so this file stays the "bare Emacs" layer. +(add-to-list 'load-path (expand-file-name "lisp" user-emacs-directory)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; General UI +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(global-so-long-mode 1) + +(setq frame-resize-pixelwise t) + +(setq inhibit-startup-screen t) +(setq initial-scratch-message nil) + +(setq-default header-line-format mode-line-format) +(setq-default mode-line-format nil) + +(blink-cursor-mode 0) + +(setq ring-bell-function #'ignore) + +(global-hl-line-mode 1) +(global-display-line-numbers-mode 1) + +(column-number-mode 1) +(size-indication-mode 1) + +(setq-default indent-tabs-mode nil) +(setq-default tab-width 4) + +(fset 'yes-or-no-p #'y-or-n-p) + +(repeat-mode 1) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Fonts +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Main font +(set-face-attribute 'default nil + :family "JetBrainsMono Nerd Font" + :height 120 + :weight 'regular) + +;; Variable pitch font (used by Org, Markdown, etc. if enabled) +(set-face-attribute 'variable-pitch nil + :family "Noto Sans" + :height 130 + :weight 'regular) + +;; Fixed pitch should match the default font +(set-face-attribute 'fixed-pitch nil + :family "JetBrainsMono Nerd Font" + :height 120 + :weight 'regular) + +;; Slightly larger mode line +(set-face-attribute 'mode-line nil + :height 130) +(set-face-attribute 'mode-line-inactive nil + :height 130) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Keep Projects Clean (built-in) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(setq backup-directory-alist + `(("." . ,(expand-file-name "backups/" user-emacs-directory)))) + +(setq auto-save-file-name-transforms + `((".*" ,(expand-file-name "auto-save/" user-emacs-directory) t))) + +(setq create-lockfiles nil) + +(make-directory (expand-file-name "backups/" user-emacs-directory) t) +(make-directory (expand-file-name "auto-save/" user-emacs-directory) t) + +(save-place-mode 1) +(savehist-mode 1) +(recentf-mode 1) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Convenience (built-in) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(setq scroll-conservatively 101) +(setq scroll-margin 15) + +(electric-pair-mode 1) + +(show-paren-mode 1) + +;; Command Palette +(global-set-key (kbd "C-S-p") #'execute-extended-command) + +(defun my/keyboard-quit () + "Escape behaves like C-g." + (interactive) + (keyboard-quit)) + +(global-set-key (kbd "") #'my/keyboard-quit) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Don't ask so many questions (built-in) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; Quit immediately +(setq confirm-kill-emacs nil) + +;; Don't ask before killing buffers with running processes +(setq confirm-kill-processes nil) + +;; Don't ask about active processes when exiting +(setq kill-buffer-query-functions nil) + +;; Follow symlinks without asking +(setq vc-follow-symlinks t) + +;; Revert changed files automatically +(global-auto-revert-mode 1) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Modules +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; vim motions used sparingly +(require 'init-evil) +;; file browser +(require 'init-dired) +;; code completion +(require 'init-completion) +;; project management +(require 'init-project) +;; project building +(require 'init-build) +;; development languages and lsp +(require 'init-dev) +;; version control git +(require 'init-git) +;; theme +(require 'init-theme) +;; cheatsheet +(require 'init-cheatsheet) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Custom +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Custom's auto-generated output goes to its own custom.el so a stray +;; `M-x customize' never leaves noise in this hand-written init. + +(setq custom-file (expand-file-name "custom.el" user-emacs-directory)) +(when (file-exists-p custom-file) + (load custom-file)) + +(custom-set-variables + ;; custom-set-variables was added by Custom. + ;; If you edit it by hand, you could mess it up, so be careful. + ;; Your init file should contain only one such instance. + ;; If there is more than one, they won't work right. + '(package-vc-selected-packages + '((odin-ts-mode :url "https://github.com/Sampie159/odin-ts-mode")))) + +(custom-set-faces + ;; custom-set-faces was added by Custom. + ;; If you edit it by hand, you could mess it up, so be careful. + ;; Your init file should contain only one such instance. + ;; If there is more than one, they won't work right. + ) + +;;; init.el ends here diff --git a/lisp/init-build.el b/lisp/init-build.el new file mode 100755 index 0000000..05a3082 --- /dev/null +++ b/lisp/init-build.el @@ -0,0 +1,71 @@ +;;; init-build.el --- Project build command -*- lexical-binding: t; -*- + +;;; Commentary: +;; Build via built-in `compile', running ./build.sh from the project root. +;; compile saves buffers, reuses one output buffer, and turns file:line:col +;; into clickable jumps — a thin wrapper, not a terminal-send hack. + +;;; Code: + +;; Always show *compilation* as a fixed right-hand panel, so the toggle below +;; hits a predictable window. +(add-to-list 'display-buffer-alist + '("\\*compilation\\*" + (display-buffer-reuse-window display-buffer-in-side-window) + (side . right) + (window-width . 0.35))) + +;; If t is too aggressive (e.g. auto-scrolls while trying to read mid-output +;; that hasn't errored yet), swap to: +;; (setq compilation-scroll-output 'first-error) +(setq compilation-scroll-output t) + +(defun my/project-build (&optional args) + "Run ./build.sh from the project root, appending ARGS (a string) if given. +Errors if build.sh isn't readable there. Saves all buffers first." + (let* ((root (if (project-current) + (project-root (project-current)) + default-directory)) + (script (expand-file-name "build.sh" root)) + (default-directory root)) + (unless (file-readable-p script) + (user-error "No build.sh found in %s" root)) + (save-some-buffers t) + (compile (string-trim (concat "./build.sh " (or args "")))))) + +(defun my/build () + "Build the current project via `./build.sh' with no arguments." + (interactive) + (my/project-build nil)) + +(defun my/build-with-options () + "Build the current project, prompting for arguments to append." + (interactive) + (my/project-build (read-string "build.sh args: "))) + +(defun my/toggle-build-panel () + "Show/hide the *compilation* window from the last run (doesn't rebuild)." + (interactive) + (let ((buf (get-buffer "*compilation*"))) + (cond + ((null buf) + (user-error "No build output yet — run `my/build' first")) + ((get-buffer-window buf) + (delete-window (get-buffer-window buf))) + (t + (display-buffer buf))))) + +;; Global (not evil-scoped) so it works from any state or buffer. +;; C-c C-c build C-c C-o build w/ options C-j toggle panel +(global-set-key (kbd "C-c C-c") #'my/build) +(global-set-key (kbd "C-c C-o") #'my/build-with-options) +(global-set-key (kbd "C-j") #'my/toggle-build-panel) + +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-c" "code" + "C-c c" "build" + "C-c o" "build (with options)")) + +(provide 'init-build) +;;; init-build.el ends here diff --git a/lisp/init-cheatsheet.el b/lisp/init-cheatsheet.el new file mode 100755 index 0000000..bc2fc2f --- /dev/null +++ b/lisp/init-cheatsheet.el @@ -0,0 +1,248 @@ +;;; init-cheatsheet.el --- In-Emacs keybinding reference -*- lexical-binding: t; -*- + +;;; Commentary: +;; A static reference buffer listing this config's custom keybindings, +;; opened as a non-blocking side window — same window-management model as +;; the build panel in `init-build.el' — so it can be toggled in and out of +;; view without interrupting whatever else is on screen. +;; +;; Content is real Markdown, viewed read-only via `markdown-view-mode' +;; (from the `markdown-mode' package already pulled in by `init-dev.el') +;; so headers/tables/code spans get proper fontification for free. + +;;; Code: + +(require 'markdown-mode) ; markdown-view-mode; make the dep explicit + +(add-to-list 'display-buffer-alist + '("\\*Cheatsheet\\*" + (display-buffer-reuse-window display-buffer-in-side-window) + (side . left) + (window-width . 0.4))) + +(defconst my/cheatsheet-text + "# SimplyEmacs Cheat Sheet + +Custom keybindings layered on top of vanilla Emacs. Toggle this buffer +any time with `C-c /`. + +Many keys use a project/fallback pattern (`my/project-or`): one action +inside a known project, a `consult`-based fallback everywhere else. + +## Navigation & Search *(Evil normal/motion state)* + +| Key | In a project | Fallback | +|------------|----------------------------|-------------------| +| `C-e` | `project-dired` | `dired-jump` | +| `C-m` | `project-switch-to-buffer` | `consult-buffer` | +| `C-f` | `consult-ripgrep` | `consult-ripgrep` | +| `C-p` | `project-find-file` | `consult-fd` | +| `C-s` | `consult-line` | | +| `` | `evil-ret` | | + +`C-f` runs `consult-ripgrep` everywhere — it already scopes to the +project root when there is one, so it needs no project/fallback split. + +## Projects + +| Key | Action | +|-----------|-------------------------------------------------------------------------------| +| `C-c C-p` | Switch project — saves & closes current project's buffers first, then prompts | + +Projects are rescanned from `~/Projects` on every startup (direct +children only, no symlinks, most-recently-modified first). Jumping into +a dependency's source keeps the *current project* pinned to your last +real project instead of hijacking to it. + +## Build + +| Key | Action | +|-----------|-----------------------------------------------------------| +| `C-c C-c` | Run `./build.sh` from project root (saves buffers first) | +| `C-c C-o` | Same, but prompts for extra args to append | +| `C-j` | Toggle the `*compilation*` output panel (right side, 35%) | + +Requires `build.sh` at the project root; output is a real `compile` +buffer, so `M-g n` / `M-g p` jump between errors. + +## Dev / Eglot (LSP) + +Wired up for Odin (`.odin`, tree-sitter + `odin-ts-mode` + OLS). + +| Key | Action | +|------------|----------------------------------| +| `C-c r` | `eglot-rename` | +| `C-c a` | `eglot-code-actions` | +| `K` norm | Full hover doc in a focused pane | +| `q` or `K` | Close the hover pane | + +Add a new language: new `;;;; ` section in `init-dev.el`. + +## Git (Magit) + +`C-c C-g` opens `magit-status` (replaces the lazygit TUI). Magit keeps +its own native keys — nothing reimplemented — evil's normal state just +shadows most of them by default, so they're restored below plus a couple +of hjkl navigation aliases. + +### Workflow + +- Expand only the file you're working on with `TAB`. +- Move between hunks with `j` / `k`. +- Press `s` on a hunk to stage only that hunk. +- Continue staging related hunks until they form a single logical change. +- Press `c c` to create a commit from the staged changes. +- Repeat for the remaining hunks, creating small, focused commits instead of one large commit. + +> **Tip:** If a single hunk contains unrelated changes, press `e` to edit the patch before staging, allowing you to split the changes into separate commits. + +| Key | Action | +|-----------|----------------------| +| `j` / `n` | next section | +| `s` | stage | +| `k` / `p` | previous section | +| `u` | unstage | +| `h` | up to parent section | +| `U` | unstage all | +| `RET` | visit thing at point | +| `x` | discard | +| `g r` | refresh | +| `q` | quit / bury buffer | + +**Popups** *(transient menus)*: + +| Key | Action | +|-----|----------------| +| `c` | commit | +| `m` | merge | +| `d` | diff | +| `r` | rebase | +| `b` | branch | +| `t` | tag | +| `l` | log | +| `z` | stash | +| `f` | fetch | +| `e` | ediff | +| `F` | pull | +| `$` | process buffer | +| `P` | push | + +Covers the daily-driver surface, not magit's entire command set — +anything not listed falls through to evil's default on that key +(harmless, just ``). + +## Dired + +Evil `hjkl` grafted onto stock dired commands, remapped only so evil +doesn't swallow dired's own letters. + +| Key | Action | +|-------------|---------------------------| +| `j` / `k` | next / previous line | +| `d` | flag for deletion | +| `h` | up a directory | +| `x` | execute flagged deletions | +| `l` / `RET` | open file or directory | +| `D` | delete now, no flag step | +| `q` | quit window | +| `R` | rename / move | +| `SPC` / `m` | mark | +| `C` | copy | +| `u` | unmark | +| `y` | copy filename as kill | +| `U` | unmark all | +| `!` | shell command on file(s) | +| `t` | toggle marks | +| `+` / `f` | create directory | +| `g r` | refresh listing | +| `a` | create empty file | +| `(` | toggle file details | + +## Evil *(buffer editing only — dired/magit/etc. keep native binds)* + +**Ex commands:** + +| Command | Action | +|---------|---------------------| +| `:w` | save buffer | +| `:q` | kill current buffer | +| `:wq` | save and kill Emacs | + +**Insert state:** + +| Key | Action | +|-------|----------------------------------------------------| +| `TAB` | insert a literal tab (overrides major-mode indent) | + +## In-buffer Completion (Corfu) + +Auto-popup after 2 characters, 0.15s delay. + +| Key | Action | +|-----------------|--------------------| +| `C-n` / `TAB` | next candidate | +| `C-p` / `S-TAB` | previous candidate | +| `RET` | insert selection | +| `` | quit completion | + +## Minibuffer Completion + +Vertico *(centered posframe)* + orderless matching + marginalia + +consult. Type fragments in any order to filter. + +## Misc / Global + +| Key | Action | +|----------------|-----------------------------------| +| `C-S-p` | `execute-extended-command` (M-x) | +| `` | acts like `C-g` everywhere | +| *(any prefix)* | which-key shows available options | + +## Look & Feel + +Theme: **gruvbox-dark-hard**, background/fringe/line-numbers darkened +to `#111111`. Font: JetBrainsMono Nerd Font @ 12pt. Line numbers and +current-line highlight on globally; scroll margin 15 lines. +" + "Reference text shown by `my/toggle-cheatsheet'.") + +(define-derived-mode my/cheatsheet-mode markdown-view-mode "Cheatsheet" + "Read-only `markdown-view-mode' variant for the keybinding cheat sheet.") + +(with-eval-after-load 'evil + (evil-define-key '(normal motion) my/cheatsheet-mode-map + (kbd "q") #'quit-window)) + +(defun my/cheatsheet-buffer () + "Return the cheatsheet buffer, creating and populating it on first use." + (let ((buf (get-buffer-create "*Cheatsheet*"))) + (with-current-buffer buf + (unless (derived-mode-p 'my/cheatsheet-mode) + (let ((inhibit-read-only t)) + (erase-buffer) + (insert my/cheatsheet-text) + (my/cheatsheet-mode) + (goto-char (point-min))))) + buf)) + +(defun my/toggle-cheatsheet () + "Toggle a non-blocking side window showing this config's cheat sheet. +Same show/hide-only-the-window behaviour as `my/toggle-build-panel' — +doesn't rebuild the buffer each time, just shows or hides it." + (interactive) + (let* ((buf (my/cheatsheet-buffer)) + (win (get-buffer-window buf))) + (if win + (delete-window win) + (display-buffer buf)))) + +;; Bound globally (not evil-scoped), matching the build panel keys, so it +;; works the same from normal state, insert state, or anywhere else. +(global-set-key (kbd "C-c /") #'my/toggle-cheatsheet) + +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-c /" "cheatsheet")) + +(provide 'init-cheatsheet) +;;; init-cheatsheet.el ends here diff --git a/lisp/init-completion.el b/lisp/init-completion.el new file mode 100755 index 0000000..621eba8 --- /dev/null +++ b/lisp/init-completion.el @@ -0,0 +1,123 @@ +;;; init-completion.el --- Minibuffer + in-buffer completion -*- lexical-binding: t; -*- + +;;; Commentary: +;; Vertico + Orderless + Marginalia + Consult for minibuffer completion. +;; Corfu provides in-buffer completion. +;; Embark adds contextual actions. + +;;; Code: + +;;;; Helper functions + +(defun my/find-file-below (file) + "Open FILE in a split below." + (interactive "fFile: ") + (select-window (split-window-below)) + (find-file file)) + +(defun my/find-file-right (file) + "Open FILE in a split to the right." + (interactive "fFile: ") + (select-window (split-window-right)) + (find-file file)) + +(defun my/vertico-split-below () + "Open the selected Vertico file candidate below." + (interactive) + (setq unread-command-events (listify-key-sequence "2")) + (embark-act)) + +(defun my/vertico-split-right () + "Open the selected Vertico file candidate to the right." + (interactive) + (setq unread-command-events (listify-key-sequence "3")) + (embark-act)) + +;;;; Vertico + +(use-package vertico + :init + (vertico-mode) + + :custom + (vertico-count 25)) + +(use-package vertico-posframe + :after vertico + :custom + (vertico-posframe-poshandler #'posframe-poshandler-frame-center) + (vertico-posframe-border-width 2) + (vertico-posframe-width 120) + (vertico-posframe-max-width 300) + :config + (vertico-posframe-mode)) + +;;;; Completion styles + +(use-package orderless + :custom + (completion-styles '(orderless basic)) + (completion-category-defaults nil) + (completion-category-overrides + '((file (styles partial-completion))))) + +(use-package marginalia + :init + (marginalia-mode)) + +;;;; In-buffer completion + +(use-package corfu + :init + (global-corfu-mode) + + :custom + (corfu-auto t) + (corfu-auto-delay 0.15) + (corfu-auto-prefix 2) + (corfu-cycle t) + (corfu-preselect 'first) + + :bind + (:map corfu-map + ("C-n" . corfu-next) + ("C-p" . corfu-previous) + ("RET" . corfu-insert) + ("" . corfu-quit)) + + :config + (with-eval-after-load 'evil + (evil-define-key 'insert 'global + (kbd "C-n") #'corfu-next + (kbd "C-p") #'corfu-previous))) + +;;;; Minibuffer commands + +(use-package consult) + +;;;; Contextual actions + +(use-package embark + :after vertico + :bind + (("C-." . embark-act) + :map embark-file-map + ("2" . my/find-file-below) + ("3" . my/find-file-right) + :map vertico-map + ("C-s" . my/vertico-split-below) + ("C-v" . my/vertico-split-right))) + +(use-package embark-consult + :after (embark consult) + :hook + (embark-collect-mode . consult-preview-at-point-mode)) + +;;;; Help/discovery + +(use-package which-key + :config + (which-key-mode)) + +(provide 'init-completion) +;;; init-completion.el ends here diff --git a/lisp/init-dev.el b/lisp/init-dev.el new file mode 100755 index 0000000..fd38a3c --- /dev/null +++ b/lisp/init-dev.el @@ -0,0 +1,95 @@ +;;; init-dev.el --- Language tooling -*- lexical-binding: t; -*- + +;;; Commentary: +;; Per-language setup. Currently Odin (tree-sitter + odin-ts-mode + eglot/OLS) +;; plus markdown-mode. Add each language as its own section. + +;;; Code: + +;;;; Markdown + +(use-package markdown-mode) + +;;;; Odin + +(setq treesit-language-source-alist + '((odin "https://github.com/tree-sitter-grammars/tree-sitter-odin"))) + +(use-package odin-ts-mode + :vc (:url "https://github.com/Sampie159/odin-ts-mode") + :mode "\\.odin\\'") + +;;;; Lua + +(use-package lua-mode + :ensure t) + +;;;; TOML + +(use-package toml-mode + :ensure t) + +;;;; Eglot LSP + +(use-package eglot + :hook (odin-ts-mode . eglot-ensure) + :bind + (:map eglot-mode-map + ("C-c r" . eglot-rename) + ("C-c a" . eglot-code-actions)) + :config + ;; Signature-only ElDoc: OLS hover already includes the signature, so + ;; showing both would duplicate it in the echo area. + (add-hook + 'eglot-managed-mode-hook + (lambda () + (setq-local eldoc-documentation-functions + '(eglot-signature-eldoc-function)))) + + ;; K is context-aware: on a diagnostic (red underline) it shows the error + ;; text, otherwise the full hover payload — both in the same focused pane + ;; (dismiss with q or K). The echo area keeps the live signature. + ;; ] e / [ e jump between diagnostics, echoing each as you land. + (define-derived-mode my/eglot-hover-mode special-mode "Eglot-Hover" + "Major mode for the eglot hover / diagnostic pane.") + + (with-eval-after-load 'evil + (evil-define-key '(normal motion) my/eglot-hover-mode-map + (kbd "q") #'quit-window + (kbd "K") #'quit-window)) + + (defun my/eglot-hover--display (string &rest _) + "Show STRING in a focused *eglot-hover* pane, or message if empty." + (if (and string (not (string-empty-p string))) + (let ((buf (get-buffer-create "*eglot-hover*"))) + (with-current-buffer buf + (let ((inhibit-read-only t)) + (erase-buffer) + (insert string) + (goto-char (point-min))) + (my/eglot-hover-mode)) + (select-window (display-buffer buf))) ; focus the pane + (message "Nothing to show at point"))) + + (defun my/eglot-help-at-point () + "Show diagnostics at point if any, else eglot hover docs — in the pane." + (interactive) + (let ((diags (flymake-diagnostics (point)))) + (if diags + (my/eglot-hover--display + (mapconcat #'flymake-diagnostic-text diags "\n\n")) + (eglot-hover-eldoc-function #'my/eglot-hover--display)))) + + (with-eval-after-load 'evil + (evil-define-key 'normal 'eglot-mode-map + (kbd "K") #'my/eglot-help-at-point + (kbd "] e") #'flymake-goto-next-error + (kbd "[ e") #'flymake-goto-prev-error)) + + (with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-c r" "rename" + "C-c a" "code actions"))) + +(provide 'init-dev) +;;; init-dev.el ends here diff --git a/lisp/init-dired.el b/lisp/init-dired.el new file mode 100755 index 0000000..3ff495e --- /dev/null +++ b/lisp/init-dired.el @@ -0,0 +1,54 @@ +;;; init-dired.el --- Dired with evil-flavoured navigation -*- lexical-binding: t; -*- + +;;; Commentary: +;; Dired stays vanilla — every binding points at a stock dired command. +;; They're remapped only so evil's normal-state keys don't shadow dired's +;; own single-letter commands. + +;;; Code: + +;; Reuse the current dired buffer when descending/ascending (no pileup). +(setq dired-kill-when-opening-new-dired-buffer t) + +;; Order directories first +(setq dired-listing-switches "-agho --group-directories-first") + +(with-eval-after-load 'dired + (evil-define-key 'normal dired-mode-map + ;; Navigation. RET and both bound so evil's global + ;; doesn't shadow them here. + (kbd "j") #'dired-next-line + (kbd "k") #'dired-previous-line + (kbd "h") #'dired-up-directory + (kbd "l") #'dired-find-file + (kbd "RET") #'dired-find-file + [return] #'dired-find-file + (kbd "q") #'quit-window + + ;; Marking. + (kbd "SPC") #'dired-mark + (kbd "m") #'dired-mark + (kbd "u") #'dired-unmark + (kbd "U") #'dired-unmark-all-marks + (kbd "t") #'dired-toggle-marks + + ;; Deletion: d flags, x executes flags, D deletes now. + (kbd "d") #'dired-flag-file-deletion + (kbd "x") #'dired-do-flagged-delete + (kbd "D") #'dired-do-delete + + ;; File operations. + (kbd "R") #'dired-do-rename + (kbd "C") #'dired-do-copy + (kbd "y") #'dired-copy-filename-as-kill + (kbd "!") #'dired-do-shell-command + (kbd "+") #'dired-create-directory + (kbd "f") #'dired-create-directory + (kbd "a") #'dired-create-empty-file + + ;; Misc. + (kbd "g r") #'revert-buffer ; refresh listing + (kbd "(") #'dired-hide-details-mode)) ; toggle perms/size/date columns + +(provide 'init-dired) +;;; init-dired.el ends here diff --git a/lisp/init-evil.el b/lisp/init-evil.el new file mode 100755 index 0000000..536baaf --- /dev/null +++ b/lisp/init-evil.el @@ -0,0 +1,84 @@ +;;; init-evil.el --- Evil, scoped to buffer editing -*- lexical-binding: t; -*- + +;;; Commentary: +;; Evil is deliberately narrow: modal editing in text buffers only, no +;; evil-collection. Dired/Magit/etc. keep native bindings on purpose. +;; The nav keys below use `my/project-or': project-scoped command inside a +;; project, consult fallback everywhere else. + +;;; Code: + +(require 'project) + +(defmacro my/project-or (project-cmd fallback-cmd) + "Call PROJECT-CMD inside a known project, else FALLBACK-CMD. +Both are unquoted command symbols." + `(lambda () + (interactive) + (call-interactively + (if (project-current) + #',project-cmd + #',fallback-cmd)))) + +(defun my/insert-tab () + "Insert a literal tab. +Bound to both TAB and to override major-mode indent in insert state; +`self-insert-command' can't handle the event (its arg is the symbol +`tab', not a character)." + (interactive) + (insert "\t")) + +(use-package evil + :init + (setq evil-want-keybinding nil) + :config + (evil-mode 1) + + ;; Vim commands + (evil-ex-define-cmd "w" #'save-buffer) + (evil-ex-define-cmd "q" #'kill-current-buffer) + (evil-ex-define-cmd "wq" #'save-buffers-kill-terminal) + + ;; Half-page movement + (define-key evil-normal-state-map (kbd "C-d") #'evil-scroll-down) + (define-key evil-normal-state-map (kbd "C-u") #'evil-scroll-up) + + ;; Editor shortcuts + (dolist (map '(evil-normal-state-map + evil-motion-state-map)) + (define-key (symbol-value map) + (kbd "C-e") + (my/project-or project-dired dired-jump)) + + (define-key (symbol-value map) + (kbd "C-s") + #'consult-line) + + ;; C-m is \r (= RET). Bind [return] to evil-ret so GUI Enter never falls + ;; back to this C-m binding, leaving C-m free as its own key (GUI only). + (define-key (symbol-value map) + (kbd "C-m") + (my/project-or project-switch-to-buffer consult-buffer)) + + (define-key (symbol-value map) + [return] + #'evil-ret) + + ;; consult-ripgrep everywhere: it already scopes to the project root when + ;; there is one, and shows hits in the posframe (RET jumps) like the other + ;; finders — unlike project-find-regexp's separate xref pane. + (define-key (symbol-value map) + (kbd "C-f") + #'consult-ripgrep) + + (define-key (symbol-value map) + (kbd "C-p") + (my/project-or project-find-file consult-fd)))) + +;; Literal tab on TAB/ in insert state, overriding major-mode indent. +(with-eval-after-load 'evil + (evil-define-key 'insert 'global (kbd "TAB") #'my/insert-tab) + (evil-define-key 'insert 'global [tab] #'my/insert-tab)) + +(provide 'init-evil) +;;; init-evil.el ends here diff --git a/lisp/init-git.el b/lisp/init-git.el new file mode 100755 index 0000000..a1feb35 --- /dev/null +++ b/lisp/init-git.el @@ -0,0 +1,83 @@ +;;; init-git.el --- Magit configuration -*- lexical-binding: t; -*- + +;;; Commentary: +;; Use Magit with its native keybindings while preserving Evil's window +;; management. +;; +;; Magit buffers are displayed in a dedicated side window on the right. +;; +;; Custom keybindings: +;; C-c C-g - Open Magit +;; C-w - Evil window map +;; j - Next section +;; k - Previous section +;; q - Kill Magit buffer and close the side window + +;;; Code: + +(use-package magit + :after evil + + :bind + (("C-c C-g" . magit-status)) + + :config + + ;; Magit appearance and behaviour. + + ;; Highlight the exact characters that changed. + (setq magit-diff-refine-hunk 'all) + + ;; Show more surrounding context in diffs. + (setq magit-diff-context-lines 5) + + ;; Show all transient options. + (setq transient-default-level 5) + + ;; Don't ask about saving modified buffers. + (setq magit-save-repository-buffers 'dontask) + + ;; Display Magit in a dedicated side window on the right. + + (add-to-list + 'display-buffer-alist + '("\\*magit" + (display-buffer-in-side-window) + (side . right) + (slot . 0) + (window-width . 0.45) + (dedicated . t))) + + ;; Custom commands. + + (defun my/magit-quit () + "Kill the current Magit buffer and close its window." + (interactive) + (quit-window t)) + + ;; Keybindings. + + (dolist (map '(magit-mode-map + magit-status-mode-map + magit-log-mode-map + magit-diff-mode-map + magit-revision-mode-map)) + (with-eval-after-load 'magit + (when (boundp map) + (let ((keymap (symbol-value map))) + ;; Keep Evil window commands. + (define-key keymap (kbd "C-w") evil-window-map) + + ;; Vim-style movement. + (define-key keymap (kbd "j") #'magit-section-forward) + (define-key keymap (kbd "k") #'magit-section-backward) + + ;; Quit Magit. + (define-key keymap (kbd "q") #'my/magit-quit))))) + + (with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-c C-g" "Magit"))) + +(provide 'init-git) +;;; init-git.el ends here diff --git a/lisp/init-project.el b/lisp/init-project.el new file mode 100755 index 0000000..a00d193 --- /dev/null +++ b/lisp/init-project.el @@ -0,0 +1,113 @@ +;;; init-project.el --- Project switching, scoped to ~/Projects -*- lexical-binding: t; -*- + +;;; Commentary: +;; Built on built-in project.el, no third-party manager. Projects are the +;; direct children of ~/Projects, rescanned authoritatively each startup so +;; the list never accumulates stale entries. A "pin" keeps the current +;; project from hijacking to a dependency's checkout on jump-to-definition. + +;;; Code: + +(require 'project) + +;; A directory is a project root if under VC or containing one of these. +(setq project-vc-extra-root-markers '("build.sh")) + +(defvar my/project-search-dirs + '("~/Projects") + "Parent directories scanned for known projects.") + +;;;; Project list: rebuilt from a fresh scan each startup +;; project-remember-project would APPEND to the persisted list and leak stale +;; entries (old paths, stdlib dirs, the config repo). Instead scan ~/Projects +;; and assign project--list directly; a real list also stops project.el +;; re-reading the stale file this session. + +(defun my/scan-projects-under (parent) + "Return `project--list' entries for direct child projects of PARENT. +Ignore symlinks; don't recurse." + (when (file-directory-p parent) + (let (entries) + (dolist (dir (directory-files parent t directory-files-no-dot-files-regexp)) + (when (and (file-directory-p dir) + (not (file-symlink-p dir))) + (let ((default-directory dir)) + (when-let ((project (project-current nil))) + (push (list (abbreviate-file-name (project-root project))) + entries))))) + entries))) + +(setq project--list + (sort (mapcan #'my/scan-projects-under + (mapcar #'expand-file-name my/project-search-dirs)) + (lambda (a b) + (time-less-p + (file-attribute-modification-time (file-attributes (car b))) + (file-attribute-modification-time (file-attributes (car a))))))) + +;; Persist so the on-disk file matches (no stale artifact left behind). +(project--write-project-list) + +;;;; Sticky project detection +;; project.el recomputes the project from the visited buffer's directory, so +;; jumping into a dependency's VC checkout (via `gd') would make THAT the +;; project. Instead pin the last project under our roots and reuse it for any +;; buffer outside them. + +(defvar my/pinned-project nil + "Last project resolved from a buffer under `my/project-search-dirs'.") + +(defun my/own-project-dir-p (dir) + "Return non-nil when DIR lives under one of `my/project-search-dirs'." + (and dir + (seq-some (lambda (parent) + (file-in-directory-p dir (expand-file-name parent))) + my/project-search-dirs))) + +(defun my/project-pinned (dir) + "Resolve DIR to the pinned project when DIR is outside our roots. +Runs ahead of `project-try-vc', so foreign buffers never become current." + (and my/pinned-project + (not (my/own-project-dir-p dir)) + my/pinned-project)) + +(defun my/remember-pinned-project (project) + "Record PROJECT as the pin when the current buffer is one of ours." + (when (and project (my/own-project-dir-p default-directory)) + (setq my/pinned-project project)) + project) + +(add-hook 'project-find-functions #'my/project-pinned) ; before project-try-vc +(advice-add 'project-current :filter-return #'my/remember-pinned-project) + +;;;; Switching and cleanup + +;; Skip the "what do you want to do" menu entirely. +(setq project-switch-commands #'project-dired) + +;; Widen what counts as "belongs to this project" for cleanup. +(setq project-kill-buffer-conditions + '(buffer-file-name + (major-mode . compilation-mode) + (major-mode . dired-mode) + (major-mode . shell-mode) + (major-mode . eshell-mode) + (major-mode . term-mode))) + +(defun my/project-switch-fresh () + "Save and close the current project before switching." + (interactive) + (when (project-current nil) + (save-some-buffers t) + (project-kill-buffers t)) + (call-interactively #'project-switch-project)) + +(global-set-key (kbd "C-c C-p") #'my/project-switch-fresh) + +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-c C-p" "switch project (fresh)" + "C-x p" "project")) + +(provide 'init-project) +;;; init-project.el ends here diff --git a/lisp/init-theme.el b/lisp/init-theme.el new file mode 100755 index 0000000..10e6c37 --- /dev/null +++ b/lisp/init-theme.el @@ -0,0 +1,21 @@ +;;; init-theme.el --- Gruvbox, dark-hard variant -*- lexical-binding: t; -*- + +;;; Commentary: +;; gruvbox-dark-hard, with background/fringe/line-numbers flattened to +;; #111111 and the vertico posframe matched to it. + +;;; Code: + +(use-package gruvbox-theme + :config + (load-theme 'gruvbox-dark-hard t) + (set-face-background 'vertico-posframe "#111111") + (set-face-background 'vertico-posframe-border "#6a9589")) + +(set-face-attribute 'default nil :background "#111111") +(set-face-attribute 'fringe nil :background "#111111") +(set-face-attribute 'line-number nil :background "#111111") +(set-face-attribute 'line-number-current-line nil :background "#111111") + +(provide 'init-theme) +;;; init-theme.el ends here