website

Personal website
git clone https://github.com/michaelneuper/website.git
README | Files | Log | Refs

website/src/scripts/emacs/minibuffer.js (6175B)


// The minibuffer: completing-read with vertico-style candidates, used by M-x,
// evil's :, load-theme and anything else that asks for a choice.

import { $ } from "../util.js";
import { message } from "./echo.js";
import { evilState } from "./modeline.js";

const mini = $(".mini");
const form = $(".mini-prompt");
const input = $("input", form);
const psEl = $(".mini-ps");
const countEl = $(".mini-count");
const list = $(".mini-list");

let pending = null;

// Resolves to the chosen string, or null if cancelled. `candidates` is an array of
// { value, ann, key } (or strings), or a function of the input returning one.
export function completingRead(prompt, candidates, opts = {}) {
  const { initial = "", requireMatch = true, select = null, onPreview = null } = opts;
  if (pending) pending.finish(null);
  const returnFocus = document.activeElement;

  return new Promise((resolve) => {
    let items = [];
    let sel = 0;
    const norm = (c) => (typeof c === "string" ? { value: c } : c);

    const render = () => {
      const q = input.value.trim().toLowerCase();
      const all = (typeof candidates === "function" ? candidates(input.value) : candidates).map(norm);
      const terms = q.split(/\s+/).filter(Boolean);
      items = typeof candidates === "function" ? all : all.filter((c) => terms.every((t) => c.value.toLowerCase().includes(t)));
      // candidates that start with what you typed come first
      if (terms.length && typeof candidates !== "function") {
        const starts = (c) => c.value.toLowerCase().startsWith(terms[0]);
        items = [...items.filter(starts), ...items.filter((c) => !starts(c))];
      }
      sel = Math.min(sel, Math.max(0, items.length - 1));
      countEl.textContent = items.length ? `${sel + 1}/${items.length}` : "0/0";
      list.replaceChildren(...items.map((c, i) => {
        const li = document.createElement("li");
        li.setAttribute("role", "option");
        li.setAttribute("aria-selected", String(i === sel));
        li.id = `mini-opt-${i}`;
        const cand = document.createElement("span");
        cand.className = "cand";
        cand.append(...highlight(c.value, terms));
        li.append(cand);
        if (c.ann) {
          const a = document.createElement("span");
          a.className = "ann";
          a.textContent = c.ann;
          li.append(a);
        }
        if (c.key) {
          const k = document.createElement("span");
          k.className = "key";
          k.textContent = c.key;
          li.append(k);
        }
        li.addEventListener("mousedown", (e) => e.preventDefault());
        li.addEventListener("click", () => { sel = i; accept(); });
        return li;
      }));
      input.setAttribute("aria-activedescendant", items.length ? `mini-opt-${sel}` : "");
      list.children[sel]?.scrollIntoView({ block: "nearest" });
    };

    const move = (d) => {
      if (!items.length) return;
      sel = (sel + d + items.length) % items.length;
      render();
      onPreview?.(items[sel].value);
    };

    const accept = () => {
      const raw = input.value.trim();
      const hit = items[sel]?.value;
      if (hit !== undefined && (requireMatch || typeof candidates !== "function" || raw === "")) finish(hit);
      else if (!requireMatch) finish(raw || hit || null);
      else if (hit !== undefined) finish(hit);
      else message("[No match]", "error", 1500);
    };

    const onKey = (e) => {
      const ctrl = e.ctrlKey && !e.metaKey;
      if (e.key === "ArrowDown" || (ctrl && e.key === "n")) { e.preventDefault(); move(1); }
      else if (e.key === "ArrowUp" || (ctrl && e.key === "p")) { e.preventDefault(); move(-1); }
      else if (e.key === "Enter") { e.preventDefault(); accept(); }
      else if (e.key === "Escape" || (ctrl && e.key === "g")) { e.preventDefault(); finish(null); }
      else if (e.key === "Tab") {
        e.preventDefault();
        if (items[sel]) {
          input.value = items[sel].value;
          render();
        }
      }
    };
    const onInput = () => {
      sel = 0;
      render();
      if (items.length) onPreview?.(items[0].value);
    };
    const onBlur = () => setTimeout(() => { if (pending?.finish === finish && document.activeElement !== input) finish(null); }, 0);
    const onSubmit = (e) => e.preventDefault();

    function finish(value) {
      if (pending?.finish !== finish) return;
      pending = null;
      input.removeEventListener("keydown", onKey);
      input.removeEventListener("input", onInput);
      input.removeEventListener("blur", onBlur);
      form.removeEventListener("submit", onSubmit);
      mini.hidden = true;
      list.replaceChildren();
      if (returnFocus && returnFocus !== input && document.contains(returnFocus)) returnFocus.focus({ preventScroll: true });
      else input.blur();
      evilState();
      resolve(value);
    }

    pending = { finish };
    psEl.textContent = prompt;
    input.value = initial;
    mini.hidden = false;
    input.addEventListener("keydown", onKey);
    input.addEventListener("input", onInput);
    input.addEventListener("blur", onBlur);
    form.addEventListener("submit", onSubmit);
    if (select) {
      const all = (typeof candidates === "function" ? candidates("") : candidates).map(norm);
      sel = Math.max(0, all.findIndex((c) => c.value === select));
    }
    render();
    input.focus({ preventScroll: true });
    evilState();
  });
}

function highlight(text, terms) {
  if (!terms.length) return [text];
  const lower = text.toLowerCase();
  const marks = new Array(text.length).fill(false);
  for (const t of terms) {
    const i = lower.indexOf(t);
    if (i >= 0) for (let k = i; k < i + t.length; k++) marks[k] = true;
  }
  const out = [];
  let buf = "";
  let on = false;
  for (let i = 0; i <= text.length; i++) {
    const m = i < text.length && marks[i];
    if (i === text.length || m !== on) {
      if (buf) {
        if (on) {
          const mk = document.createElement("mark");
          mk.textContent = buf;
          out.push(mk);
        } else out.push(buf);
      }
      buf = "";
      on = m;
    }
    if (i < text.length) buf += text[i];
  }
  return out;
}