mirror of
https://github.com/tridactyl/tridactyl.git
synced 2026-09-10 07:16:33 -04:00
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
This commit is contained in:
parent
655c0fe724
commit
a04019e38c
|
|
@ -5,6 +5,7 @@ import type { MinimalKey } from "@src/lib/keyseq"
|
|||
import { splitNumericPrefix } from "@src/lib/keyseq"
|
||||
import * as Messaging from "@src/lib/messaging"
|
||||
import { mode2maps } from "@src/lib/binding"
|
||||
import * as Metadata from "@src/.metadata.generated"
|
||||
|
||||
const logger = new Logger("whichkey")
|
||||
|
||||
|
|
@ -15,6 +16,8 @@ let lastShownPrefixStr: string | null = null
|
|||
let lastMultiColumn = false // Tracks whether last show() was multi-column for resize() to restore position
|
||||
let lastShownMode: string | null = null
|
||||
let lastShownPrefix: MinimalKey[] = []
|
||||
let lastRenderedMode: string | null = null
|
||||
let lastRenderedPressedPrefix: string | null = null
|
||||
|
||||
function makeIframe() {
|
||||
wk_iframe = window.document.createElementNS(
|
||||
|
|
@ -85,13 +88,97 @@ function columnsFor(rowCount: number): number {
|
|||
return Math.min(columnCount, maxColumns)
|
||||
}
|
||||
|
||||
const TOKEN_RE = /^(<[^>]+>|.)(.*)/s
|
||||
|
||||
function nextTokenOf(keyStr: string, skipN: number): string {
|
||||
let s = keyStr
|
||||
for (let i = 0; i < skipN; i++) {
|
||||
const m = TOKEN_RE.exec(s)
|
||||
if (!m) return ""
|
||||
s = m[2]
|
||||
}
|
||||
const m = TOKEN_RE.exec(s)
|
||||
return m ? m[1] : ""
|
||||
}
|
||||
|
||||
function resolveDisplayText(
|
||||
_mode: string,
|
||||
_keyStr: string,
|
||||
exstr: string,
|
||||
): string | undefined {
|
||||
const parts = exstr.trim().split(/\s+/)
|
||||
const cmdWord = parts[0]
|
||||
const flagArgs = parts.slice(1).filter((p: string) => p.startsWith("-"))
|
||||
// Check metadata @arg flags: try combined string first, then right-to-left chars
|
||||
const metaFlags = Metadata.excmdsFunctions[cmdWord]?.flags as
|
||||
| Record<string, string>
|
||||
| undefined
|
||||
if (metaFlags && flagArgs.length > 0) {
|
||||
// For -W/-F/-pipe, append the next positional arg to give context
|
||||
const wIdx = parts.indexOf("-W")
|
||||
if (wIdx !== -1 && parts[wIdx + 1]) return `run: ${parts[wIdx + 1]}`
|
||||
const combined = flagArgs.map((f: string) => f.slice(1)).join("")
|
||||
if (metaFlags[`-${combined}`] !== undefined) return metaFlags[`-${combined}`]
|
||||
for (let i = combined.length - 1; i >= 0; i--) {
|
||||
const label = metaFlags[`-${combined[i]}`]
|
||||
if (label !== undefined) return label
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function show(mode: string, prefix: MinimalKey[]) {
|
||||
if (config.get("whichkeyenabled") === "false") return
|
||||
if (!mode2maps.has(mode)) return
|
||||
|
||||
const [countKeys, cmdKeys] =
|
||||
const [countKeys, rawCmdKeys] =
|
||||
prefix.length > 0 ? splitNumericPrefix(prefix) : [[], prefix]
|
||||
const countStr = countKeys.map(k => k.key).join("")
|
||||
const cmdKeys = rawCmdKeys.filter(k => !k.keyup)
|
||||
const pressedPrefix = cmdKeys.map(k => k.toMapstr()).join("")
|
||||
|
||||
// Header prefix: each key token separated by » (e.g. "12 » g").
|
||||
const prefixTokens = [
|
||||
...(countStr ? [countStr] : []),
|
||||
...cmdKeys.map(k => k.toMapstr()),
|
||||
].filter(Boolean)
|
||||
const prefixStr = prefixTokens.join(" » ")
|
||||
|
||||
// Modifier keydowns (e.g. Control before Escape) cause redundant show() calls with the same prefix, skip to avoid reset/resize cycle.
|
||||
if (
|
||||
wk_iframe &&
|
||||
!wk_iframe.classList.contains("hidden") &&
|
||||
prefixStr === lastShownPrefixStr
|
||||
)
|
||||
return
|
||||
|
||||
// Handle count digits changes, update header only
|
||||
if (
|
||||
wk_iframe &&
|
||||
!wk_iframe.classList.contains("hidden") &&
|
||||
mode === lastRenderedMode &&
|
||||
pressedPrefix === lastRenderedPressedPrefix
|
||||
) {
|
||||
const docs = config.get("whichkeydocs") ?? {}
|
||||
const headingText: string | undefined =
|
||||
pressedPrefix ? docs.headings?.[mode]?.[pressedPrefix] : undefined
|
||||
const headerLabel = headingText ? `+${headingText}` : prefixStr
|
||||
|
||||
const delay = parseInt(config.get("whichkeydelay") as string, 10) || 0
|
||||
if (showTimer !== null) clearTimeout(showTimer)
|
||||
const doUpdateHeader = () => {
|
||||
showTimer = null
|
||||
lastShownPrefixStr = prefixStr
|
||||
lastShownPrefix = prefix
|
||||
Messaging.messageOwnTab("whichkey_frame", "updateHeader", [
|
||||
headerLabel,
|
||||
showGeneration,
|
||||
])
|
||||
}
|
||||
if (delay > 0) showTimer = setTimeout(doUpdateHeader, delay)
|
||||
else doUpdateHeader()
|
||||
return
|
||||
}
|
||||
|
||||
let matches: [string, string][]
|
||||
try {
|
||||
|
|
@ -99,12 +186,13 @@ export function show(mode: string, prefix: MinimalKey[]) {
|
|||
if (conf === undefined) return
|
||||
const map = keyseq.keyMap(conf)
|
||||
|
||||
// Use only the non-count part of the prefix for completions.
|
||||
// show only bare key prefix pressed so far to find what can come next.
|
||||
matches = [...keyseq.completions(cmdKeys, map).entries()]
|
||||
.map(
|
||||
([ks, exstr]) =>
|
||||
[
|
||||
ks.map(k => k.toMapstr()).join(""),
|
||||
// Drop the optional release placeholders
|
||||
ks.filter(k => !k.optional).map(k => k.toMapstr()).join(""),
|
||||
typeof exstr === "string" ? exstr : String(exstr),
|
||||
] as [string, string],
|
||||
)
|
||||
|
|
@ -124,38 +212,56 @@ export function show(mode: string, prefix: MinimalKey[]) {
|
|||
return
|
||||
}
|
||||
|
||||
// Header prefix: each key token separated by » (e.g. "12 » g").
|
||||
const prefixTokens = [
|
||||
...(countStr ? [countStr] : []),
|
||||
...cmdKeys.map(k => k.toMapstr()),
|
||||
].filter(Boolean)
|
||||
const prefixStr = prefixTokens.join(" » ")
|
||||
const docs = config.get("whichkeydocs") ?? {}
|
||||
const depth = cmdKeys.length
|
||||
|
||||
// Modifier keydowns (e.g. Control before Escape) cause redundant show() calls with the same prefix, skip to avoid reset/resize cycle.
|
||||
if (
|
||||
wk_iframe &&
|
||||
!wk_iframe.classList.contains("hidden") &&
|
||||
prefixStr === lastShownPrefixStr
|
||||
)
|
||||
return
|
||||
// Group completions by their next token to detect and collapse sub-prefixes.
|
||||
const byNextToken = new Map<string, [string, string][]>()
|
||||
for (const match of matches) {
|
||||
const tok = nextTokenOf(match[0], depth)
|
||||
let group = byNextToken.get(tok)
|
||||
if (!group) {
|
||||
group = []
|
||||
byNextToken.set(tok, group)
|
||||
}
|
||||
group.push(match)
|
||||
}
|
||||
|
||||
const resolvedMatches: [string, string, string?][] = []
|
||||
for (const [tok, group] of byNextToken) {
|
||||
if (group.length === 1) {
|
||||
const [ks, exstr] = group[0]
|
||||
resolvedMatches.push([ks, exstr, resolveDisplayText(mode, ks, exstr)])
|
||||
} else {
|
||||
// Multiple completions share this next token, collapse into one row.
|
||||
const subKey = pressedPrefix + tok
|
||||
const heading = docs.headings?.[mode]?.[subKey]
|
||||
resolvedMatches.push([subKey, "", heading ? `+${heading}` : `+[${group.length}]`])
|
||||
}
|
||||
}
|
||||
|
||||
const headingText: string | undefined =
|
||||
pressedPrefix ? docs.headings?.[mode]?.[pressedPrefix] : undefined
|
||||
|
||||
const delay = parseInt(config.get("whichkeydelay") as string, 10) || 0
|
||||
if (showTimer !== null) clearTimeout(showTimer)
|
||||
|
||||
const doShow = () => {
|
||||
showTimer = null
|
||||
const columnCount = columnsFor(matches.length)
|
||||
const columnCount = columnsFor(resolvedMatches.length)
|
||||
lastMultiColumn = columnCount > 1
|
||||
const gen = ++showGeneration
|
||||
lastShownPrefixStr = prefixStr
|
||||
lastShownMode = mode
|
||||
lastShownPrefix = prefix
|
||||
lastRenderedMode = mode
|
||||
lastRenderedPressedPrefix = pressedPrefix
|
||||
|
||||
const loc = applyPosition(lastMultiColumn)
|
||||
ensureIframeExists()
|
||||
wk_iframe.classList.remove("hidden")
|
||||
wk_iframe.style.setProperty("opacity", "0", "important") // Keep invisible until resize()
|
||||
const rowsInTallestCol = Math.ceil(matches.length / columnCount)
|
||||
const rowsInTallestCol = Math.ceil(resolvedMatches.length / columnCount)
|
||||
const estimatedHeight = Math.min(
|
||||
rowsInTallestCol * APPROX_ROW_HEIGHT_PX +
|
||||
(prefixStr ? APPROX_HEADER_HEIGHT_PX : 0),
|
||||
|
|
@ -167,13 +273,14 @@ export function show(mode: string, prefix: MinimalKey[]) {
|
|||
"important",
|
||||
)
|
||||
Messaging.messageOwnTab("whichkey_frame", "update", [
|
||||
matches,
|
||||
resolvedMatches,
|
||||
columnCount,
|
||||
gen,
|
||||
cmdKeys.length,
|
||||
loc,
|
||||
prefixStr,
|
||||
countKeys.length > 0,
|
||||
headingText,
|
||||
])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
--tridactyl-wk-key-fg: var(--tridactyl-cmplt-fg, black);
|
||||
--tridactyl-wk-key-next-fg: #ffa500;
|
||||
--tridactyl-wk-exstr-fg: var(--tridactyl-url-fg, #1f9947);
|
||||
--tridactyl-wk-group-fg: #5b6af0;
|
||||
--tridactyl-wk-border: var(--tridactyl-cmplt-border-top, 1px solid #ccc);
|
||||
--tridactyl-wk-outer-border: var(
|
||||
--tridactyl-cmplt-border-top,
|
||||
|
|
@ -31,6 +32,7 @@
|
|||
--tridactyl-wk-key-fg: var(--tridactyl-cmplt-fg, #e0e0e0);
|
||||
--tridactyl-wk-key-next-fg: #ffb347;
|
||||
--tridactyl-wk-exstr-fg: var(--tridactyl-url-fg, #5faf5f);
|
||||
--tridactyl-wk-group-fg: #7b8ff5;
|
||||
--tridactyl-wk-border: var(
|
||||
--tridactyl-cmplt-border-top,
|
||||
1px solid #555
|
||||
|
|
@ -173,3 +175,10 @@ body[data-location="center"] #whichkey-binds {
|
|||
.wk-exstr:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wk-group .wk-exstr,
|
||||
.wk-group .wk-exstr:link,
|
||||
.wk-group .wk-exstr:visited {
|
||||
color: var(--tridactyl-wk-group-fg, #5b6af0) !important;
|
||||
cursor: default;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,14 +40,14 @@ function commandAcceptsCount(exstr: string): boolean {
|
|||
}
|
||||
|
||||
function buildColumn(
|
||||
matches: [string, string][],
|
||||
matches: [string, string, string?][],
|
||||
prefixLen: number,
|
||||
): HTMLDivElement {
|
||||
const col = document.createElement("div")
|
||||
col.className = "wk-column"
|
||||
for (const [keyStr, exstr] of matches) {
|
||||
for (const [keyStr, exstr, displayText] of matches) {
|
||||
const row = document.createElement("div")
|
||||
row.className = "wk-row"
|
||||
row.className = exstr ? "wk-row" : "wk-row wk-group"
|
||||
|
||||
const keySpan = document.createElement("span")
|
||||
keySpan.className = "wk-key"
|
||||
|
|
@ -67,11 +67,15 @@ function buildColumn(
|
|||
|
||||
const exstrEl = document.createElement("a")
|
||||
exstrEl.className = "wk-exstr"
|
||||
exstrEl.textContent = exstr
|
||||
exstrEl.textContent = displayText ?? exstr
|
||||
const cmdWord = exstr.trim().split(/\s+/)[0]
|
||||
if (cmdWord) {
|
||||
const doc = Metadata.getDoc(Metadata.excmdsFunctions[cmdWord])
|
||||
if (doc) exstrEl.title = doc
|
||||
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",
|
||||
|
|
@ -89,47 +93,8 @@ function buildColumn(
|
|||
return col
|
||||
}
|
||||
|
||||
/** Render matches split across `columnCount` columns, then report actual height back to content */
|
||||
export function update(
|
||||
matches: [string, string][],
|
||||
columnCount: number,
|
||||
generation: number,
|
||||
prefixLen: number,
|
||||
location = "left",
|
||||
prefixStr = "",
|
||||
hasCountPrefix = false,
|
||||
) {
|
||||
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)
|
||||
|
||||
let headerEl: HTMLElement | null = null
|
||||
if (prefixStr) {
|
||||
headerEl = document.createElement("div")
|
||||
headerEl.className = "wk-header"
|
||||
headerEl.textContent = prefixStr
|
||||
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)
|
||||
|
||||
/** 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
|
||||
|
|
@ -145,6 +110,9 @@ export function update(
|
|||
? (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 +
|
||||
|
|
@ -165,6 +133,73 @@ export function update(
|
|||
})
|
||||
}
|
||||
|
||||
/** 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"))
|
||||
|
|
|
|||
Loading…
Reference in a new issue