import { Decoration, EditorView, ViewPlugin, WidgetType, showPanel, type DecorationSet, type Panel, type ViewUpdate, } from "@codemirror/view"; import { getMode, helixState } from "./state"; class EolCursorWidget extends WidgetType { toDOM() { const span = document.createElement("span"); span.className = "cm-helix-block cm-helix-block-eol"; span.textContent = " "; return span; } ignoreEvent() { return true; } } const blockMark = Decoration.mark({ class: "cm-helix-block" }); const selectionMark = Decoration.mark({ class: "cm-helix-selection" }); const eolCursor = Decoration.widget({ widget: new EolCursorWidget(), side: 1 }); // Draws the selection ranges + a block cursor at each head while in // normal/select mode. We render the selection ourselves (rather than relying // on drawSelection, which the host theme can leave invisible) so multi-cursor // selections are always clearly highlighted. export const blockCursor = ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(view: EditorView) { this.decorations = this.build(view); } update(update: ViewUpdate) { if ( update.docChanged || update.selectionSet || update.viewportChanged || update.startState.field(helixState).mode !== update.state.field(helixState).mode ) { this.decorations = this.build(update.view); } } build(view: EditorView): DecorationSet { if (getMode(view.state) === "insert") return Decoration.none; const deco: ReturnType[] = []; for (const range of view.state.selection.ranges) { if (!range.empty) deco.push(selectionMark.range(range.from, range.to)); const pos = range.head; const line = view.state.doc.lineAt(pos); deco.push(pos < line.to ? blockMark.range(pos, pos + 1) : eolCursor.range(pos)); } return Decoration.set(deco, true); } }, { decorations: (plugin) => plugin.decorations }, ); export const modeEditorClass = EditorView.editorAttributes.compute([helixState], (state) => ({ class: `cm-helix cm-helix-${getMode(state)}`, })); function statusPanel(view: EditorView): Panel { const dom = document.createElement("div"); dom.className = "cm-helix-status"; const render = () => { const s = view.state.field(helixState); const label = s.mode === "insert" ? "INS" : s.mode === "select" ? "SEL" : "NOR"; const count = s.count ? ` ${s.count}` : ""; const reg = s.register ? ` "${s.register}` : ""; const pend = s.pending ? ` ${s.pending.type}` : ""; const sels = view.state.selection.ranges.length; const multi = sels > 1 ? ` ${sels} sels` : ""; dom.textContent = `${label}${count}${reg}${pend}${multi}`; dom.dataset.mode = s.mode; }; render(); return { dom, update: (u) => { if (u.docChanged || u.selectionSet || u.transactions.length) render(); }, }; } export const helixStatusPanel = showPanel.of(statusPanel); export const helixTheme = EditorView.baseTheme({ "&.cm-helix-normal .cm-cursor, &.cm-helix-normal .cm-cursorLayer": { display: "none" }, "&.cm-helix-select .cm-cursor, &.cm-helix-select .cm-cursorLayer": { display: "none" }, ".cm-helix-selection": { backgroundColor: "rgba(120,160,255,0.32)" }, ".cm-helix-block": { backgroundColor: "rgba(125,165,255,0.7)", borderRadius: "1px" }, ".cm-helix-block-eol": { display: "inline-block", width: "0.55em" }, ".cm-helix-status": { padding: "1px 10px", font: "11px ui-monospace, SFMono-Regular, Menlo, monospace", letterSpacing: "0.05em", }, ".cm-helix-status[data-mode=insert]": { color: "#16a34a" }, ".cm-helix-status[data-mode=select]": { color: "#d97706" }, ".cm-helix-status[data-mode=normal]": { color: "#2563eb" }, ".cm-helix-prompt": { display: "flex", gap: "0.5em", alignItems: "center", padding: "2px 10px", font: "12px ui-monospace, SFMono-Regular, Menlo, monospace", }, ".cm-helix-prompt label": { opacity: 0.7 }, ".cm-helix-prompt input": { flex: "1", border: "none", outline: "none", background: "transparent", color: "inherit", font: "inherit", }, });