diff options
| author | cashmere <cashmere@autistici.org> | 2026-09-03 15:50:28 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-09-03 15:50:28 +0200 |
| commit | 91ee05480922ed885973ece11a13ed7cf0b43f16 (patch) | |
| tree | 447e42bee33ea03b8b55340bb4be787ca88fa08a | |
| parent | a44f66e0b6ea0a6fafa5d998a338baea724ed7f3 (diff) | |
| download | emacs-91ee05480922ed885973ece11a13ed7cf0b43f16.tar.gz | |
md: 2026-09-03T15:50:32+02:00
| -rw-r--r-- | .gitignore | 3 | ||||
| -rwxr-xr-x | bin/project-ctags | 156 | ||||
| -rw-r--r-- | config.el | 163 | ||||
| -rw-r--r-- | config.org | 168 | ||||
| -rw-r--r-- | lisp/citre-backend.el | 138 | ||||
| -rw-r--r-- | lisp/citre-index.el | 469 | ||||
| -rw-r--r-- | lisp/my-header-line.el | 162 | ||||
| -rw-r--r-- | lisp/my-quick-access.el | 419 | ||||
| -rw-r--r-- | themes/tinty-theme.el | 146 |
9 files changed, 1596 insertions, 228 deletions
@@ -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 @@ -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 @@ -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)))) |
