export class CommandLine extends HTMLElement {
static commands = new Map();
constructor() {
super();
}
connectedCallback() {
this.suggestions = this.appendChild(document.createElement("ul"));
this.suggestions.classList.add("suggestions");
let inputWrapper = this.appendChild(document.createElement("div"));
inputWrapper.classList.add("input-wrapper");
this.input = inputWrapper.appendChild(document.createElement("input"));
this.input.type = "text";
window.addEventListener("keydown", (event) => {
if (event.key == ":" && !this.visible) {
event.stopPropagation();
event.preventDefault();
this.show();
}
if (event.key == "Escape") {
this.hide();
}
});
window.addEventListener("click", () => {
this.hide();
});
this.addEventListener("click", (event) => {
event.stopPropagation();
});
this.input.addEventListener("keydown", (event) => {
if (event.key == "Enter") {
event.preventDefault();
this.hide();
this.runCommand(this.input.value);
}
if (event.key == "Tab") {
event.preventDefault();
if (event.shiftKey) this.tabToPreviousSuggestion();
else this.tabToNextSuggestion();
}
});
this.input.addEventListener("input", () => {
this.updateSuggestions();
});
}
get visible() {
return this.classList.contains("visible");
}
show() {
this.classList.add("visible");
this.input.focus();
this.input.value = "";
this.updateSuggestions();
}
hide() {
this.classList.remove("visible");
}
tab(current, next) {
current?.classList?.remove("tabbed");
next.classList.add("tabbed");
this.input.value = next.name;
// NOTE: Do NOT update suggestions here.
// This would cause the tabbing to break.
}
tabToNextSuggestion() {
let current = this.suggestions.querySelector(".tabbed");
let next = current?.nextSibling ?? this.suggestions.childNodes[0];
this.tab(current, next);
}
tabToPreviousSuggestion() {
let current = this.suggestions.querySelector(".tabbed");
let previous =
current?.previousSibling ??
this.suggestions.childNodes[this.suggestions.childNodes.length - 1];
this.tab(current, previous);
}
updateSuggestions() {
let search = parseCommand(this.input.value)?.command ?? "";
let suggestions = Array.from(CommandLine.commands.entries()).filter(
([name, def]) => !def.isAlias && fuzzyMatch(search, name),
);
suggestions.sort();
this.suggestions.replaceChildren();
for (let [name, def] of suggestions) {
let suggestion = this.suggestions.appendChild(document.createElement("li"));
let commandName = suggestion.appendChild(document.createElement("dfn"));
commandName.textContent = name;
let commandDescription = suggestion.appendChild(document.createElement("span"));
commandDescription.classList.add("description");
commandDescription.textContent = def.description;
suggestion.name = name;
suggestion.def = def;
suggestion.addEventListener("click", () => {
this.input.value = name;
this.updateSuggestions();
this.input.focus();
});
}
}
runCommand(commandLine) {
let { command, args } = parseCommand(commandLine);
let commandDef = CommandLine.commands.get(command);
if (CommandLine.commands.has(command)) {
commandDef.run(args);
} else {
console.log(`unknown command`);
}
}
static registerCommand({ aliases, description, run }) {
for (let i = 0; i < aliases.length; ++i) {
CommandLine.commands.set(aliases[i], {
isAlias: i != 0,
description,
run,
});
}
}
}
customElements.define("th-command-line", CommandLine);
function parseCommand(commandLine) {
let result = /^([^ ]+) *(.*)$/.exec(commandLine);
if (result == null) return null;
let [_, command, args] = result;
return { command, args };
}
// https://www.forrestthewoods.com/blog/reverse_engineering_sublime_texts_fuzzy_match/
function fuzzyMatch(pattern, string) {
let iPattern = 0;
let iString = 0;
while (iPattern < pattern.length && iString < string.length) {
if (pattern.charAt(iPattern).toLowerCase() == string.charAt(iString).toLowerCase()) {
iPattern += 1;
}
iString += 1;
}
return iPattern == pattern.length;
}
CommandLine.registerCommand({
aliases: ["help", "h"],
description: '"OwO, what is this?"',
run() {
window.location = `${TREEHOUSE_SITE}/treehouse/cmd`;
},
});