tridactyl.tridactyl/src/whichkey_frame.ts
Robert Hill a04019e38c feat(whichkey): resolve flag labels, collapse sub-prefixes, header-only redraws
- resolveDisplayText() looks up per-flag descriptions from excmd metadata
  (falls back to raw exstr when no flags map exists) so e.g. ';t' shows
  'new tab' instead of 'hint -t'
- completions sharing the next key token collapse into a single '+N' or
  '+heading' group row instead of listing every leaf individually
- count-prefix keystrokes (e.g. '1', '2' before a repeat count) now patch
  the header text directly via updateHeader() instead of tearing down and
  rebuilding the whole binding list, avoiding a resize/reflow flash
- wk-group rows get a distinct color in whichkey.css
2026-08-27 11:52:07 -04:00

206 lines
7 KiB
TypeScript

import * as SELF from "@src/whichkey_frame"
import Logger from "@src/lib/logging"
import * as Messaging from "@src/lib/messaging"
import * as Metadata from "@src/.metadata.generated"
import { theme } from "@src/content/styling"
const logger = new Logger("whichkey_frame")
const bindsEl = window.document.getElementById("whichkey-binds")
const TOKEN_RE = /^(<[^>]+>|.)(.*)/s
/** Split keyStr into [first n tokens, remainder] */
function splitAtPrefix(keyStr: string, n: number): [string, string] {
let remaining = keyStr
let pressed = ""
for (let i = 0; i < n; i++) {
const m = TOKEN_RE.exec(remaining)
if (!m) return [pressed, remaining]
pressed += m[1]
remaining = m[2]
}
return [pressed, remaining]
}
function argAcceptsNumber(t: any): boolean {
if (!t) return false
if (t.type === "intrinsic" && t.name === "number") return true
if (t.type === "union" && Array.isArray(t.types))
return t.types.some((u: any) => argAcceptsNumber(u))
return false
}
function commandAcceptsCount(exstr: string): boolean {
const cmdWord = exstr.trim().split(/\s+/)[0]
if (!cmdWord) return false
const fn = Metadata.excmdsFunctions[cmdWord]
if (!fn) return false
return fn.params.some((p: any) => argAcceptsNumber(p.type))
}
function buildColumn(
matches: [string, string, string?][],
prefixLen: number,
): HTMLDivElement {
const col = document.createElement("div")
col.className = "wk-column"
for (const [keyStr, exstr, displayText] of matches) {
const row = document.createElement("div")
row.className = exstr ? "wk-row" : "wk-row wk-group"
const keySpan = document.createElement("span")
keySpan.className = "wk-key"
// Only show the part after the pressed prefix; prefix is in the header.
const [, remaining] = splitAtPrefix(keyStr, prefixLen)
if (remaining) {
const nextSpan = document.createElement("span")
nextSpan.className = "wk-key-next"
nextSpan.textContent = remaining
keySpan.appendChild(nextSpan)
}
const arrowSpan = document.createElement("span")
arrowSpan.className = "wk-arrow"
arrowSpan.textContent = "→"
const exstrEl = document.createElement("a")
exstrEl.className = "wk-exstr"
exstrEl.textContent = displayText ?? exstr
const cmdWord = exstr.trim().split(/\s+/)[0]
if (cmdWord) {
if (displayText) {
exstrEl.title = exstr
} else {
const doc = Metadata.getDoc(Metadata.excmdsFunctions[cmdWord])
if (doc) exstrEl.title = doc
}
exstrEl.href =
browser.runtime.getURL(
"static/docs/modules/_src_excmds_.html",
) +
"#" +
cmdWord
exstrEl.target = "_blank"
}
row.appendChild(keySpan)
row.appendChild(arrowSpan)
row.appendChild(exstrEl)
col.appendChild(row)
}
return col
}
/** Measure rendered content and report the height/width back to content */
function measureAndReportSize(generation: number, headerEl: HTMLElement | null) {
requestAnimationFrame(() => {
const firstRow = bindsEl.querySelector<HTMLElement>(".wk-row")
const rowHeightPx = firstRow
? parseFloat(getComputedStyle(firstRow).height)
: 20
const bindsStyle = getComputedStyle(bindsEl)
const bindsBorderPx =
(parseFloat(bindsStyle.borderTopWidth) || 0) +
(parseFloat(bindsStyle.borderBottomWidth) || 0)
const firstCol = bindsEl.querySelector<HTMLElement>(".wk-column")
const colStyle = firstCol ? getComputedStyle(firstCol) : null
const colPaddingPx = colStyle
? (parseFloat(colStyle.paddingTop) || 0) +
(parseFloat(colStyle.paddingBottom) || 0)
: 0
const rows = bindsEl.querySelectorAll(".wk-row").length
const cols = bindsEl.querySelectorAll(".wk-column").length || 1
const rowsPerCol = Math.ceil(rows / cols)
const headerHeightPx = headerEl ? headerEl.offsetHeight : 0
const naturalHeight = Math.ceil(
rowsPerCol * rowHeightPx +
bindsBorderPx +
colPaddingPx +
headerHeightPx,
)
// scrollWidth gives full text width even when clipped by overflow:hidden on parent.
// 24 = outer-border(2+2) + margin(6+6) + cushion(8)
const headerMinWidthPx = headerEl
? Math.ceil(headerEl.scrollWidth + 24)
: 0
Messaging.messageOwnTab("whichkey_content", "resize", [
naturalHeight,
generation,
headerMinWidthPx,
])
})
}
/** Render matches split across `columnCount` columns, then report actual height back to content */
export function update(
matches: [string, string, string?][],
columnCount: number,
generation: number,
prefixLen: number,
location = "left",
prefixStr = "",
hasCountPrefix = false,
headingText?: string,
) {
if (!bindsEl) {
logger.error("whichkey_frame: DOM elements not found")
return
}
document.body.dataset.location = location
if (hasCountPrefix)
matches = matches.filter(([, exstr]) => commandAcceptsCount(exstr))
while (bindsEl.firstChild) bindsEl.removeChild(bindsEl.firstChild)
const headerLabel = headingText ? `+${headingText}` : prefixStr
let headerEl: HTMLElement | null = null
if (headerLabel) {
headerEl = document.createElement("div")
headerEl.className = "wk-header"
headerEl.textContent = headerLabel
bindsEl.appendChild(headerEl)
}
const columnsEl = document.createElement("div")
columnsEl.className = "wk-columns"
const cols = Math.max(1, columnCount)
const rowsPerCol = Math.ceil(matches.length / cols)
for (let i = 0; i < cols; i++) {
const slice = matches.slice(i * rowsPerCol, (i + 1) * rowsPerCol)
if (slice.length > 0)
columnsEl.appendChild(buildColumn(slice, prefixLen))
}
bindsEl.appendChild(columnsEl)
measureAndReportSize(generation, headerEl)
}
/** Handle header-only changes, skip rebuild and update header directly */
export function updateHeader(headerLabel: string, generation: number) {
if (!bindsEl) {
logger.error("whichkey_frame: DOM elements not found")
return
}
let headerEl = bindsEl.querySelector<HTMLElement>(".wk-header")
if (headerLabel) {
if (!headerEl) {
headerEl = document.createElement("div")
headerEl.className = "wk-header"
bindsEl.insertBefore(headerEl, bindsEl.firstChild)
}
headerEl.textContent = headerLabel
} else if (headerEl) {
headerEl.remove()
headerEl = null
}
measureAndReportSize(generation, headerEl)
}
Messaging.addListener("whichkey_frame", Messaging.attributeCaller(SELF))
theme(document.querySelector(":root"))