summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-09-03 15:50:28 +0200
committercashmere <cashmere@autistici.org>2026-09-03 15:50:28 +0200
commit91ee05480922ed885973ece11a13ed7cf0b43f16 (patch)
tree447e42bee33ea03b8b55340bb4be787ca88fa08a
parenta44f66e0b6ea0a6fafa5d998a338baea724ed7f3 (diff)
downloademacs-91ee05480922ed885973ece11a13ed7cf0b43f16.tar.gz
md: 2026-09-03T15:50:32+02:00
-rw-r--r--.gitignore3
-rwxr-xr-xbin/project-ctags156
-rw-r--r--config.el163
-rw-r--r--config.org168
-rw-r--r--lisp/citre-backend.el138
-rw-r--r--lisp/citre-index.el469
-rw-r--r--lisp/my-header-line.el162
-rw-r--r--lisp/my-quick-access.el419
-rw-r--r--themes/tinty-theme.el146
9 files changed, 1596 insertions, 228 deletions
diff --git a/.gitignore b/.gitignore
index c6b26cc..158f103 100644
--- a/.gitignore
+++ b/.gitignore
@@ -104,3 +104,6 @@ dirvish/thumbnails
/elfeed/
/cache/
/clatter/
+
+# Automatic Citre project indexes and session snapshots
+/citre-cache/
diff --git a/bin/project-ctags b/bin/project-ctags
index f0216d0..ec28964 100755
--- a/bin/project-ctags
+++ b/bin/project-ctags
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
import argparse
import ast
+import fcntl
+import hashlib
import json
import os
import shlex
@@ -8,6 +10,7 @@ import shutil
import subprocess
import sys
import tempfile
+import time
from pathlib import Path
IGNORED_DIRECTORIES = {
@@ -85,6 +88,12 @@ def parse_arguments():
parser.add_argument("--no-python-deps", action="store_true")
parser.add_argument("--no-rust-deps", action="store_true")
parser.add_argument("--no-c-deps", action="store_true")
+ parser.add_argument("--cache-dir", help="Maintain project and dependency indexes here")
+ parser.add_argument("--force", action="store_true")
+ parser.add_argument("--snapshot-request", help="JSON list of source/snapshot/output mappings")
+ parser.add_argument("--exclude-dir", action="append", default=[])
+ parser.add_argument("--watch-limit", type=int, default=256)
+ parser.add_argument("--dependency-check-interval", type=float, default=300)
return parser.parse_args()
@@ -405,7 +414,7 @@ def extra_source_files(args, root):
return sorted(files)
-def generate_tags(ctags, output, files):
+def generate_tags(ctags, output, files, mappings=None):
output.parent.mkdir(parents=True, exist_ok=True)
list_file = tempfile.NamedTemporaryFile(mode="w", prefix="project-ctags-", delete=False)
temporary_output = tempfile.NamedTemporaryFile(
@@ -414,6 +423,8 @@ def generate_tags(ctags, output, files):
temporary_output.close()
try:
for path in files:
+ if "\n" in str(path) or "\r" in str(path):
+ raise ValueError(f"Ctags file lists cannot represent this path: {path!r}")
list_file.write(f"{path}\n")
list_file.close()
command = [
@@ -433,6 +444,11 @@ def generate_tags(ctags, output, files):
result = subprocess.run(command, check=False)
if result.returncode != 0:
raise SystemExit(result.returncode)
+ if mappings:
+ data = Path(temporary_output.name).read_bytes()
+ for source, original in mappings.items():
+ data = data.replace(b"\t" + tag_path(source) + b"\t", b"\t" + tag_path(original) + b"\t")
+ Path(temporary_output.name).write_bytes(data)
os.replace(temporary_output.name, output)
finally:
list_file.close()
@@ -440,6 +456,138 @@ def generate_tags(ctags, output, files):
Path(temporary_output.name).unlink(missing_ok=True)
+
+def tag_path(path):
+ """Encode filenames in the tags input field."""
+ return os.fsencode(path).replace(b"\\", b"\\\\").replace(b"\t", b"\\t").replace(b"\n", b"\\n").replace(b"\r", b"\\r")
+
+
+def file_stamp(path):
+ try:
+ value = Path(path).stat()
+ return [value.st_mtime_ns, value.st_ctime_ns, value.st_size]
+ except OSError:
+ return None
+
+
+def atomic_json(path, value):
+ with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as stream:
+ temporary = Path(stream.name)
+ json.dump(value, stream)
+ try:
+ os.replace(temporary, path)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def incremental_tags(ctags, output, current, previous, force=False):
+ """Parse changed files and replace their old records, including empty/deleted files."""
+ changed = {name for name, stamp in current.items() if previous.get(name) != stamp}
+ removed = previous.keys() - current.keys()
+ if force or not output.exists():
+ generate_tags(ctags, output, [Path(name) for name in current])
+ return True
+ if not changed and not removed:
+ return False
+ with tempfile.TemporaryDirectory(prefix="update-", dir=output.parent) as temporary:
+ delta = Path(temporary) / "delta.tags"
+ generate_tags(ctags, delta, [Path(name) for name in sorted(changed)])
+ replaced = {tag_path(name) for name in changed | removed}
+ headers = {}
+ records = []
+ for source, old in ((output, True), (delta, False)):
+ for line in source.read_bytes().splitlines(keepends=True):
+ if line.startswith(b"!_TAG_"):
+ headers[line.split(b"\t", 1)[0]] = line
+ elif line.strip():
+ fields = line.split(b"\t", 2)
+ if not old or (len(fields) > 1 and fields[1] not in replaced):
+ records.append(line)
+ merged = Path(temporary) / "merged.tags"
+ merged.write_bytes(b"".join(headers.values()) + b"".join(sorted(set(records))))
+ os.replace(merged, output)
+ return True
+
+
+def dependency_signature(args, root, files):
+ """Track declarations without resolving dependencies on every source edit."""
+ declarations = []
+ for path in files:
+ if path.name in {"Cargo.toml", "Cargo.lock", "pyproject.toml", "uv.lock", "poetry.lock", "pixi.lock", "pixi.toml", "requirements.txt", "compile_commands.json", "flake.lock", "flake.nix", ".envrc"}:
+ declarations.append((str(path), file_stamp(path)))
+ elif path.suffix.lower() in C_SOURCE_SUFFIXES | C_HEADER_SUFFIXES:
+ try:
+ includes = [line.strip() for line in path.read_text(errors="replace").splitlines() if line.lstrip().startswith(("#include", "# include"))]
+ declarations.append((str(path), includes))
+ except OSError:
+ pass
+ value = [imported_modules(files), declarations,
+ {key: item for key, item in vars(args).items() if key != "force"},
+ {key: os.environ.get(key) for key in ("PATH", "VIRTUAL_ENV", "CC", "CFLAGS", "CPPFLAGS", "PROJECT_CTAGS_PYTHON_COMMAND", "PROJECT_CTAGS_CARGO", "PROJECT_CTAGS_SOURCES")},
+ [(str(root / name), file_stamp(root / name)) for name in (".venv", ".pixi/envs/default", ".devenv/state/venv")]]
+ return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
+
+
+def refresh_cache(args, root, ctags):
+ cache = resolved_path(args.cache_dir, root)
+ cache.mkdir(parents=True, exist_ok=True)
+ # Different Emacs instances may share a cache; only the worker waits for this lock.
+ with (cache / "lock").open("w") as lock:
+ fcntl.flock(lock, fcntl.LOCK_EX)
+ manifest_path = cache / "manifest.json"
+ try:
+ old = json.loads(manifest_path.read_text())
+ except (OSError, ValueError):
+ old = {}
+ identity = [3, str(root), str(ctags), file_stamp(ctags), file_stamp(__file__), args.exclude_dir]
+ force = args.force or old.get("identity") != identity
+ project_output = cache / "project.tags"
+ dependency_output = cache / "dependencies.tags"
+ excluded = set(args.exclude_dir)
+ files = [path for path in project_files(root, resolved_path(args.output, root))
+ if not path.is_relative_to(cache)
+ and not any(part in excluded for part in path.parts)]
+ current = {str(path): file_stamp(path) for path in files}
+ project_changed = incremental_tags(ctags, project_output, current, old.get("files", {}),
+ force or old.get("project_stamp") != file_stamp(project_output))
+ # Publish project readiness before slower dependency discovery finishes.
+ print(json.dumps({"event": "project", "changed": project_changed}), flush=True)
+ signature = dependency_signature(args, root, files)
+ resolve = force or old.get("dependency_signature") != signature or time.time() - old.get("dependency_checked", 0) >= args.dependency_check_interval
+ dependencies = old.get("dependencies", {})
+ checked = old.get("dependency_checked", 0)
+ if resolve:
+ dependency_files = []
+ if not args.no_python_deps:
+ dependency_files.extend(python_dependency_files(args, root, files)[0])
+ if not args.no_rust_deps:
+ dependency_files.extend(rust_dependency_files(args, root)[0])
+ if not args.no_c_deps:
+ dependency_files.extend(c_dependency_files(root, files))
+ dependency_files.extend(extra_source_files(args, root))
+ dependencies = {str(path): file_stamp(path) for path in sorted(set(dependency_files)) if str(path) not in current}
+ checked = time.time()
+ else:
+ dependencies = {name: stamp for name in dependencies if (stamp := file_stamp(name)) is not None}
+ dependency_changed = incremental_tags(ctags, dependency_output, dependencies, old.get("dependencies", {}),
+ force or old.get("dependency_stamp") != file_stamp(dependency_output))
+ manifest = {"identity": identity, "files": current, "dependencies": dependencies,
+ "dependency_signature": signature, "dependency_checked": checked,
+ "project_stamp": file_stamp(project_output), "dependency_stamp": file_stamp(dependency_output),
+ "directories": sorted({str(path.parent) for path in files} | {str(root)}),
+ "checked": time.time()}
+ atomic_json(manifest_path, manifest)
+ print(json.dumps({"event": "complete", "changed": project_changed or dependency_changed,
+ "project_files": len(files), "dependency_files": len(dependencies),
+ "directories": manifest["directories"][:max(0, args.watch_limit)]}), flush=True)
+
+
+def snapshot_tags(args, ctags):
+ for entry in json.loads(Path(args.snapshot_request).read_text()):
+ source = Path(entry["snapshot"])
+ generate_tags(ctags, Path(entry["output"]), [source], {source: Path(entry["source"])})
+
+
def main():
args = parse_arguments()
root = resolved_path(args.root, Path.cwd())
@@ -450,6 +598,12 @@ def main():
if ctags is None:
raise SystemExit("Universal Ctags is not available")
+ if args.snapshot_request:
+ snapshot_tags(args, ctags)
+ return
+ if args.cache_dir:
+ refresh_cache(args, root, ctags)
+ return
local_files = project_files(root, output)
python_files = []
python = None
diff --git a/config.el b/config.el
index d4c9f86..fa3e4c8 100644
--- a/config.el
+++ b/config.el
@@ -109,6 +109,7 @@ Set per-host in the gitignored `local.el'.")
(warning-minimum-level :emergency) ;; Set the minimum level of warnings to display.
:hook ;; Add hooks to enable specific features in certain modes.
(prog-mode . display-line-numbers-mode)
+ (conf-mode . display-line-numbers-mode)
(markdown-mode . display-line-numbers-mode)
(text-mode . visual-line-mode) ;; Soft-wrap prose/text files at window edge.
(conf-mode . visual-line-mode) ;; Soft-wrap .conf and similar config files.
@@ -2156,13 +2157,45 @@ Timers that expired while Emacs was closed fire immediately."
:demand t
:hook (after-init . global-company-mode)
:custom
- (company-idle-delay nil)
- ;; (company-minimum-prefix-length 2)
+ (company-idle-delay 0.1)
+ (company-minimum-prefix-length 2)
(company-require-match nil)
- ;; Limit the visible menu, not the candidate set. Truncating candidates
- ;; here made valid mu4e contacts and IRC nicks impossible to select.
- (company-tooltip-limit 4)
- (company-backends '(company-capf)))
+ (company-insertion-on-trigger nil)
+ (company-tooltip-limit 8)
+ (company-tooltip-align-annotations t)
+ (company-selection-wrap-around t)
+ (company-backends '(company-capf))
+ (company-frontends '(my/company-popup-frontend company-echo-metadata-frontend))
+ :config
+ (require 'company-childframe)
+ (defun my/company-popup-frontend (command)
+ "Use an aligned child frame in graphical Emacs and an overlay in terminals."
+ (cond
+ ((eq command 'hide)
+ (company-childframe-hide)
+ (company-pseudo-tooltip-hide))
+ ((display-graphic-p)
+ (company-childframe-frontend command))
+ (t
+ (company-pseudo-tooltip-frontend command))))
+ (setq company-selection-default 0)
+ (defun my/company-abort-and-normal-state ()
+ "Close completion and leave Evil insert state."
+ (interactive)
+ (company-abort)
+ (when (bound-and-true-p evil-local-mode)
+ (evil-normal-state)))
+ (defun my/company-navigation-keys (&rest _)
+ "Keep completion navigation consistent after Evil Collection loads."
+ (dolist (key '("TAB" "<tab>" "C-n" "C-j"))
+ (keymap-set company-active-map key #'company-select-next))
+ (dolist (key '("<backtab>" "S-TAB" "C-p" "C-k"))
+ (keymap-set company-active-map key #'company-select-previous))
+ (keymap-set company-active-map "RET" #'company-complete-selection)
+ (keymap-set company-active-map "<return>" #'company-complete-selection)
+ (keymap-set company-active-map "<escape>" #'my/company-abort-and-normal-state))
+ (my/company-navigation-keys)
+ (add-hook 'evil-collection-setup-hook #'my/company-navigation-keys))
(use-package which-key
:ensure t
@@ -2238,65 +2271,47 @@ Timers that expired while Emacs was closed fire immediately."
(citre-default-create-tags-file-location 'in-dir)
(citre-edit-ctags-options-manually nil)
(citre-auto-enable-citre-mode-modes '(prog-mode))
+ (citre-completion-backends '(my-index))
+ (citre-find-definition-backends '(my-index))
+ (citre-identifier-list-backends '(my-index))
+ (citre-tags-in-buffer-backends '(my-index))
+ (citre-find-reference-backends '(global))
+ (citre-capf-optimize-for-popup t)
:config
- (defvar my/project-ctags-program
- (expand-file-name "bin/project-ctags" user-emacs-directory))
-
- (defvar-local my/project-ctags-arguments nil)
-
- (put 'my/project-ctags-arguments 'safe-local-variable
- (lambda (value)
- (and (listp value) (seq-every-p #'stringp value))))
-
- (defun my/project-ctags-root ()
- (or (projectile-project-root)
- (user-error "Not in a Projectile project")))
-
- (defun my/project-ctags-enable-citre (root)
- (dolist (buffer (buffer-list))
- (with-current-buffer buffer
- (when (and buffer-file-name
- (file-in-directory-p buffer-file-name root))
- (citre-mode 1)))))
-
- (defun my/project-ctags-sentinel (process _event)
- (when (memq (process-status process) '(exit signal))
- (let ((root (process-get process 'project-root)))
- (if (and (eq (process-status process) 'exit)
- (zerop (process-exit-status process)))
- (progn
- (citre-clear-tags-file-cache)
- (my/project-ctags-enable-citre root)
- (message "Ctags ready for %s" root))
- (display-buffer (process-buffer process))
- (message "Ctags failed for %s; see %s"
- root
- (buffer-name (process-buffer process)))))))
-
- (defun my/project-ctags-start (root arguments)
- (unless (file-executable-p my/project-ctags-program)
- (user-error "Project Ctags generator is not executable: %s"
- my/project-ctags-program))
- (let ((output (get-buffer-create "*project-ctags*")))
- (with-current-buffer output
- (erase-buffer))
- (let ((process
- (make-process
- :name (format "ctags:%s"
- (file-name-nondirectory
- (directory-file-name root)))
- :buffer output
- :command (append (list my/project-ctags-program root)
- arguments)
- :connection-type 'pipe
- :sentinel #'my/project-ctags-sentinel)))
- (process-put process 'project-root root)))
- (message "Generating Ctags for %s..." root))
-
- (defun my/project-ctags-setup ()
- (interactive)
- (my/project-ctags-start (my/project-ctags-root)
- my/project-ctags-arguments)))
+ (require 'citre-index)
+ (require 'citre-backend)
+ (setq my/project-ctags-program (expand-file-name "bin/project-ctags" user-emacs-directory)
+ my/citre-index-cache-directory (expand-file-name "citre-cache/" user-emacs-directory)
+ my/citre-index-idle-delay 0.1
+ my/citre-index-refresh-delay 0.2
+ my/citre-index-check-interval 30
+ my/citre-index-dependency-check-interval 300
+ my/citre-index-max-buffer-size (* 2 1024 1024)
+ my/citre-index-max-watches 256
+ my/citre-index-process-timeout 120
+ my/citre-index-excluded-directories
+ '(".git" ".hg" ".svn" ".direnv" ".venv" ".pixi" "__pycache__"
+ "node_modules" "target" "build" "dist" "citre-cache"
+ "elpaca" "elpa" "eln-cache" "tree-sitter" "workspaces"))
+ (defun my/citre-completion-at-point ()
+ "Allow buffer-word fallback when Citre has no matching completion."
+ (when-let* ((completion (citre-completion-at-point)))
+ (append completion '(:exclusive no))))
+ (defun my/citre-completion-sources ()
+ "Use file completion before language sources and buffer words as a fallback."
+ (when (derived-mode-p 'prog-mode)
+ (setq-local completion-at-point-functions
+ (cl-subst #'my/citre-completion-at-point #'citre-completion-at-point completion-at-point-functions))
+ (add-hook 'completion-at-point-functions #'cape-file nil t)
+ (add-hook 'completion-at-point-functions #'cape-dabbrev 90 t)))
+ (defun my/citre-auto-enable ()
+ "Let Citre enable unmanaged buffers without toggling an already active mode."
+ (unless (bound-and-true-p citre-mode)
+ (citre-auto-enable-citre-mode)))
+ (remove-hook 'find-file-hook #'citre-auto-enable-citre-mode)
+ (add-hook 'find-file-hook #'my/citre-auto-enable 90)
+ (add-hook 'citre-mode-hook #'my/citre-completion-sources)
+ (my/citre-index-mode 1))
(use-package nov
:ensure t
@@ -2758,6 +2773,7 @@ so the working-tree diff stays visible until the user explicitly stages."
((dired-mode . diredfl-mode)
(dirvish-directory-view-mode . diredfl-mode)))
+(setq display-time-default-load-average nil)
(display-time-mode 1)
(defvar my/mode-line--format
@@ -3354,6 +3370,18 @@ place. `C-c C-c' commits, `C-c C-k' aborts."
(ibuffer nil (format "*ibuffer: %s*" name)
(list (cons 'persp t))))))
+(use-package my-quick-access
+ :ensure nil
+ :after persp-mode
+ :demand t
+ :custom
+ (my/quick-access-recent-count 5) ; Recent buffers, excluding the active buffer.
+ (my/quick-access-selection-keys '("f" "j" "d" "k" "g" "h")) ; Recent selection and initial collisions.
+ (my/quick-access-page-size 6) ; Maximum visible entries before paging.
+ (my/quick-access-max-height 0.48) ; At most 48% of the frame height.
+ :config
+ (my/quick-access-mode 1))
+
(use-package pass
:ensure t
:defer t
@@ -3525,6 +3553,7 @@ place. `C-c C-c' commits, `C-c C-k' aborts."
(keymap-set my/leader-project-map "i" #'projectile-invalidate-cache)
(keymap-set my/leader-project-map "t" #'projectile-run-task)
(keymap-set my/leader-project-map "c" #'my/project-ctags-setup)
+(keymap-set my/leader-project-map "C" #'my/citre-index-status)
(keymap-set my/leader-map "g" my/leader-git-map)
(keymap-set my/leader-git-map "c" #'magit-clone)
@@ -3689,7 +3718,12 @@ place. `C-c C-c' commits, `C-c C-k' aborts."
(evil-define-key 'insert 'global (kbd "C-w") evil-window-map)
+(keymap-global-set "M-b" #'my/quick-access)
+(evil-define-key '(normal visual motion insert emacs) 'global
+ (kbd "M-b") #'my/quick-access)
+
(evil-define-key 'normal 'global
+ (kbd "t") #'my/toggle-header-line-workspaces
(kbd "K") #'my/eldoc-and-jump
(kbd "] d") #'flycheck-next-error
(kbd "[ d") #'flycheck-previous-error
@@ -3870,6 +3904,7 @@ place. `C-c C-c' commits, `C-c C-k' aborts."
(evil-define-key '(normal insert) helm-map
(kbd "C-j") #'helm-next-line
(kbd "C-k") #'helm-previous-line
+ (kbd "C-z") #'helm-toggle-full-frame
(kbd "C-g") #'helm-keyboard-quit)
(evil-define-key 'normal helm-map
(kbd "<escape>") #'helm-keyboard-quit
diff --git a/config.org b/config.org
index 3013e21..dd3f2ee 100644
--- a/config.org
+++ b/config.org
@@ -144,6 +144,7 @@ normal package updates cannot silently rewrite pinned revisions.
(warning-minimum-level :emergency) ;; Set the minimum level of warnings to display.
:hook ;; Add hooks to enable specific features in certain modes.
(prog-mode . display-line-numbers-mode)
+ (conf-mode . display-line-numbers-mode)
(markdown-mode . display-line-numbers-mode)
(text-mode . visual-line-mode) ;; Soft-wrap prose/text files at window edge.
(conf-mode . visual-line-mode) ;; Soft-wrap .conf and similar config files.
@@ -2531,13 +2532,45 @@ Auto switch silos (different workspaces/folders) based on preconfigured director
:demand t
:hook (after-init . global-company-mode)
:custom
- (company-idle-delay nil)
- ;; (company-minimum-prefix-length 2)
+ (company-idle-delay 0.1)
+ (company-minimum-prefix-length 2)
(company-require-match nil)
- ;; Limit the visible menu, not the candidate set. Truncating candidates
- ;; here made valid mu4e contacts and IRC nicks impossible to select.
- (company-tooltip-limit 4)
- (company-backends '(company-capf)))
+ (company-insertion-on-trigger nil)
+ (company-tooltip-limit 8)
+ (company-tooltip-align-annotations t)
+ (company-selection-wrap-around t)
+ (company-backends '(company-capf))
+ (company-frontends '(my/company-popup-frontend company-echo-metadata-frontend))
+ :config
+ (require 'company-childframe)
+ (defun my/company-popup-frontend (command)
+ "Use an aligned child frame in graphical Emacs and an overlay in terminals."
+ (cond
+ ((eq command 'hide)
+ (company-childframe-hide)
+ (company-pseudo-tooltip-hide))
+ ((display-graphic-p)
+ (company-childframe-frontend command))
+ (t
+ (company-pseudo-tooltip-frontend command))))
+ (setq company-selection-default 0)
+ (defun my/company-abort-and-normal-state ()
+ "Close completion and leave Evil insert state."
+ (interactive)
+ (company-abort)
+ (when (bound-and-true-p evil-local-mode)
+ (evil-normal-state)))
+ (defun my/company-navigation-keys (&rest _)
+ "Keep completion navigation consistent after Evil Collection loads."
+ (dolist (key '("TAB" "<tab>" "C-n" "C-j"))
+ (keymap-set company-active-map key #'company-select-next))
+ (dolist (key '("<backtab>" "S-TAB" "C-p" "C-k"))
+ (keymap-set company-active-map key #'company-select-previous))
+ (keymap-set company-active-map "RET" #'company-complete-selection)
+ (keymap-set company-active-map "<return>" #'company-complete-selection)
+ (keymap-set company-active-map "<escape>" #'my/company-abort-and-normal-state))
+ (my/company-navigation-keys)
+ (add-hook 'evil-collection-setup-hook #'my/company-navigation-keys))
#+end_src
* which-key
@@ -2651,65 +2684,47 @@ Auto switch silos (different workspaces/folders) based on preconfigured director
(citre-default-create-tags-file-location 'in-dir)
(citre-edit-ctags-options-manually nil)
(citre-auto-enable-citre-mode-modes '(prog-mode))
+ (citre-completion-backends '(my-index))
+ (citre-find-definition-backends '(my-index))
+ (citre-identifier-list-backends '(my-index))
+ (citre-tags-in-buffer-backends '(my-index))
+ (citre-find-reference-backends '(global))
+ (citre-capf-optimize-for-popup t)
:config
- (defvar my/project-ctags-program
- (expand-file-name "bin/project-ctags" user-emacs-directory))
-
- (defvar-local my/project-ctags-arguments nil)
-
- (put 'my/project-ctags-arguments 'safe-local-variable
- (lambda (value)
- (and (listp value) (seq-every-p #'stringp value))))
-
- (defun my/project-ctags-root ()
- (or (projectile-project-root)
- (user-error "Not in a Projectile project")))
-
- (defun my/project-ctags-enable-citre (root)
- (dolist (buffer (buffer-list))
- (with-current-buffer buffer
- (when (and buffer-file-name
- (file-in-directory-p buffer-file-name root))
- (citre-mode 1)))))
-
- (defun my/project-ctags-sentinel (process _event)
- (when (memq (process-status process) '(exit signal))
- (let ((root (process-get process 'project-root)))
- (if (and (eq (process-status process) 'exit)
- (zerop (process-exit-status process)))
- (progn
- (citre-clear-tags-file-cache)
- (my/project-ctags-enable-citre root)
- (message "Ctags ready for %s" root))
- (display-buffer (process-buffer process))
- (message "Ctags failed for %s; see %s"
- root
- (buffer-name (process-buffer process)))))))
-
- (defun my/project-ctags-start (root arguments)
- (unless (file-executable-p my/project-ctags-program)
- (user-error "Project Ctags generator is not executable: %s"
- my/project-ctags-program))
- (let ((output (get-buffer-create "*project-ctags*")))
- (with-current-buffer output
- (erase-buffer))
- (let ((process
- (make-process
- :name (format "ctags:%s"
- (file-name-nondirectory
- (directory-file-name root)))
- :buffer output
- :command (append (list my/project-ctags-program root)
- arguments)
- :connection-type 'pipe
- :sentinel #'my/project-ctags-sentinel)))
- (process-put process 'project-root root)))
- (message "Generating Ctags for %s..." root))
-
- (defun my/project-ctags-setup ()
- (interactive)
- (my/project-ctags-start (my/project-ctags-root)
- my/project-ctags-arguments)))
+ (require 'citre-index)
+ (require 'citre-backend)
+ (setq my/project-ctags-program (expand-file-name "bin/project-ctags" user-emacs-directory)
+ my/citre-index-cache-directory (expand-file-name "citre-cache/" user-emacs-directory)
+ my/citre-index-idle-delay 0.1
+ my/citre-index-refresh-delay 0.2
+ my/citre-index-check-interval 30
+ my/citre-index-dependency-check-interval 300
+ my/citre-index-max-buffer-size (* 2 1024 1024)
+ my/citre-index-max-watches 256
+ my/citre-index-process-timeout 120
+ my/citre-index-excluded-directories
+ '(".git" ".hg" ".svn" ".direnv" ".venv" ".pixi" "__pycache__"
+ "node_modules" "target" "build" "dist" "citre-cache"
+ "elpaca" "elpa" "eln-cache" "tree-sitter" "workspaces"))
+ (defun my/citre-completion-at-point ()
+ "Allow buffer-word fallback when Citre has no matching completion."
+ (when-let* ((completion (citre-completion-at-point)))
+ (append completion '(:exclusive no))))
+ (defun my/citre-completion-sources ()
+ "Use file completion before language sources and buffer words as a fallback."
+ (when (derived-mode-p 'prog-mode)
+ (setq-local completion-at-point-functions
+ (cl-subst #'my/citre-completion-at-point #'citre-completion-at-point completion-at-point-functions))
+ (add-hook 'completion-at-point-functions #'cape-file nil t)
+ (add-hook 'completion-at-point-functions #'cape-dabbrev 90 t)))
+ (defun my/citre-auto-enable ()
+ "Let Citre enable unmanaged buffers without toggling an already active mode."
+ (unless (bound-and-true-p citre-mode)
+ (citre-auto-enable-citre-mode)))
+ (remove-hook 'find-file-hook #'citre-auto-enable-citre-mode)
+ (add-hook 'find-file-hook #'my/citre-auto-enable 90)
+ (add-hook 'citre-mode-hook #'my/citre-completion-sources)
+ (my/citre-index-mode 1))
#+end_src
* nov
@@ -3365,6 +3380,7 @@ Native =undo-redo= (via =evil-undo-system='undo-redo=); just raise the undo limi
** display-time
#+begin_src emacs-lisp
+(setq display-time-default-load-average nil)
(display-time-mode 1)
#+end_src
@@ -3722,7 +3738,6 @@ This function records the time when `window-setup-hook' runs."
(message "Startup time was not recorded.")))
#+end_src
-
* ibuffer hide
#+begin_src emacs-lisp
@@ -4061,6 +4076,22 @@ place. `C-c C-c' commits, `C-c C-k' aborts."
(list (cons 'persp t))))))
#+end_src
+** quick-access
+
+#+begin_src emacs-lisp
+(use-package my-quick-access
+ :ensure nil
+ :after persp-mode
+ :demand t
+ :custom
+ (my/quick-access-recent-count 5) ; Recent buffers, excluding the active buffer.
+ (my/quick-access-selection-keys '("f" "j" "d" "k" "g" "h")) ; Recent selection and initial collisions.
+ (my/quick-access-page-size 6) ; Maximum visible entries before paging.
+ (my/quick-access-max-height 0.48) ; At most 48% of the frame height.
+ :config
+ (my/quick-access-mode 1))
+#+end_src
+
* pass
#+begin_src emacs-lisp
@@ -4256,6 +4287,7 @@ place. `C-c C-c' commits, `C-c C-k' aborts."
(keymap-set my/leader-project-map "i" #'projectile-invalidate-cache)
(keymap-set my/leader-project-map "t" #'projectile-run-task)
(keymap-set my/leader-project-map "c" #'my/project-ctags-setup)
+(keymap-set my/leader-project-map "C" #'my/citre-index-status)
(keymap-set my/leader-map "g" my/leader-git-map)
(keymap-set my/leader-git-map "c" #'magit-clone)
@@ -4428,7 +4460,12 @@ place. `C-c C-c' commits, `C-c C-k' aborts."
(evil-define-key 'insert 'global (kbd "C-w") evil-window-map)
+(keymap-global-set "M-b" #'my/quick-access)
+(evil-define-key '(normal visual motion insert emacs) 'global
+ (kbd "M-b") #'my/quick-access)
+
(evil-define-key 'normal 'global
+ (kbd "t") #'my/toggle-header-line-workspaces
(kbd "K") #'my/eldoc-and-jump
(kbd "] d") #'flycheck-next-error
(kbd "[ d") #'flycheck-previous-error
@@ -4683,6 +4720,7 @@ place. `C-c C-c' commits, `C-c C-k' aborts."
(evil-define-key '(normal insert) helm-map
(kbd "C-j") #'helm-next-line
(kbd "C-k") #'helm-previous-line
+ (kbd "C-z") #'helm-toggle-full-frame
(kbd "C-g") #'helm-keyboard-quit)
(evil-define-key 'normal helm-map
(kbd "<escape>") #'helm-keyboard-quit
diff --git a/lisp/citre-backend.el b/lisp/citre-backend.el
new file mode 100644
index 0000000..8c50c6d
--- /dev/null
+++ b/lisp/citre-backend.el
@@ -0,0 +1,138 @@
+;;; citre-backend.el --- Live and persistent tags through Citre -*- lexical-binding: t; -*-
+
+(require 'citre)
+(require 'citre-tags)
+(require 'citre-index)
+
+(defvar company--capf-cache)
+(defvar-local my/citre-backend--completion-cache nil)
+
+;; Retire the popup restart timer when reloading an earlier version of this module.
+(when (boundp 'my/citre-backend--company-timer)
+ (my/citre-index--cancel-timer (symbol-value 'my/citre-backend--company-timer))
+ (set 'my/citre-backend--company-timer nil))
+
+(defun my/citre-backend--sources ()
+ "Return (TAGS-FILE . LIVE-SOURCE) pairs in priority order."
+ (if-let* ((state my/citre-index--state))
+ (let* ((entries (my/citre-index-live-entries state))
+ (current (my/citre-index--path))
+ (live (sort entries (lambda (a b) (and (equal (car a) current) (not (equal (car b) current)))))))
+ (append
+ (mapcar (lambda (entry) (cons (plist-get (cdr entry) :file) (car entry))) live)
+ (let ((project (expand-file-name "project.tags" (my/citre-index-state-cache state))))
+ (if (file-readable-p project)
+ (list (cons project nil))
+ (when-let* ((legacy (citre-tags-file-path))) (list (cons legacy nil)))))
+ (let ((dependencies (expand-file-name "dependencies.tags" (my/citre-index-state-cache state))))
+ (when (file-readable-p dependencies) (list (cons dependencies nil))))))
+ (when-let* ((file (citre-tags-file-path))) (list (cons file nil)))))
+
+(defun my/citre-backend--query (operation &optional identifier)
+ "Merge tags for OPERATION, optionally using IDENTIFIER, while masking superseded files."
+ (let* ((sources (my/citre-backend--sources))
+ (masked (delq nil (mapcar #'cdr sources)))
+ (seen (make-hash-table :test #'equal))
+ result)
+ (dolist (source sources)
+ (let* ((file (car source))
+ (symbol (or (and identifier
+ (citre-put-property (copy-sequence identifier)
+ 'file-path buffer-file-name 'tags-file file))
+ (and (not (memq operation '(identifiers imenu)))
+ (citre-tags-get-symbol file))))
+ (filter
+ (pcase operation
+ ('completion (when symbol (or (citre-tags--get-value-in-language-alist :completion-filter symbol)
+ (citre-tags-completion-default-filter symbol))))
+ ('definition (when symbol (or (citre-tags--get-value-in-language-alist :definition-filter symbol)
+ (citre-tags-definition-default-filter symbol))))
+ ('imenu (list 'and (citre-readtags-filter-input (my/citre-index--path) file)
+ (list 'not citre-tags-filter-file-tags)))
+ (_ (list 'not citre-tags-filter-file-tags))))
+ (sorter
+ (pcase operation
+ ('completion (when symbol (or (citre-tags--get-value-in-language-alist :completion-sorter symbol)
+ citre-tags-completion-default-sorter)))
+ ('imenu (citre-readtags-sorter 'line))
+ (_ citre-tags-definition-default-sorter)))
+ (tags (when (or symbol (memq operation '(identifiers imenu)))
+ (citre-tags-get-tags
+ file symbol (if (eq operation 'completion) (if citre-tags-substr-completion 'substr 'prefix) 'exact)
+ :filter filter :sorter sorter
+ :require '(name ext-abspath)
+ :optional '(pattern line end ext-kind-full signature scope typeref extras language)))))
+ (dolist (tag tags)
+ (let* ((path (citre-get-tag-field 'ext-abspath tag))
+ (key (mapcar (lambda (field) (citre-get-tag-field field tag))
+ '(name ext-abspath line scope ext-kind-full))))
+ (unless (or (and (not (cdr source)) (member path masked)) (gethash key seen))
+ (puthash key t seen)
+ (push tag result))))))
+ (nreverse result)))
+
+(defun my/citre-backend-completions ()
+ "Return current Citre completion bounds and tags, reusing a narrowing prefix."
+ (when-let* ((source (car (my/citre-backend--sources)))
+ (symbol (citre-tags-get-symbol (car source)))
+ (bounds (citre-get-property 'bounds symbol)))
+ (let* ((revision (and my/citre-index--state (my/citre-index-state-revision my/citre-index--state)))
+ (context (list revision (car bounds) major-mode citre-tags-substr-completion
+ (buffer-substring-no-properties (line-beginning-position) (car bounds))))
+ (cache my/citre-backend--completion-cache)
+ (tags (if (and revision (equal context (plist-get cache :context))
+ (string-prefix-p (plist-get cache :prefix) symbol))
+ (plist-get cache :tags)
+ (let ((tags (my/citre-backend--query 'completion)))
+ (setq my/citre-backend--completion-cache
+ (list :context context :prefix (substring-no-properties symbol) :tags tags))
+ tags))))
+ (when tags (list (car bounds) (cdr bounds) tags)))))
+
+(defun my/citre-backend-definitions ()
+ "Find definitions in the current live and persistent indexes."
+ (my/citre-backend--query 'definition))
+
+(defun my/citre-backend-definitions-of-id (identifier)
+ "Find definitions of IDENTIFIER in the current project."
+ (my/citre-backend--query 'definition identifier))
+
+(defun my/citre-backend-identifiers ()
+ "Return all current identifiers for Citre's query UI."
+ (delete-dups (mapcar (lambda (tag) (citre-get-tag-field 'name tag))
+ (my/citre-backend--query 'identifiers))))
+
+(defun my/citre-backend-imenu ()
+ "Return the current file's symbols for Imenu, including unsaved changes."
+ (my/citre-backend--query 'imenu))
+
+(defun my/citre-backend-usable-p ()
+ "Whether the current buffer has an automatic or conventional tags index."
+ (or my/citre-index--state (citre-tags-usable-p)))
+
+(defun my/citre-backend--updated (state)
+ "Invalidate data for STATE without restarting or rearranging an open Company menu."
+ (dolist (buffer (my/citre-index--buffers state))
+ (with-current-buffer buffer
+ (setq my/citre-backend--completion-cache nil)
+ (when (boundp 'imenu--index-alist) (setq imenu--index-alist nil))
+ ;; Fresh data is consumed on the next edit or completion request. Navigation keeps its current candidates and selection.
+ (when (and (boundp 'company--capf-cache)
+ (eq (car company--capf-cache) buffer))
+ (setq company--capf-cache nil)))))
+
+(citre-register-backend
+ 'my-index
+ (citre-backend-create :usable-probe #'my/citre-backend-usable-p
+ :symbol-at-point-fn #'citre-tags-symbol-at-point
+ :completions-fn #'my/citre-backend-completions
+ :defs-fn #'my/citre-backend-definitions
+ :defs-of-id-fn #'my/citre-backend-definitions-of-id
+ :id-list-fn #'my/citre-backend-identifiers
+ :tags-in-buffer-fn #'my/citre-backend-imenu
+ :after-jump-fn #'my/citre-index-attach))
+
+(add-hook 'my/citre-index-updated-hook #'my/citre-backend--updated)
+
+(provide 'citre-backend)
+;;; citre-backend.el ends here
diff --git a/lisp/citre-index.el b/lisp/citre-index.el
new file mode 100644
index 0000000..99dda7b
--- /dev/null
+++ b/lisp/citre-index.el
@@ -0,0 +1,469 @@
+;;; citre-index.el --- Automatic project and live-buffer tags -*- lexical-binding: t; -*-
+
+(require 'cl-lib)
+(require 'json)
+(require 'project)
+(require 'filenotify)
+(require 'subr-x)
+
+(defgroup my/citre nil "Automatic Citre indexes." :group 'tools)
+(defcustom my/project-ctags-program (expand-file-name "bin/project-ctags" user-emacs-directory)
+ "Worker used for disk indexes and buffer snapshots." :type 'file :group 'my/citre)
+(defcustom my/citre-index-cache-directory (expand-file-name "citre-cache/" user-emacs-directory)
+ "Persistent disk indexes; live snapshots are private to this Emacs session." :type 'directory :group 'my/citre)
+(defcustom my/citre-index-idle-delay 0.1
+ "Idle seconds before parsing changed buffers." :type 'number :group 'my/citre)
+(defcustom my/citre-index-refresh-delay 0.2
+ "Seconds to collect disk changes before checking the project." :type 'number :group 'my/citre)
+(defcustom my/citre-index-check-interval 30
+ "Seconds between background checks of projects with open buffers." :type 'number :group 'my/citre)
+(defcustom my/citre-index-dependency-check-interval 300
+ "Seconds between dependency discovery when declarations have not changed." :type 'number :group 'my/citre)
+(defcustom my/citre-index-max-watches 256
+ "Maximum directory watches per project; periodic checks cover the remainder." :type 'integer :group 'my/citre)
+(defcustom my/citre-index-max-buffer-size (* 2 1024 1024)
+ "Maximum buffer size for live snapshots; larger files use the disk index." :type 'integer :group 'my/citre)
+(defcustom my/citre-index-process-timeout 120
+ "Maximum seconds for a disk-index worker." :type 'number :group 'my/citre)
+(defcustom my/citre-index-excluded-directories
+ '(".git" ".hg" ".svn" ".direnv" ".venv" ".pixi" "__pycache__" "node_modules" "target" "build" "dist" "citre-cache")
+ "Directory names excluded from automatic project and live-buffer indexing." :type '(repeat string) :group 'my/citre)
+(defvar-local my/project-ctags-arguments nil
+ "Additional worker arguments, usually configured in project directory locals.")
+(put 'my/project-ctags-arguments 'safe-local-variable
+ (lambda (value) (and (listp value) (seq-every-p #'stringp value))))
+
+(cl-defstruct (my/citre-index-state (:constructor my/citre-index--make-state))
+ root cache environment paths arguments
+ disk-process live-process disk-timer live-timer pending force
+ entries watches live-directory error checked (revision 0))
+
+(defvar my/citre-index--projects (make-hash-table :test #'equal))
+(defvar my/citre-index--timer nil)
+(defvar my/citre-index-mode nil)
+(defvar my/citre-index-updated-hook nil
+ "Functions called with the project state after an index changes.")
+(defvar-local my/citre-index--state nil)
+(defvar-local my/citre-index--dirty nil)
+
+(defun my/citre-index--path ()
+ "Return the canonical visited filename, including for a new file."
+ (when buffer-file-name (file-truename buffer-file-name)))
+
+(defun my/citre-index--eligible-p ()
+ "Whether the current buffer is a local source file eligible for indexing."
+ (and buffer-file-name (derived-mode-p 'prog-mode)
+ (not (file-remote-p buffer-file-name))
+ (not (cl-intersection (split-string buffer-file-name "/" t)
+ my/citre-index-excluded-directories :test #'equal))))
+
+(defun my/citre-index--root ()
+ "Find a local project root without prompting."
+ (when (my/citre-index--eligible-p)
+ (when-let* ((root (or (and (fboundp 'projectile-project-root)
+ (ignore-errors (projectile-project-root)))
+ (when-let* ((project (project-current nil)))
+ (project-root project)))))
+ (file-name-as-directory (file-truename root)))))
+
+(defun my/citre-index--buffers (state)
+ "Return living buffers attached to STATE."
+ (cl-remove-if-not
+ (lambda (buffer)
+ (and (buffer-live-p buffer)
+ (eq (buffer-local-value 'my/citre-index--state buffer) state)))
+ (buffer-list)))
+
+(defun my/citre-index--log (state)
+ "Return the private worker log for STATE."
+ (get-buffer-create (format " *citre-index:%s*" (my/citre-index-state-root state))))
+
+(defun my/citre-index--updated (state)
+ "Invalidate consumers after publishing a new index in STATE."
+ (cl-incf (my/citre-index-state-revision state))
+ (run-hook-with-args 'my/citre-index-updated-hook state))
+
+(defun my/citre-index--environment (state)
+ "Capture the current buffer's project environment in STATE."
+ (setf (my/citre-index-state-environment state) (copy-sequence process-environment)
+ (my/citre-index-state-paths state) (copy-sequence exec-path)
+ (my/citre-index-state-arguments state) (copy-sequence my/project-ctags-arguments)))
+
+(defun my/citre-index--cancel-timer (timer)
+ "Cancel TIMER if it exists."
+ (when (timerp timer) (cancel-timer timer)))
+
+(defun my/citre-index--record-error (state error)
+ "Record ERROR without interrupting editing or displaying a buffer."
+ (setf (my/citre-index-state-error state) (format "%s" error))
+ (with-current-buffer (my/citre-index--log state)
+ (goto-char (point-max))
+ (insert (format "\n%s\n" error))))
+
+(defun my/citre-index--spawn (state name arguments sentinel &optional filter)
+ "Start one worker for STATE using ARGUMENTS, SENTINEL and FILTER."
+ (let ((default-directory (my/citre-index-state-root state))
+ (process-environment (my/citre-index-state-environment state))
+ (exec-path (my/citre-index-state-paths state)))
+ (make-process :name name :buffer (my/citre-index--log state)
+ :command (append (list my/project-ctags-program default-directory) arguments)
+ :connection-type 'pipe :noquery t :sentinel sentinel :filter filter
+ :stderr (my/citre-index--log state))))
+
+(defun my/citre-index--watch (state directories)
+ "Reconcile bounded directory watches for STATE with DIRECTORIES."
+ (let* ((git (expand-file-name ".git" (my/citre-index-state-root state)))
+ (wanted (seq-take (delete-dups
+ (append (list (my/citre-index-state-root state))
+ (when (file-directory-p git) (list git (expand-file-name "refs/heads" git)))
+ directories))
+ my/citre-index-max-watches))
+ (watches (my/citre-index-state-watches state)))
+ (maphash (lambda (directory descriptor)
+ (unless (and (member directory wanted) (file-notify-valid-p descriptor))
+ (ignore-errors (file-notify-rm-watch descriptor))
+ (remhash directory watches)))
+ watches)
+ (dolist (directory wanted)
+ (when (and (file-directory-p directory) (not (gethash directory watches)))
+ (condition-case nil
+ (puthash directory
+ (file-notify-add-watch
+ directory '(change)
+ (lambda (event)
+ (unless (or (eq (nth 1 event) 'stopped)
+ (cl-some (lambda (path)
+ (and (stringp path)
+ (string-prefix-p (file-name-as-directory my/citre-index-cache-directory) path)))
+ (cddr event)))
+ (my/citre-index-request-refresh state))))
+ watches)
+ (file-notify-error nil))))))
+
+(defun my/citre-index--disk-filter (state process chunk)
+ "Consume line-delimited worker events for STATE from PROCESS and CHUNK."
+ (let* ((text (concat (or (process-get process 'partial) "") chunk))
+ (lines (split-string text "\n")))
+ (process-put process 'partial (car (last lines)))
+ (dolist (line (butlast lines))
+ (condition-case nil
+ (let ((event (json-parse-string line :object-type 'alist :array-type 'list :false-object nil)))
+ (when (equal (alist-get 'event event) "complete")
+ (process-put process 'manifest event))
+ (when (alist-get 'changed event)
+ (process-put process 'changed t)
+ (when (equal (alist-get 'event event) "project")
+ (my/citre-index--updated state))))
+ (error (with-current-buffer (my/citre-index--log state)
+ (goto-char (point-max)) (insert line "\n")))))))
+
+(defun my/citre-index--disk-finished (state process _event)
+ "Publish a finished disk worker for STATE."
+ (when (memq (process-status process) '(exit signal))
+ (my/citre-index--cancel-timer (process-get process 'timeout))
+ (when (eq process (my/citre-index-state-disk-process state))
+ (setf (my/citre-index-state-disk-process state) nil)
+ (if (and (eq (process-status process) 'exit) (zerop (process-exit-status process)))
+ (condition-case error
+ (let ((manifest (process-get process 'manifest)))
+ (setf (my/citre-index-state-error state) nil
+ (my/citre-index-state-checked state) (current-time))
+ (my/citre-index--watch state (alist-get 'directories manifest))
+ (when (process-get process 'changed)
+ (my/citre-index--updated state)))
+ (error (my/citre-index--record-error state error)))
+ (my/citre-index--record-error state (format "Disk worker exited: %s" (process-exit-status process))))
+ (when (my/citre-index-state-pending state)
+ (my/citre-index-request-refresh state)))))
+
+(defun my/citre-index--start-disk (state)
+ "Start or coalesce the next disk check for STATE."
+ (setf (my/citre-index-state-disk-timer state) nil)
+ (when (and my/citre-index-mode (my/citre-index--buffers state))
+ (if (process-live-p (my/citre-index-state-disk-process state))
+ (setf (my/citre-index-state-pending state) t)
+ (condition-case error
+ (let* ((arguments
+ (append (my/citre-index-state-arguments state)
+ (list "--cache-dir" (my/citre-index-state-cache state)
+ "--dependency-check-interval" (number-to-string my/citre-index-dependency-check-interval)
+ "--watch-limit" (number-to-string my/citre-index-max-watches))
+ (cl-mapcan (lambda (name) (list "--exclude-dir" name)) my/citre-index-excluded-directories)
+ (when (my/citre-index-state-force state) '("--force"))))
+ (process (my/citre-index--spawn state "citre-disk" arguments
+ (lambda (process event) (my/citre-index--disk-finished state process event))
+ (lambda (process chunk) (my/citre-index--disk-filter state process chunk)))))
+ (setf (my/citre-index-state-disk-process state) process
+ (my/citre-index-state-pending state) nil
+ (my/citre-index-state-force state) nil)
+ (process-put process 'timeout
+ (run-at-time my/citre-index-process-timeout nil
+ (lambda () (when (process-live-p process) (delete-process process))))))
+ (error (my/citre-index--record-error state error))))))
+
+(defun my/citre-index-request-refresh (state &optional force)
+ "Schedule a background disk check for STATE; FORCE requests a full rebuild."
+ (when (and my/citre-index-mode state)
+ (when force (setf (my/citre-index-state-force state) t))
+ (setf (my/citre-index-state-pending state) t)
+ (unless (timerp (my/citre-index-state-disk-timer state))
+ (setf (my/citre-index-state-disk-timer state)
+ (run-at-time my/citre-index-refresh-delay nil #'my/citre-index--start-disk state)))))
+
+(defun my/citre-index-live-entries (state)
+ "Return valid live entries in STATE and retire entries of detached or renamed buffers."
+ (let (entries retired)
+ (maphash
+ (lambda (path entry)
+ (let ((buffer (plist-get entry :buffer)))
+ (if (and (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (and (eq my/citre-index--state state) (equal path (my/citre-index--path))
+ (or (buffer-modified-p) (verify-visited-file-modtime buffer)))))
+ (push (cons path entry) entries)
+ (push path retired)
+ (ignore-errors (delete-file (plist-get entry :file))))))
+ (my/citre-index-state-entries state))
+ (dolist (path retired) (remhash path (my/citre-index-state-entries state)))
+ entries))
+
+(defun my/citre-index--live-finished (state directory jobs process _event)
+ "Publish only matching buffer versions from JOBS, then remove DIRECTORY."
+ (when (memq (process-status process) '(exit signal))
+ (my/citre-index--cancel-timer (process-get process 'timeout))
+ (when (eq process (my/citre-index-state-live-process state))
+ (setf (my/citre-index-state-live-process state) nil)
+ (unwind-protect
+ (if (and (eq (process-status process) 'exit) (zerop (process-exit-status process)))
+ (progn
+ (dolist (job jobs)
+ (let ((buffer (plist-get job :buffer))
+ (path (plist-get job :source)))
+ (when (and (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (and (eq my/citre-index--state state)
+ (equal path (my/citre-index--path))
+ (= (buffer-chars-modified-tick) (plist-get job :tick)))))
+ (let* ((old (gethash path (my/citre-index-state-entries state)))
+ (output (make-temp-file (expand-file-name "buffer-" (my/citre-index-state-live-directory state)) nil ".tags")))
+ (rename-file (plist-get job :output) output t)
+ (puthash path (list :file output :buffer buffer :tick (plist-get job :tick))
+ (my/citre-index-state-entries state))
+ (when old (ignore-errors (delete-file (plist-get old :file))))
+ (with-current-buffer buffer (setq my/citre-index--dirty nil))))))
+ (my/citre-index--updated state))
+ ;; Retry on the next edit or explicit rebuild, not in an error loop.
+ (dolist (job jobs)
+ (when (buffer-live-p (plist-get job :buffer))
+ (with-current-buffer (plist-get job :buffer)
+ (when (= (buffer-chars-modified-tick) (plist-get job :tick))
+ (setq my/citre-index--dirty nil)))))
+ (my/citre-index--record-error state (format "Live worker exited: %s" (process-exit-status process))))
+ (ignore-errors (delete-directory directory t)))
+ (when (cl-some (lambda (buffer) (buffer-local-value 'my/citre-index--dirty buffer))
+ (my/citre-index--buffers state))
+ (my/citre-index--schedule-live state)))))
+
+(defun my/citre-index--start-live (state)
+ "Parse dirty buffers of STATE independently of slower dependency checks."
+ (my/citre-index--cancel-timer (my/citre-index-state-live-timer state))
+ (setf (my/citre-index-state-live-timer state) nil)
+ (when (and my/citre-index-mode (not (process-live-p (my/citre-index-state-live-process state))))
+ (let ((directory (make-temp-file (expand-file-name "job-" (my/citre-index-state-live-directory state)) t))
+ jobs request)
+ (condition-case error
+ (progn
+ (dolist (buffer (my/citre-index--buffers state))
+ (with-current-buffer buffer
+ (when my/citre-index--dirty
+ (if (or (> (buffer-size) my/citre-index-max-buffer-size)
+ (not (my/citre-index--eligible-p)))
+ (setq my/citre-index--dirty nil)
+ (let* ((source (my/citre-index--path))
+ (snapshot (expand-file-name (concat (number-to-string (length jobs)) "/" (file-name-nondirectory source)) directory))
+ (output (concat snapshot ".tags"))
+ (tick (buffer-chars-modified-tick)))
+ (make-directory (file-name-directory snapshot) t)
+ (let ((coding-system-for-write (or buffer-file-coding-system 'utf-8-unix)))
+ (save-restriction
+ (widen)
+ (write-region (point-min) (point-max) snapshot nil 'silent)))
+ (push (list :buffer buffer :source source :output output :tick tick) jobs)
+ (push (list (cons 'source source) (cons 'snapshot snapshot) (cons 'output output)) request))))))
+ (if (null jobs)
+ (delete-directory directory t)
+ (let ((request-file (expand-file-name "request.json" directory)))
+ (with-temp-file request-file (insert (json-encode (vconcat request))))
+ (let ((process (my/citre-index--spawn
+ state "citre-live" (append (my/citre-index-state-arguments state) (list "--snapshot-request" request-file))
+ (lambda (process event) (my/citre-index--live-finished state directory jobs process event)))))
+ (setf (my/citre-index-state-live-process state) process)
+ (process-put process 'timeout
+ (run-at-time 10 nil (lambda () (when (process-live-p process) (delete-process process)))))))))
+ (error
+ (ignore-errors (delete-directory directory t))
+ (my/citre-index--record-error state error))))))
+
+(defun my/citre-index--schedule-live (state)
+ "Debounce live parsing for STATE."
+ (unless (timerp (my/citre-index-state-live-timer state))
+ (setf (my/citre-index-state-live-timer state)
+ (run-with-idle-timer my/citre-index-idle-delay nil #'my/citre-index--start-live state))))
+
+(defun my/citre-index--changed (&rest _)
+ "Mark the current buffer dirty without running a parser inside the change hook."
+ (when my/citre-index--state
+ (setq my/citre-index--dirty t)
+ (my/citre-index--schedule-live my/citre-index--state)))
+
+(defun my/citre-index--saved ()
+ "Refresh disk state after a save or revert."
+ (when my/citre-index--state
+ (my/citre-index--environment my/citre-index--state)
+ (my/citre-index--changed)
+ (my/citre-index-request-refresh my/citre-index--state)))
+
+(defun my/citre-index--renamed ()
+ "Reattach live content when the visited filename or project changes."
+ (my/citre-index--detach)
+ (my/citre-index-attach))
+
+(defun my/citre-index--flush ()
+ "Start a pending live update when leaving Evil insert state."
+ (when (and my/citre-index--state my/citre-index--dirty)
+ (my/citre-index--start-live my/citre-index--state)))
+
+(defun my/citre-index--detach ()
+ "Detach the current buffer and invalidate its live results."
+ (when-let* ((state my/citre-index--state))
+ (setq my/citre-index--state nil my/citre-index--dirty nil)
+ (my/citre-index-live-entries state)
+ (my/citre-index--updated state)
+ (if (my/citre-index--buffers state)
+ (my/citre-index-request-refresh state)
+ (my/citre-index--release state))))
+
+(defun my/citre-index-attach ()
+ "Attach a local programming buffer to its automatic project index."
+ (when my/citre-index-mode
+ (when-let* ((root (my/citre-index--root)))
+ (when my/citre-index--state
+ (my/citre-index--environment my/citre-index--state))
+ (unless (and my/citre-index--state (equal root (my/citre-index-state-root my/citre-index--state)))
+ (my/citre-index--detach)
+ (let ((state (gethash root my/citre-index--projects)))
+ (unless state
+ (let ((cache (expand-file-name (secure-hash 'sha256 root) my/citre-index-cache-directory)))
+ (make-directory cache t)
+ (setq state (my/citre-index--make-state
+ :root root :cache cache :entries (make-hash-table :test #'equal)
+ :watches (make-hash-table :test #'equal)
+ :live-directory (make-temp-file (expand-file-name "live-" cache) t)))
+ (puthash root state my/citre-index--projects)))
+ (setq my/citre-index--state state)
+ (my/citre-index--environment state)
+ (add-hook 'after-change-functions #'my/citre-index--changed nil t)
+ (add-hook 'after-save-hook #'my/citre-index--saved nil t)
+ (add-hook 'after-revert-hook #'my/citre-index--saved nil t)
+ (add-hook 'after-set-visited-file-name-hook #'my/citre-index--renamed nil t)
+ (add-hook 'kill-buffer-hook #'my/citre-index--detach nil t)
+ (add-hook 'change-major-mode-hook #'my/citre-index--detach nil t)
+ (add-hook 'evil-insert-state-exit-hook #'my/citre-index--flush nil t)
+ (when (fboundp 'citre-mode) (citre-mode 1))
+ (my/citre-index--changed)
+ (my/citre-index-request-refresh state))))))
+
+(defun my/citre-index--check ()
+ "Check active projects and refresh environments after direnv or project switches."
+ (when my/citre-index-mode
+ (maphash
+ (lambda (_root state)
+ (when-let* ((buffers (my/citre-index--buffers state)))
+ (with-current-buffer (if (memq (current-buffer) buffers) (current-buffer) (car buffers))
+ (my/citre-index--environment state))
+ (my/citre-index-request-refresh state)))
+ my/citre-index--projects)))
+
+(defun my/citre-index--focus ()
+ "Check project files when Emacs regains focus."
+ (when (frame-focus-state) (my/citre-index--check)))
+
+(defun my/project-ctags-setup ()
+ "Force a complete asynchronous rebuild of this project's disk and live indexes."
+ (interactive)
+ (my/citre-index-attach)
+ (unless my/citre-index--state (user-error "No local programming project in this buffer"))
+ (dolist (buffer (my/citre-index--buffers my/citre-index--state))
+ (with-current-buffer buffer (my/citre-index--changed)))
+ (my/citre-index-request-refresh my/citre-index--state t)
+ (message "Citre rebuild scheduled"))
+
+(defun my/citre-index-status ()
+ "Show index state and offer the log without opening it during ordinary editing."
+ (interactive)
+ (unless my/citre-index--state (user-error "No automatic Citre index in this buffer"))
+ (let ((state my/citre-index--state))
+ (with-help-window "*Citre index status*"
+ (princ (format "Project: %s\nCache: %s\nLive buffers: %d\nDisk worker: %s\nLive worker: %s\nLast check: %s\nLast error: %s\nLog: %s\n"
+ (my/citre-index-state-root state) (my/citre-index-state-cache state)
+ (length (my/citre-index-live-entries state))
+ (if (process-live-p (my/citre-index-state-disk-process state)) "running" "idle")
+ (if (process-live-p (my/citre-index-state-live-process state)) "running" "idle")
+ (if (my/citre-index-state-checked state) (format-time-string "%F %T" (my/citre-index-state-checked state)) "pending")
+ (or (my/citre-index-state-error state) "none")
+ (buffer-name (my/citre-index--log state)))))))
+
+(defun my/citre-index--release (state)
+ "Release workers and watches belonging to STATE and remove its session snapshots."
+ (my/citre-index--cancel-timer (my/citre-index-state-disk-timer state))
+ (my/citre-index--cancel-timer (my/citre-index-state-live-timer state))
+ (dolist (process (list (my/citre-index-state-disk-process state) (my/citre-index-state-live-process state)))
+ (when (processp process)
+ (my/citre-index--cancel-timer (process-get process 'timeout))
+ (set-process-sentinel process #'ignore)
+ (when (process-live-p process) (delete-process process))))
+ (maphash (lambda (_directory descriptor) (ignore-errors (file-notify-rm-watch descriptor)))
+ (my/citre-index-state-watches state))
+ (ignore-errors (delete-directory (my/citre-index-state-live-directory state) t))
+ (remhash (my/citre-index-state-root state) my/citre-index--projects))
+
+(defun my/citre-index--stop ()
+ "Stop workers and watches and remove only this session's live snapshots."
+ (my/citre-index--cancel-timer my/citre-index--timer)
+ (setq my/citre-index--timer nil)
+ (maphash (lambda (_root state) (my/citre-index--release state)) my/citre-index--projects)
+ (clrhash my/citre-index--projects)
+ (dolist (buffer (buffer-list))
+ (with-current-buffer buffer
+ (when my/citre-index--state
+ (setq my/citre-index--state nil my/citre-index--dirty nil)
+ (remove-hook 'after-change-functions #'my/citre-index--changed t)
+ (remove-hook 'after-save-hook #'my/citre-index--saved t)
+ (remove-hook 'after-revert-hook #'my/citre-index--saved t)
+ (remove-hook 'after-set-visited-file-name-hook #'my/citre-index--renamed t)
+ (remove-hook 'kill-buffer-hook #'my/citre-index--detach t)
+ (remove-hook 'change-major-mode-hook #'my/citre-index--detach t)
+ (remove-hook 'evil-insert-state-exit-hook #'my/citre-index--flush t)))))
+
+(define-minor-mode my/citre-index-mode
+ "Maintain project tags and unsaved-buffer tags automatically."
+ :global t :group 'my/citre
+ (if my/citre-index-mode
+ (progn
+ (add-hook 'find-file-hook #'my/citre-index-attach)
+ (add-hook 'after-change-major-mode-hook #'my/citre-index-attach)
+ (add-hook 'magit-post-refresh-hook #'my/citre-index--check)
+ (add-hook 'kill-emacs-hook #'my/citre-index--stop)
+ (add-function :after after-focus-change-function #'my/citre-index--focus)
+ (my/citre-index--cancel-timer my/citre-index--timer)
+ (setq my/citre-index--timer (run-at-time my/citre-index-check-interval my/citre-index-check-interval #'my/citre-index--check))
+ (dolist (buffer (buffer-list)) (with-current-buffer buffer (my/citre-index-attach))))
+ (remove-hook 'find-file-hook #'my/citre-index-attach)
+ (remove-hook 'after-change-major-mode-hook #'my/citre-index-attach)
+ (remove-hook 'magit-post-refresh-hook #'my/citre-index--check)
+ (remove-hook 'kill-emacs-hook #'my/citre-index--stop)
+ (remove-function after-focus-change-function #'my/citre-index--focus)
+ (my/citre-index--stop)))
+
+(provide 'citre-index)
+;;; citre-index.el ends here
diff --git a/lisp/my-header-line.el b/lisp/my-header-line.el
index 0b0bc75..3d52f2c 100644
--- a/lisp/my-header-line.el
+++ b/lisp/my-header-line.el
@@ -2,6 +2,17 @@
(require 'mode-line-maker)
+(defcustom my/header-line-height 35
+ "Minimum graphical header-line height, before image scaling.
+Matches the default height of Dirvish's full-frame header line."
+ :type 'natnum
+ :group 'mode-line)
+
+(defcustom my/header-line-show-major-mode nil
+ "Whether to show the major mode next to the clock in the header line."
+ :type 'boolean
+ :group 'mode-line)
+
(defface my/header-line-evil-normal
'((t (:inherit font-lock-keyword-face :weight bold)))
"Face for the evil normal state indicator.")
@@ -35,16 +46,16 @@
"Face for the IRC activity indicator.")
(defface my/header-line-clatter-face
- '((t (:inherit font-lock-builtin-face :weight bold)))
- "Face for the Clatter joined-channel indicator.")
+ '((t (:inherit success :weight bold)))
+ "Theme success color for Clatter's connected-channel indicator.")
(defface my/header-line-mu4e-face
- '((t (:inherit warning :weight bold)))
- "Face for the unread mail indicator.")
+ '((t (:inherit font-lock-type-face :weight bold)))
+ "Theme type accent for the unread mail indicator.")
(defface my/header-line-time-face
- '((t (:inherit font-lock-constant-face)))
- "Face for the time segment.")
+ '((t (:inherit mode-line-buffer-id :weight normal)))
+ "Theme buffer-name accent for the clock, in regular weight.")
(defface my/header-line-state-saved-face
'((t (:inherit success)))
@@ -58,6 +69,30 @@
'((t (:inherit error)))
"Face for the read-only buffer state icon.")
+(defface my/header-line-position-face
+ '((t (:inherit shadow :weight normal :slant normal)))
+ "Quiet face for the scroll percentage and cursor position.")
+
+(defface my/header-line-workspace-active-face
+ '((t (:inherit my/header-line-position-face :inverse-video nil :box nil)))
+ "Current workspace, styled like the percentage and cursor position.")
+
+(defface my/header-line-workspace-inactive-face
+ '((t (:inherit shadow :weight normal :slant normal)))
+ "Quiet numbers for other open workspaces.")
+
+(defvar my/header-line-workspaces-expanded nil
+ "Whether the header line also shows the other open workspaces.")
+
+(defconst my/header-line-position
+ '(" "
+ (:propertize "%p" face my/header-line-position-face
+ help-echo "Window scroll position")
+ " "
+ (:propertize "%l:%c" face my/header-line-position-face
+ help-echo "Line : column (columns start at 0)"))
+ "Compact scroll percentage and line:column display.")
+
(defvar-local my/header-line--buffer-title nil)
(defconst my/header-line--missing (make-symbol "missing")
@@ -217,6 +252,56 @@ of a literal dash."
(propertize (if icon (concat icon " " time) time)
'face 'my/header-line-time-face))))
+(defun my/toggle-header-line-workspaces ()
+ "Toggle between the current workspace and all open workspace names."
+ (interactive)
+ (setq my/header-line-workspaces-expanded
+ (not my/header-line-workspaces-expanded))
+ (force-mode-line-update t))
+
+(defun my/header-line--workspace-number (index)
+ "Return an outlined Nerd Icon for workspace INDEX, with a text fallback."
+ (let ((fallback (format "[%d]" index)))
+ (if (<= 1 index 10)
+ (my/header-line--icon
+ (format "nf-md-numeric_%d_box_outline" index)
+ 'my/header-line-position-face fallback)
+ fallback)))
+
+(defun my/header-line-workspaces ()
+ "Show the current workspace or all workspaces, each with its number icon."
+ (when (and (bound-and-true-p persp-mode)
+ (fboundp 'my/workspace-current-name)
+ (fboundp 'my/workspace-names))
+ (let ((current (my/workspace-current-name))
+ (names (my/workspace-names))
+ (index 0))
+ (when (member current names)
+ (string-join
+ (delq nil
+ (mapcar
+ (lambda (name)
+ ;; Keep the same numbering as workspace switching.
+ (setq index (1+ index))
+ (let ((active (equal name current)))
+ (when (or active my/header-line-workspaces-expanded)
+ (propertize
+ ;; The maker expands this before the final header.
+ (string-replace
+ "%" "%%"
+ (concat (my/header-line--workspace-number index)
+ " " name))
+ 'face (if active 'my/header-line-workspace-active-face
+ 'my/header-line-workspace-inactive-face)
+ 'help-echo
+ (format "%d: %s — t: toggle workspaces" index name)))))
+ names))
+ " ")))))
+
+(defun my/header-line--refresh-workspaces (&rest _args)
+ "Refresh all headers after workspace changes."
+ (force-mode-line-update t))
+
(defun my/header-line-sync-theme (&optional _theme)
"Make helper padding inherit the live header-line theme face.
Optional THEME is ignored so this function also fits
@@ -231,6 +316,18 @@ Optional THEME is ignored so this function also fits
:inverse-video nil
:strike-through nil))
+(defun my/header-line--height-spacer ()
+ "Return a transparent height spacer, using the same approach as Dirvish."
+ (when (and (display-graphic-p)
+ (image-type-available-p 'pbm)
+ (> my/header-line-height 0))
+ (propertize
+ " " 'display
+ (create-image
+ (concat (format "P1\n1 %d\n" my/header-line-height)
+ (make-string my/header-line-height ?0) "\n")
+ 'pbm t :foreground "None" :ascent 'center))))
+
(define-minor-mode my/global-header-line-mode
"Show the custom header line globally.
@@ -248,34 +345,49 @@ headings, remain visible."
(interactive)
(my/global-header-line-mode (if my/global-header-line-mode -1 1)))
+(defun my/header-line--make (left right)
+ "Build a header from LEFT and RIGHT, preserving literal percent signs."
+ ;; The maker has already expanded %p, %l, etc. Escape its result before
+ ;; redisplay interprets it again, including percent signs in buffer names.
+ (let ((format (mode-line-maker left right)))
+ `(:eval (string-replace "%" "%%"
+ (apply #'concat ,(cadr format))))))
+
(defun my/header-line-install ()
"Install the custom header line built with mode-line-maker."
(setq global-mode-string (delq 'display-time-string global-mode-string))
(my/header-line-sync-theme)
(add-hook 'enable-theme-functions #'my/header-line-sync-theme)
(setq my/header-line--format
- (mode-line-maker
- '((:eval (my/header-line-evil-state)) " "
- (:eval (my/header-line-buffer-state))
- (:eval (my/header-line-remote)) " "
- (:eval (my/header-line-buffer-icon)) " "
- (:eval (propertize (my/header-line-buffer-name)
- 'face 'mode-line-buffer-id
- 'mouse-face 'mode-line-highlight
- 'help-echo (concat (or buffer-file-truename (buffer-name))
- "\nmouse-1: Previous buffer\nmouse-3: Next buffer")
- 'local-map mode-line-buffer-identification-keymap)) " "
- mode-line-position)
- '("" (:eval (my/header-line-irc)) " "
- (:eval (my/header-line-clatter)) " "
- (:eval (my/header-line-mu4e)) " "
- mode-line-process " "
- (:eval (my/header-line-time)) " "
- mode-name " ")))
+ (list
+ '(:eval (my/header-line--height-spacer))
+ (my/header-line--make
+ '((:eval (my/header-line-evil-state)) " "
+ (:eval (my/header-line-buffer-state))
+ (:eval (my/header-line-remote)) " "
+ (:eval (my/header-line-buffer-icon)) " "
+ (:eval (propertize (my/header-line-buffer-name)
+ 'face 'mode-line-buffer-id
+ 'mouse-face 'mode-line-highlight
+ 'help-echo (concat (or buffer-file-truename (buffer-name))
+ "\nmouse-1: Previous buffer\nmouse-3: Next buffer")
+ 'local-map mode-line-buffer-identification-keymap))
+ my/header-line-position)
+ '("" (:eval (my/header-line-irc)) " "
+ (:eval (my/header-line-clatter)) " "
+ (:eval (my/header-line-mu4e)) " "
+ mode-line-process " "
+ (:eval (my/header-line-time)) " "
+ (my/header-line-show-major-mode ("" mode-name " "))
+ (:eval (my/header-line-workspaces)) " "))))
;; The header line is visible on every startup, regardless of its previous
;; interactive state in a long-running session.
(my/global-header-line-mode 1)
- (add-hook 'before-save-hook #'my/header-line--invalidate-title-cache))
+ (add-hook 'before-save-hook #'my/header-line--invalidate-title-cache)
+ (with-eval-after-load 'persp-mode
+ (add-hook 'persp-activated-functions #'my/header-line--refresh-workspaces)
+ (add-hook 'persp-names-cache-changed-functions
+ #'my/header-line--refresh-workspaces)))
(provide 'my-header-line)
;;; my-header-line.el ends here
diff --git a/lisp/my-quick-access.el b/lisp/my-quick-access.el
new file mode 100644
index 0000000..ae1d538
--- /dev/null
+++ b/lisp/my-quick-access.el
@@ -0,0 +1,419 @@
+;;; my-quick-access.el --- Workspace bookmarks and recent buffers -*- lexical-binding: t; -*-
+
+(require 'cl-lib)
+(require 'subr-x)
+(require 'seq)
+(require 'bookmark)
+(require 'persp-mode)
+
+(defgroup my/quick-access nil
+ "Quick access to workspace bookmarks and buffers."
+ :group 'convenience)
+
+(defcustom my/quick-access-recent-count 5
+ "Maximum number of recently visited buffers shown in the popup."
+ :type 'natnum :group 'my/quick-access)
+
+(defface my/quick-access-title
+ '((t (:inherit mode-line-buffer-id :weight normal)))
+ "Theme accent for popup titles and selection keys."
+ :group 'my/quick-access)
+
+(defcustom my/quick-access-selection-keys '("f" "j" "d" "k" "g" "h")
+ "Distinct single-letter keys for recent buffers and ambiguous initials; use at least two."
+ :type '(repeat string) :group 'my/quick-access)
+
+(defcustom my/quick-access-max-height 0.48
+ "Maximum popup height as a fraction of the frame height."
+ :type '(float :tag "Frame height fraction") :group 'my/quick-access)
+
+(defcustom my/quick-access-page-size 6
+ "Maximum entries per page; small frames show fewer to keep the footer visible."
+ :type 'natnum :group 'my/quick-access)
+(defconst my/quick-access--buffer-name " *Workspace Quick Access*")
+(defvar my/quick-access--history (make-hash-table :test #'eq :weakness 'key))
+(defvar my/quick-access--hidden (make-hash-table :test #'eq :weakness 'key)
+ "Buffers hidden from quick access per workspace during this session.")
+(defvar my/quick-access--last-visit nil)
+(defvar my/quick-access--active nil)
+
+(defun my/quick-access--eligible-p (buffer)
+ "Whether BUFFER is a live, user-visible buffer."
+ (and (buffer-live-p buffer)
+ (not (minibufferp buffer))
+ (not (string-prefix-p " " (buffer-name buffer)))))
+
+(defun my/quick-access--track ()
+ "Remember visits independently for each workspace."
+ (when (and (bound-and-true-p persp-mode)
+ (not my/quick-access--active)
+ (not (active-minibuffer-window)))
+ (let* ((persp (get-current-persp))
+ (buffer (window-buffer (selected-window)))
+ (visit (cons persp buffer)))
+ (unless (and (eq persp (car my/quick-access--last-visit))
+ (eq buffer (cdr my/quick-access--last-visit)))
+ (setq my/quick-access--last-visit visit)
+ (when (and (my/quick-access--eligible-p buffer)
+ (memq buffer (safe-persp-buffers persp)))
+ (puthash persp
+ (cons buffer
+ (cl-remove-if
+ (lambda (old) (or (eq old buffer)
+ (not (buffer-live-p old))))
+ (gethash persp my/quick-access--history)))
+ my/quick-access--history))))))
+
+(defun my/quick-access--buffers (persp current)
+ "Return live buffers of PERSP in visit order, excluding CURRENT.
+The frame's buffer order seeds history for buffers not visited since loading."
+ (let ((members (safe-persp-buffers persp))
+ (hidden (cl-remove-if-not #'buffer-live-p
+ (gethash persp my/quick-access--hidden))))
+ (puthash persp hidden my/quick-access--hidden)
+ (cl-remove-if-not
+ (lambda (buffer)
+ (and (not (eq buffer current))
+ (not (memq buffer hidden))
+ (my/quick-access--eligible-p buffer)
+ (memq buffer members)))
+ (delete-dups (append (gethash persp my/quick-access--history)
+ (buffer-list (selected-frame)))))))
+
+(defun my/quick-access--scope-record (args)
+ "Tag stored bookmark ARGS before the bookmark library saves them.
+Existing assignments are retained when a shared bookmark is updated."
+ (if (not (bound-and-true-p persp-mode)) args
+ (pcase-let ((`(,name ,record ,no-overwrite) args))
+ (bookmark-maybe-load-default-file)
+ (let* ((old (and (not no-overwrite) (assoc name bookmark-alist)))
+ (scopes (delete-dups
+ (cons (safe-persp-name (get-current-persp))
+ (append (alist-get 'my/quick-access-workspaces record)
+ (and old (bookmark-prop-get
+ old 'my/quick-access-workspaces)))))))
+ (list name
+ (cons (cons 'my/quick-access-workspaces scopes)
+ (assq-delete-all 'my/quick-access-workspaces
+ (copy-tree record)))
+ no-overwrite)))))
+
+(defun my/quick-access--renamed (original new-name &rest args)
+ "Keep bookmark assignments when ORIGINAL renames a workspace to NEW-NAME."
+ (bookmark-maybe-load-default-file)
+ (let ((old-name (apply original new-name args)) changed)
+ (when old-name
+ (dolist (record bookmark-alist)
+ (let ((scopes (bookmark-prop-get record 'my/quick-access-workspaces)))
+ (when (member old-name scopes)
+ (bookmark-prop-set
+ record 'my/quick-access-workspaces
+ (delete-dups (cons new-name (remove old-name scopes))))
+ (setq changed t))))
+ (when changed
+ (cl-incf bookmark-alist-modification-count)
+ (bookmark-save)))
+ old-name))
+
+(defun my/quick-access--bookmarks (workspace)
+ "Return bookmark records explicitly assigned to WORKSPACE."
+ (bookmark-maybe-load-default-file)
+ (cl-remove-if-not
+ (lambda (record)
+ (member workspace (bookmark-prop-get record 'my/quick-access-workspaces)))
+ (copy-sequence bookmark-alist)))
+
+(defun my/quick-access--add-bookmark (workspace)
+ "Assign an existing bookmark to WORKSPACE without moving or copying it."
+ (bookmark-maybe-load-default-file)
+ (unless bookmark-alist (user-error "No existing bookmarks; use C-s to save one"))
+ (let* ((name (completing-read "Add bookmark to workspace: "
+ (bookmark-all-names) nil t))
+ (record (bookmark-get-bookmark name))
+ (scopes (bookmark-prop-get record 'my/quick-access-workspaces)))
+ (unless (member workspace scopes)
+ (bookmark-prop-set record 'my/quick-access-workspaces (cons workspace scopes))
+ (cl-incf bookmark-alist-modification-count)
+ (bookmark-save))))
+
+(defun my/quick-access--remove (item persp workspace)
+ "Remove ITEM from quick access in PERSP, named WORKSPACE.
+Buffers remain live and workspace members; bookmarks retain other assignments."
+ (if (bufferp item)
+ (when (buffer-live-p item)
+ (puthash persp
+ (cons item (delq item (cl-remove-if-not
+ #'buffer-live-p
+ (gethash persp my/quick-access--hidden))))
+ my/quick-access--hidden))
+ (let ((scopes (bookmark-prop-get item 'my/quick-access-workspaces)))
+ (when (member workspace scopes)
+ (bookmark-prop-set item 'my/quick-access-workspaces (remove workspace scopes))
+ (cl-incf bookmark-alist-modification-count)
+ (bookmark-save)))))
+
+(defun my/quick-access-restore-buffers ()
+ "Restore all buffers hidden from quick access in the current workspace."
+ (interactive)
+ (remhash (get-current-persp) my/quick-access--hidden)
+ (message "Hidden buffers restored for %s" (safe-persp-name (get-current-persp))))
+
+(defun my/quick-access--label (item)
+ "Return the display name of bookmark or buffer ITEM."
+ (if (bufferp item) (buffer-name item) (car item)))
+
+(defun my/quick-access--detail (item)
+ "Return a compact target description without accessing the filesystem."
+ (let ((file (if (bufferp item)
+ (buffer-local-value 'buffer-file-name item)
+ (bookmark-get-filename item))))
+ (if (and (stringp file) (not (equal file "")))
+ (abbreviate-file-name file)
+ (if (bufferp item)
+ (with-current-buffer item
+ (abbreviate-file-name default-directory))
+ (or (bookmark-prop-get item 'buffer-name) "")))))
+
+(defun my/quick-access--initial (item)
+ "Return ITEM's first letter, ignoring punctuation and leading stars."
+ (let ((name (my/quick-access--label item)))
+ (if (string-match "[[:alnum:]]" name)
+ (downcase (match-string 0 name))
+ "?")))
+
+(defun my/quick-access--codes (count)
+ "Return COUNT prefix-free labels made from comfortable letter keys."
+ (unless (and (>= (length my/quick-access-selection-keys) 2)
+ (cl-every (lambda (key) (and (stringp key) (string-match-p "\\`[a-z]\\'" key))) my/quick-access-selection-keys)
+ (= (length my/quick-access-selection-keys) (length (delete-dups (copy-sequence my/quick-access-selection-keys)))))
+ (user-error "Selection keys must contain at least two distinct lowercase letters"))
+ (let ((codes (copy-sequence my/quick-access-selection-keys)))
+ (while (< (length codes) count)
+ (let* ((shortest (apply #'min (mapcar #'length codes)))
+ (index (cl-position shortest codes :key #'length :from-end t))
+ (prefix (nth index codes)))
+ (setq codes
+ (append (cl-subseq codes 0 index)
+ (mapcar (lambda (key) (concat prefix key)) my/quick-access-selection-keys)
+ (nthcdr (1+ index) codes)))))
+ (cl-subseq codes 0 count)))
+
+(defun my/quick-access--entries (items home prefix)
+ "Label ITEMS using HOME keys or initials; filter HOME labels by PREFIX."
+ (let ((codes (and home (my/quick-access--codes (length items)))))
+ (cl-loop for item in items for index from 0
+ for code = (if home (nth index codes) (my/quick-access--initial item))
+ when (or (not home) (string-prefix-p prefix code))
+ collect (cons code item))))
+
+(defun my/quick-access--clean-text (text)
+ "Keep TEXT on one display line and remove embedded text properties."
+ (replace-regexp-in-string "[\n\r\t]" " " (substring-no-properties text)))
+
+(defun my/quick-access--render (buffer window workspace source entries page size prefix group &optional removing)
+ "Draw ENTRIES in BUFFER and WINDOW, indicating REMOVING when active."
+ (let* ((pages (max 1 (ceiling (length entries) size)))
+ (page (min page (1- pages)))
+ (visible (seq-subseq entries (min (* page size) (length entries))
+ (min (* (1+ page) size) (length entries)))))
+ (with-current-buffer buffer
+ (let ((inhibit-read-only t)
+ (width (max 20 (- (window-body-width window) 4))))
+ (erase-buffer)
+ (insert " " (propertize
+ (if removing
+ (if (eq source 'bookmarks) "Remove bookmark" "Hide recent buffer")
+ (if (eq source 'bookmarks) "Bookmarks" "Recent buffers"))
+ 'face 'my/quick-access-title)
+ " " (propertize (my/quick-access--clean-text workspace) 'face 'shadow))
+ (when group (insert (format " / %s%s" group prefix)))
+ (when (> pages 1) (insert (propertize (format " (%d/%d)" (1+ page) pages)
+ 'face 'shadow)))
+ (insert "\n\n")
+ (if (null entries)
+ (insert " " (propertize
+ (if (eq source 'bookmarks)
+ "No bookmarks yet. C-s: save current location; C-i: add existing."
+ "No other recent buffers in this workspace.")
+ 'face 'shadow) "\n")
+ (dolist (entry visible)
+ (let* ((item (cdr entry))
+ (key (substring (car entry) (length prefix)))
+ (start (point)))
+ (insert " " (propertize (format "%-3s" key) 'face 'my/quick-access-title)
+ (truncate-string-to-width
+ (my/quick-access--clean-text (my/quick-access--label item))
+ (- width 3) nil nil "…") "\n"
+ " " (propertize
+ (truncate-string-to-width
+ (my/quick-access--clean-text (my/quick-access--detail item))
+ (- width 3) nil nil "…")
+ 'face 'shadow) "\n")
+ (add-text-properties start (point)
+ `(my/quick-access-item ,item mouse-face highlight)))))
+ (insert "\n " (propertize
+ (concat (if (eq source 'bookmarks) "SPC Recent buffers" "SPC Bookmarks")
+ (if removing " C-d Cancel removal" " C-d Remove")
+ " C-f Search Esc Close") 'face 'shadow)
+ "\n " (propertize
+ (concat "C-s Save bookmark C-i Add existing"
+ (when group " Backspace Back")
+ (when (> pages 1) " C-j/C-k Page"))
+ 'face 'shadow) "\n")
+ (goto-char (point-min))))
+ (set-window-start window 1)
+ (fit-window-to-buffer window (max 6 (floor (* my/quick-access-max-height (frame-height)))) 5)
+ page))
+
+(defun my/quick-access--search (items &optional prompt)
+ "Read an ITEM through the user's existing completion interface."
+ (unless items (user-error "No entries in this workspace"))
+ (let* ((table (cl-loop for item in items for index from 1
+ collect (cons (format "%s — %s <%d>"
+ (my/quick-access--label item)
+ (my/quick-access--detail item) index)
+ item)))
+ (choice (completing-read (or prompt "Open: ") table nil t)))
+ (cdr (assoc choice table))))
+
+;;;###autoload
+(defun my/quick-access ()
+ "Open workspace bookmarks; SPC toggles recent buffers.
+
+Select bookmarks by initial and resolve collisions with `my/quick-access-selection-keys'; recent buffers use those keys directly. C-f searches the current source, including all eligible workspace buffers from the recent view. C-s saves the original location; C-i assigns an existing bookmark.
+
+C-d toggles removal: select an entry to unassign its bookmark or hide its buffer in this workspace. The popup stays open after removal. Hidden buffers stay hidden for this session; `my/quick-access-restore-buffers' restores them. Escape or C-g closes the popup and restores the original window."
+ (interactive)
+ (when (or my/quick-access--active (active-minibuffer-window))
+ (user-error "Finish the current selection first"))
+ (let* ((persp (get-current-persp))
+ (workspace (safe-persp-name persp))
+ (origin (selected-window))
+ (current (current-buffer))
+ (buffers (my/quick-access--buffers persp current))
+ (bookmarks (my/quick-access--bookmarks workspace))
+ (my/quick-access--active t)
+ (source 'bookmarks) group (prefix "") (page 0) removing
+ (size (max 1 (min my/quick-access-page-size (/ (- (floor (* my/quick-access-max-height (frame-height))) 7) 2))))
+ popup window selected
+ (pick (lambda (item)
+ (when item
+ (if removing
+ (progn
+ (my/quick-access--remove item persp workspace)
+ (if (bufferp item)
+ (setq buffers (delq item buffers))
+ (setq bookmarks (delq item bookmarks)))
+ (setq removing nil group nil prefix "" page 0))
+ (throw 'my/quick-access-done item))))))
+ (unwind-protect
+ (progn
+ (let ((persp-add-buffer-on-after-change-major-mode nil))
+ (setq popup (generate-new-buffer my/quick-access--buffer-name))
+ (with-current-buffer popup
+ (special-mode)
+ (setq-local header-line-format nil mode-line-format nil
+ display-line-numbers nil cursor-type nil
+ truncate-lines t show-trailing-whitespace nil)))
+ (setq window
+ (display-buffer-in-side-window
+ popup `((side . bottom) (slot . 10)
+ (window-height . ,my/quick-access-max-height)
+ (window-parameters . ((no-other-window . t)
+ (no-delete-other-windows . t))))))
+ (unless (window-live-p window) (user-error "Cannot display quick access"))
+ (setq selected
+ (catch 'my/quick-access-done
+ (while (and (window-live-p window) (window-live-p origin))
+ (let* ((items (if (eq source 'bookmarks)
+ (if group
+ (cl-remove-if-not
+ (lambda (item) (equal group (my/quick-access--initial item)))
+ bookmarks)
+ bookmarks)
+ (seq-take buffers my/quick-access-recent-count)))
+ (home (or group (eq source 'buffers)))
+ (entries (my/quick-access--entries items home prefix)))
+ (setq page (my/quick-access--render
+ popup window workspace source entries page size prefix group removing))
+ (let ((event (read-key)))
+ (cond
+ ((memq event '(27 escape ?\C-g)) (throw 'my/quick-access-done nil))
+ ((eq event ?\C-d) (setq removing (not removing)))
+ ((eq event ?\s)
+ (setq source (if (eq source 'bookmarks) 'buffers 'bookmarks)
+ group nil prefix "" page 0 removing nil))
+ ((eq event ?\M-b)
+ (setq source 'bookmarks group nil prefix "" page 0 removing nil))
+ ((memq event '(backspace 127))
+ (if (string-empty-p prefix) (setq group nil)
+ (setq prefix (substring prefix 0 -1)))
+ (setq page 0))
+ ((memq event '(?\C-j ?\C-k))
+ (setq page (mod (+ page (if (eq event ?\C-j) 1 -1))
+ (max 1 (ceiling (length entries) size)))))
+ ((memq event '(?\C-f ?/))
+ (condition-case err
+ (funcall pick
+ (my/quick-access--search
+ (if (eq source 'bookmarks) bookmarks buffers)
+ (when removing "Remove from quick access: ")))
+ (quit nil) (user-error (message "%s" (cadr err)))))
+ ((memq event '(?\C-s ?\C-i tab))
+ (condition-case err
+ (progn
+ (with-selected-window origin
+ (if (eq event ?\C-s)
+ (call-interactively #'bookmark-set)
+ (my/quick-access--add-bookmark workspace)))
+ (setq bookmarks (my/quick-access--bookmarks workspace)
+ source 'bookmarks group nil prefix "" page 0 removing nil))
+ (quit nil) (user-error (message "%s" (cadr err)))))
+ ((and (consp event) (eq (car event) 'mouse-1))
+ (let ((pos (event-start event)))
+ (when (eq (posn-window pos) window)
+ (when-let* ((point (posn-point pos))
+ ((integerp point))
+ (item (get-text-property point 'my/quick-access-item popup)))
+ (funcall pick item)))))
+ ((and (characterp event) (>= event 32))
+ (let* ((key (downcase (char-to-string event)))
+ (code (concat prefix key))
+ (matches (cl-remove-if-not
+ (lambda (entry)
+ (if home (string-prefix-p code (car entry))
+ (equal key (car entry)))) entries)))
+ (cond
+ ((and home (assoc code matches))
+ (funcall pick (cdr (assoc code matches))))
+ ((and (not home) (= 1 (length matches)))
+ (funcall pick (cdar matches)))
+ (matches
+ (if home (setq prefix code) (setq group key))
+ (setq page 0))))))))))))
+ (when (and (window-live-p window) (eq (window-buffer window) popup))
+ (delete-window window))
+ (when (buffer-live-p popup) (kill-buffer popup))
+ (when (window-live-p origin) (select-window origin)))
+ (when selected
+ (if (bufferp selected)
+ (if (buffer-live-p selected) (switch-to-buffer selected)
+ (user-error "That buffer has been closed"))
+ (bookmark-jump selected))
+ (when (bound-and-true-p persp-mode)
+ (persp-add-buffer (current-buffer) persp nil nil)))))
+
+(define-minor-mode my/quick-access-mode
+ "Track workspace buffer visits and assign newly saved bookmarks to their workspace."
+ :global t :group 'my/quick-access
+ (if my/quick-access-mode
+ (progn
+ (add-hook 'post-command-hook #'my/quick-access--track)
+ (advice-add 'bookmark-store :filter-args #'my/quick-access--scope-record)
+ (advice-add 'persp-rename :around #'my/quick-access--renamed))
+ (remove-hook 'post-command-hook #'my/quick-access--track)
+ (advice-remove 'bookmark-store #'my/quick-access--scope-record)
+ (advice-remove 'persp-rename #'my/quick-access--renamed)))
+
+(provide 'my-quick-access)
+;;; my-quick-access.el ends here
diff --git a/themes/tinty-theme.el b/themes/tinty-theme.el
index 90a0617..2942c9e 100644
--- a/themes/tinty-theme.el
+++ b/themes/tinty-theme.el
@@ -65,67 +65,67 @@
(deftheme tinty "Noctalia theme with proper color contrast.")
(let* (;; Tinted8 UI surface colors
- (ui-bg "#22212c")
- (ui-fg "#f8f8f2")
- (ui-fg-dim "#babaab")
+ (ui-bg "#f5f5f5")
+ (ui-fg "#1f1f1f")
+ (ui-fg-dim "#4f4f4f")
;; Use dim foreground for borders/muted chrome so they stay visible in
;; light variants (the raw border color is nearly white on latte).
- (ui-border "#babaab")
- (ui-line-highlight "#454158")
- (ui-bg-dark "#201f2e")
- (ui-bg-light "#36334c")
+ (ui-border "#4f4f4f")
+ (ui-line-highlight "#cfcfde")
+ (ui-bg-dark "#e5e5e5")
+ (ui-bg-light "#c5c5c5")
;; Tinted8 UI accent/status colors
- (ui-accent "#b4befe")
- (ui-link "#80ffea")
- (ui-info "#8c78f0")
- (ui-error "#ff9580")
- (ui-success "#8aff80")
- (ui-warning "#ffca80")
+ (ui-accent "#686e93")
+ (ui-link "#036a96")
+ (ui-info "#336699")
+ (ui-error "#cb3a2a")
+ (ui-success "#14710a")
+ (ui-warning "#a34d14")
;; Tinted8 UI container colors (for BACKGROUNDS only)
- (ui-selection-bg "#454158")
- (ui-selection-fg "#f8f8f2")
- (ui-button-bg "#2b293d")
- (ui-button-fg "#f8f8f2")
- (ui-tooltip-bg "#201f2e")
- (ui-tooltip-fg "#f8f8f2")
- (ui-active-text-bg "#454158")
- (ui-active-text-fg "#ededde")
- (ui-search-bg "#201f2e")
- (ui-search-fg "#ededde")
+ (ui-selection-bg "#cfcfde")
+ (ui-selection-fg "#1f1f1f")
+ (ui-button-bg "#d5d5d5")
+ (ui-button-fg "#1f1f1f")
+ (ui-tooltip-bg "#e5e5e5")
+ (ui-tooltip-fg "#1f1f1f")
+ (ui-active-text-bg "#cfcfde")
+ (ui-active-text-fg "#2f2f2f")
+ (ui-search-bg "#e5e5e5")
+ (ui-search-fg "#2f2f2f")
;; Tinted8 syntax colors
;; Comments use dim foreground instead of the raw syntax-comment gray,
;; which becomes almost invisible in light variants.
- (syntax-comment "#babaab")
- (syntax-constant "#ffca80")
- (syntax-function "#8aff80")
- (syntax-keyword "#ff80bf")
- (syntax-string "#ffff80")
- (syntax-type "#80ffea")
+ (syntax-comment "#4f4f4f")
+ (syntax-constant "#a34d14")
+ (syntax-function "#14710a")
+ (syntax-keyword "#a3144d")
+ (syntax-string "#846e15")
+ (syntax-type "#036a96")
;; Give variables/imported names a distinct hue instead of blending into
;; the default foreground.
- (syntax-variable "#f8f8f2")
- (syntax-warning "#ff9580")
- (syntax-preprocessor "#8c78f0")
- (syntax-annotation "#ffff80")
- (syntax-attribute "#80ffea")
- (syntax-number "#ffca80")
- (syntax-operator "#80ffea")
+ (syntax-variable "#1f1f1f")
+ (syntax-warning "#cb3a2a")
+ (syntax-preprocessor "#336699")
+ (syntax-annotation "#846e15")
+ (syntax-attribute "#036a96")
+ (syntax-number "#a34d14")
+ (syntax-operator "#036a96")
;; Raw palette colors for rainbow headings, delimiters, terminal faces,
;; and anywhere else we need a predictable hue cycle.
- (pal-red "#ff9580")
- (pal-orange "#ffca80")
- (pal-yellow "#ffff80")
- (pal-green "#8aff80")
- (pal-cyan "#80ffea")
- (pal-blue "#9580ff")
- (pal-magenta "#ff80bf")
- (pal-gray "#8a83b4")
- (pal-white "#f8f8f2")
- (pal-black "#8a898f")
+ (pal-red "#cb3a2a")
+ (pal-orange "#a34d14")
+ (pal-yellow "#846e15")
+ (pal-green "#14710a")
+ (pal-cyan "#036a96")
+ (pal-blue "#644ac9")
+ (pal-magenta "#a3144d")
+ (pal-gray "#635d97")
+ (pal-white "#6e6e6e")
+ (pal-black "#1f1f1f")
(h1 (if tinty-scale-headings tinty-height-1 1.0))
(h2 (if tinty-scale-headings tinty-height-2 1.0))
(h3 (if tinty-scale-headings tinty-height-3 1.0))
@@ -251,16 +251,16 @@
`(magit-branch-remote ((t (:foreground ,ui-success :weight bold))))
;; === Company ===
- `(company-tooltip ((t (:background ,ui-line-highlight :foreground ,ui-fg))))
- `(company-tooltip-selection ((t (:background ,ui-selection-bg :foreground ,ui-selection-fg))))
+ `(company-tooltip ((t (:background ,ui-tooltip-bg :foreground ,ui-tooltip-fg))))
+ `(company-tooltip-selection ((t (:background ,ui-selection-bg :foreground ,ui-selection-fg :weight bold))))
`(company-tooltip-deprecated ((t (:foreground ,ui-fg-dim :strike-through t))))
`(company-tooltip-search ((t (:foreground ,ui-accent :weight bold))))
`(company-tooltip-search-selection ((t (:foreground ,ui-accent :background ,ui-selection-bg :weight bold))))
`(company-tooltip-mouse ((t (:background ,ui-bg-light))))
`(company-tooltip-common ((t (:foreground ,ui-link :weight bold))))
- `(company-tooltip-common-selection ((t (:foreground ,ui-link :weight bold))))
+ `(company-tooltip-common-selection ((t (:foreground ,ui-selection-fg :weight bold))))
`(company-tooltip-annotation ((t (:foreground ,ui-fg-dim))))
- `(company-tooltip-annotation-selection ((t (:foreground ,ui-fg-dim))))
+ `(company-tooltip-annotation-selection ((t (:foreground ,ui-selection-fg))))
`(company-scrollbar-fg ((t (:background ,ui-border))))
`(company-scrollbar-bg ((t (:background ,ui-bg-dark))))
`(company-preview ((t (:foreground ,ui-fg-dim :slant italic))))
@@ -344,13 +344,13 @@
;; === Terminal colors - mapped to the palette so they stay correct in both variants ===
`(term-color-black ((t (:foreground ,ui-bg-dark :background ,ui-bg-dark))))
- `(term-color-red ((t (:foreground "#ff9580" :background "#ff9580"))))
- `(term-color-green ((t (:foreground "#8aff80" :background "#8aff80"))))
- `(term-color-yellow ((t (:foreground "#ffff80" :background "#ffff80"))))
- `(term-color-blue ((t (:foreground "#9580ff" :background "#9580ff"))))
- `(term-color-magenta ((t (:foreground "#ff80bf" :background "#ff80bf"))))
- `(term-color-cyan ((t (:foreground "#80ffea" :background "#80ffea"))))
- `(term-color-white ((t (:foreground "#f8f8f2" :background "#f8f8f2"))))
+ `(term-color-red ((t (:foreground "#cb3a2a" :background "#cb3a2a"))))
+ `(term-color-green ((t (:foreground "#14710a" :background "#14710a"))))
+ `(term-color-yellow ((t (:foreground "#846e15" :background "#846e15"))))
+ `(term-color-blue ((t (:foreground "#644ac9" :background "#644ac9"))))
+ `(term-color-magenta ((t (:foreground "#a3144d" :background "#a3144d"))))
+ `(term-color-cyan ((t (:foreground "#036a96" :background "#036a96"))))
+ `(term-color-white ((t (:foreground "#6e6e6e" :background "#6e6e6e"))))
;; === EShell ===
`(eshell-prompt ((t (:foreground ,ui-accent :weight bold))))
@@ -556,14 +556,14 @@
`(ansi-color-magenta ((t (:foreground ,pal-magenta :background ,pal-magenta))))
`(ansi-color-cyan ((t (:foreground ,pal-cyan :background ,pal-cyan))))
`(ansi-color-white ((t (:foreground ,pal-white :background ,pal-white))))
- `(ansi-color-bright-black ((t (:foreground "#8a8997" :background "#8a8997"))))
- `(ansi-color-bright-red ((t (:foreground "#ff9580" :background "#ff9580"))))
- `(ansi-color-bright-green ((t (:foreground "#8aff80" :background "#8aff80"))))
- `(ansi-color-bright-yellow ((t (:foreground "#ffff80" :background "#ffff80"))))
- `(ansi-color-bright-blue ((t (:foreground "#9580ff" :background "#9580ff"))))
- `(ansi-color-bright-magenta ((t (:foreground "#ff80bf" :background "#ff80bf"))))
- `(ansi-color-bright-cyan ((t (:foreground "#80ffea" :background "#80ffea"))))
- `(ansi-color-bright-white ((t (:foreground "#ededde" :background "#ededde"))))
+ `(ansi-color-bright-black ((t (:foreground "#4f4f4f" :background "#4f4f4f"))))
+ `(ansi-color-bright-red ((t (:foreground "#cb3a2a" :background "#cb3a2a"))))
+ `(ansi-color-bright-green ((t (:foreground "#14710a" :background "#14710a"))))
+ `(ansi-color-bright-yellow ((t (:foreground "#846e15" :background "#846e15"))))
+ `(ansi-color-bright-blue ((t (:foreground "#644ac9" :background "#644ac9"))))
+ `(ansi-color-bright-magenta ((t (:foreground "#a3144d" :background "#a3144d"))))
+ `(ansi-color-bright-cyan ((t (:foreground "#036a96" :background "#036a96"))))
+ `(ansi-color-bright-white ((t (:foreground "#707070" :background "#707070"))))
;; === Anzu ===
`(anzu-replace-highlight ((t (:background ,ui-line-highlight :foreground ,ui-error :weight bold :strike-through t))))
@@ -1365,14 +1365,14 @@
;; === Term (extended) ===
`(term ((t (:foreground ,ui-fg :background ,ui-bg))))
`(term-bold ((t (:weight bold))))
- `(term-color-bright-black ((t (:foreground "#8a8997" :background "#8a8997"))))
- `(term-color-bright-red ((t (:foreground "#ff9580" :background "#ff9580"))))
- `(term-color-bright-green ((t (:foreground "#8aff80" :background "#8aff80"))))
- `(term-color-bright-yellow ((t (:foreground "#ffff80" :background "#ffff80"))))
- `(term-color-bright-blue ((t (:foreground "#9580ff" :background "#9580ff"))))
- `(term-color-bright-magenta ((t (:foreground "#ff80bf" :background "#ff80bf"))))
- `(term-color-bright-cyan ((t (:foreground "#80ffea" :background "#80ffea"))))
- `(term-color-bright-white ((t (:foreground "#ededde" :background "#ededde"))))
+ `(term-color-bright-black ((t (:foreground "#4f4f4f" :background "#4f4f4f"))))
+ `(term-color-bright-red ((t (:foreground "#cb3a2a" :background "#cb3a2a"))))
+ `(term-color-bright-green ((t (:foreground "#14710a" :background "#14710a"))))
+ `(term-color-bright-yellow ((t (:foreground "#846e15" :background "#846e15"))))
+ `(term-color-bright-blue ((t (:foreground "#644ac9" :background "#644ac9"))))
+ `(term-color-bright-magenta ((t (:foreground "#a3144d" :background "#a3144d"))))
+ `(term-color-bright-cyan ((t (:foreground "#036a96" :background "#036a96"))))
+ `(term-color-bright-white ((t (:foreground "#707070" :background "#707070"))))
;; === Transient ===
`(transient-heading ((t (:foreground ,ui-accent :weight bold))))