diff --git a/src/commandline_background.ts b/src/commandline_background.ts index 2c88dd91..de3500b5 100644 --- a/src/commandline_background.ts +++ b/src/commandline_background.ts @@ -1,39 +1,55 @@ import * as Messaging from './messaging' +export type onLineCallback = (exStr: string) => void + /** CommandLine API for inclusion in background script Receives messages from commandline_frame */ -export namespace onLine { - - export type onLineCallback = (exStr: string) => void - - const listeners = new Set() - export function addListener(cb: onLineCallback) { +export const onLine = { + addListener: function (cb: onLineCallback) { listeners.add(cb) return () => { listeners.delete(cb) } - } - - /** Receive events from commandline_frame and pass to listeners */ - function recvExStr(exstr: string) { - for (let listener of listeners) { - listener(exstr) - } - } - - /** Helpers for completions */ - async function currentWindowTabs(): Promise { - return await browser.tabs.query({currentWindow:true}) - } - - async function allWindowTabs(): Promise { - let allTabs: browser.tabs.Tab[] = [] - for (const window of await browser.windows.getAll()) { - const tabs = await browser.tabs.query({windowId:window.id}) - allTabs = allTabs.concat(tabs) - } - return allTabs - } - - Messaging.addListener("commandline_background", Messaging.attributeCaller({currentWindowTabs, recvExStr})) + }, } + +const listeners = new Set() + +/** Receive events from commandline_frame and pass to listeners */ +function recvExStr(exstr: string) { + for (let listener of listeners) { + listener(exstr) + } +} + +/** Helpers for completions */ +async function currentWindowTabs(): Promise { + return await browser.tabs.query({currentWindow:true}) +} + +async function allWindowTabs(): Promise { + let allTabs: browser.tabs.Tab[] = [] + for (const window of await browser.windows.getAll()) { + const tabs = await browser.tabs.query({windowId:window.id}) + allTabs = allTabs.concat(tabs) + } + return allTabs +} + +export async function show() { + Messaging.messageActiveTab('commandline_content', 'show') + Messaging.messageActiveTab('commandline_content', 'focus') + Messaging.messageActiveTab('commandline_frame', 'focus') +} + +export async function hide() { + Messaging.messageActiveTab('commandline_content', 'hide') + Messaging.messageActiveTab('commandline_content', 'blur') +} + +Messaging.addListener("commandline_background", Messaging.attributeCaller({ + currentWindowTabs, + recvExStr, + show, + hide, +})) diff --git a/src/commandline_frame.ts b/src/commandline_frame.ts index 904bf646..ae6351c6 100644 --- a/src/commandline_frame.ts +++ b/src/commandline_frame.ts @@ -8,8 +8,8 @@ import * as SELF from './commandline_frame' import './number.clamp' import state from './state' -let completionsrc: Completions.CompletionSource = undefined -let completions = window.document.getElementById("completions") as HTMLElement +let activeCompletions: Completions.CompletionSource[] = undefined +let completionsDiv = window.document.getElementById("completions") as HTMLElement let clInput = window.document.getElementById("tridactyl-input") as HTMLInputElement /* This is to handle Escape key which, while the cmdline is focused, @@ -19,9 +19,23 @@ let clInput = window.document.getElementById("tridactyl-input") as HTMLInputElem * tl;dr TODO: delete this and better resolve race condition */ let isVisible = false -function resizeArea() { if (isVisible) sendExstr("showcmdline") } +function resizeArea() { + if (isVisible) { + Messaging.message("commandline_background", "show") + } +} -export let focus = () => clInput.focus() +export function focus() { + clInput.focus() + console.log(activeCompletions) + if (! activeCompletions) { + activeCompletions = [ + new Completions.BufferCompletionSource(completionsDiv), + ] + + activeCompletions.forEach(comp => completionsDiv.appendChild(comp.node)) + } +} async function sendExstr(exstr) { Messaging.message("commandline_background", "recvExStr", [exstr]) @@ -74,39 +88,28 @@ clInput.addEventListener("keydown", function (keyevent) { } }) -clInput.addEventListener("input", async () => { - // TODO: Handle this in parser - if (clInput.value.startsWith("buffer ") || clInput.value.startsWith("tabclose ") || - clInput.value.startsWith("tabmove ")) { - const tabs: browser.tabs.Tab[] = await Messaging.message("commandline_background", "currentWindowTabs") - completionsrc = Completions.BufferCompletionSource.fromTabs(tabs) - completionsrc = await completionsrc.filter(clInput.value) - completions.innerHTML = "" - completions.appendChild(completionsrc.node) - resizeArea() - } - else if (clInput.value.startsWith("bufferall ")) { - // TODO - } - else if (completionsrc) { - completionsrc = undefined - completions.innerHTML = "" - resizeArea() - } +clInput.addEventListener("input", () => { + // Fire each completion and add a callback to resize area + console.log(activeCompletions) + activeCompletions.forEach(comp => + comp.filter(clInput.value).then(() => resizeArea()) + ) }) let cmdline_history_position = 0 let cmdline_history_current = "" -function hide_and_clear(){ +async function hide_and_clear(){ /** Bug workaround: clInput cannot be cleared during an "Escape" * keydown event, presumably due to Firefox's internal handler for * Escape. So clear clInput just after :) */ - completionsrc = undefined - completions.innerHTML = "" setTimeout(()=>{clInput.value = ""}, 0) - sendExstr("hidecmdline") + await Messaging.message('commandline_background', 'hide') + // Delete all completion sources - I don't think this is required, but this + // way if there is a transient bug in completions it shouldn't persist. + activeCompletions.forEach(comp => completionsDiv.removeChild(comp.node)) + activeCompletions = undefined isVisible = false } @@ -118,7 +121,6 @@ function tabcomplete(){ } function history(n){ - completions.innerHTML = "" if (cmdline_history_position == 0){ cmdline_history_current = clInput.value } @@ -133,7 +135,6 @@ function history(n){ /* Send the commandline to the background script and await response. */ function process() { console.log(clInput.value) - sendExstr("hidecmdline") sendExstr(clInput.value) // Save non-secret commandlines to the history. @@ -144,10 +145,9 @@ function process() { state.cmdHistory = state.cmdHistory.concat([clInput.value]) } console.log(state.cmdHistory) - completionsrc = undefined - completions.innerHTML = "" - clInput.value = "" cmdline_history_position = 0 + + hide_and_clear() } export function fillcmdline(newcommand?: string, trailspace = true){ diff --git a/src/completions.ts b/src/completions.ts index ab0e4f03..141d1cc7 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -12,102 +12,237 @@ How to handle cached e.g. buffer information going out of date? import * as Fuse from 'fuse.js' import {enumerate} from './itertools' import {toNumber} from './convert' +import * as Messaging from './messaging' const DEFAULT_FAVICON = browser.extension.getURL("static/defaultFavicon.svg") // {{{ INTERFACES -interface CompletionOption { - // What to fill into cmdline - value: string +type OptionState = 'focused' | 'hidden' | 'normal' - // Highlight and blur element, - blur(): void - focus(): void +abstract class CompletionOption { + /** What to fill into cmdline */ + value: string + /** Control presentation of the option */ + state: OptionState } export abstract class CompletionSource { - private obsolete = false + readonly options: CompletionOption[] + node: HTMLElement + public completion: string - readonly options = new Array() + /** Update [[node]] to display completions relevant to exstr */ + public abstract filter(exstr: string): Promise - public node: HTMLElement + private _state: OptionState - // Called by updateCompletions on the child that succeeds its parent - abstract activate(): void - // this.node now belongs to you, update it or something :) - // Example: Mutate node or call replaceChild on its parent + /** Control presentation of Source */ + set state(newstate: OptionState) { + switch (newstate) { + case 'normal': + this.node.classList.remove('hidden') + this.completion = undefined + break + case 'hidden': + this.node.classList.add('hidden') + break; + } + this._state = newstate + } - abstract async filter(exstr): Promise - // - // Make a new CompletionOptions and return it + get state() { + return this._state + } +} + +// Default classes + +abstract class CompletionOptionHTML extends CompletionOption { + public html: HTMLElement + public value + + private _state: OptionState = 'hidden' + + /** Control presentation of element */ + set state(newstate: OptionState) { + switch (newstate) { + case 'focused': + this.html.classList.add('focused') + this.html.classList.remove('hidden') + break + case 'normal': + this.html.classList.remove('focused') + this.html.classList.remove('hidden') + break + case 'hidden': + this.html.classList.remove('focused') + this.html.classList.add('hidden') + break; + } + } +} + +interface CompletionOptionFuse extends CompletionOption { + // For fuzzy matching + fuseKeys: any[] +} + +type ScoredOption = { + index: number, + option: CompletionOptionFuse, + score: number +} + +abstract class CompletionSourceFuse extends CompletionSource { + public node + public options: CompletionOptionFuse[] + + protected optionContainer = html`
` + + constructor(private prefixes, className: string, title?: string) { + super() + this.node = html + `` + this.node.appendChild(this.optionContainer) + } + + abstract onFilter(query: string, exstr?: string) + + async filter(exstr: string) { + let prefix, query + for (const pre of this.prefixes) { + if (exstr.startsWith(pre)) { + prefix = pre + query = exstr.replace(pre, '') + } + } + + // Hide self if prefixes don't match + if (prefix) { + this.state = 'normal' + } else { + this.state = 'hidden' + return + } + + // Call concrete class + this.onFilter(query, prefix) + } + + /** Rtn sorted array of {option, score} */ + protected scoredOptions(query: string, options = this.options): ScoredOption[] { + const fuseOptions = { + keys: ["fuseKeys"], + shouldSort: true, + id: "index", + includeScore: true, + } + + // Can't sort the real options array because Fuse loses class information. + const searchThis = this.options.map( + (elem, index) => { + return {index, fuseKeys: elem.fuseKeys} + }) + + // PERF: Could be expensive not to cache Fuse() + const fuse = new Fuse(searchThis, fuseOptions) + return fuse.search(query).map( + res => { + let result = res as any + console.log(result, result.item, query) + let index = toNumber(result.item) + return { + index, + option: this.options[index], + score: result.score as number + } + }) + } + + /** Set option state by score + + For now just displays all scored elements (see threshold in fuse) and + focus the best match. + */ + protected setStateFromScore(scoredOpts: ScoredOption[]) { + let matches = scoredOpts.map(res => res.index) + + for (const [index, option] of enumerate(this.options)) { + if (matches.includes(index)) option.state = 'normal' + else option.state = 'hidden' + } + + if (matches.length) { + // TODO: use prefix of last exstr. + this.completion = "buffer " + this.options[matches[0]].value + this.options[matches[0]].state = 'focused' + } + } } // }}} // {{{ IMPLEMENTATIONS -class BufferCompletionOption implements CompletionOption { - // keep a reference to our markup so we can highlight it on focus(). - html: HTMLElement - - // For fuzzy matching - matchStrings: string[] = [] +class BufferCompletionOption extends CompletionOptionHTML implements CompletionOptionFuse { + public fuseKeys = [] constructor(public value: string, tab: browser.tabs.Tab, isAlternative = false) { + super() // Two character buffer properties prefix let pre = "" if (tab.active) pre += "%" else if (isAlternative) pre += "#" - if (tab.pinned) { pre += "@" } - this.matchStrings.push(pre) // before pad so we don't match whitespace - pre = pre.padEnd(2) - this.matchStrings.push(String(tab.index + 1), tab.title, tab.url) + if (tab.pinned) pre += "@" + + // Push prefix before padding so we don't match on whitespace + this.fuseKeys.push(pre) + + // Push properties we want to fuzmatch on + this.fuseKeys.push(String(tab.index + 1), tab.title, tab.url) + + // Create HTMLElement const favIconUrl = tab.favIconUrl ? tab.favIconUrl : DEFAULT_FAVICON this.html = html`
- ${pre} + ${pre.padEnd(2)} ${tab.index + 1}: ${tab.title} ${tab.url}
` } - - blur() { this.html.classList.remove("focused") } - focus() { this.html.classList.add("focused"); this.show() } - hide() { this.html.classList.add("hidden"); this.blur() } - show() { this.html.classList.remove("hidden") } } -export class BufferCompletionSource extends CompletionSource { - private fuse: Fuse - public prefixes = [ "buffer " ] +export class BufferCompletionSource extends CompletionSourceFuse { + public options: BufferCompletionOption[] - constructor( - readonly options: BufferCompletionOption[], - public node: HTMLElement, - ) { - super() - const fuseOptions = { - keys: ["matchStrings"], - shouldSort: true, - id: "index", - } + // TODO: + // - store the exstr and trigger redraws on user or data input without + // callback faffery + // - sort out the element redrawing. - // Can't sort the real options array because Fuse loses class information. - const searchThis = options.map((elem, index) => {return {index, matchStrings: elem.matchStrings}}) - this.fuse = new Fuse(searchThis, fuseOptions) + // Callback + private waiting + + constructor(private _parent) { + super(["buffer ", "tabmove "], "BufferCompletionOption", "Buffers") + this.updateOptions() + this._parent.appendChild(this.node) } - static fromTabs(tabs: browser.tabs.Tab[]) { - const node = html`
-
Buffers
` + private async updateOptions(exstr?: string) { + /* console.log('updateOptions', this.optionContainer) */ + const tabs: browser.tabs.Tab[] = + await Messaging.message("commandline_background", "currentWindowTabs") + + const options = [] // Get alternative tab, defined as last accessed tab. const alt = tabs.sort((a, b) => { return a.lastAccessed < b.lastAccessed ? 1 : -1 })[1] tabs.sort((a, b) => { return a.index < b.index ? -1 : 1 }) - const options: BufferCompletionOption[] = [] - for (const tab of tabs) { options.push(new BufferCompletionOption( (tab.index + 1).toString(), @@ -116,45 +251,64 @@ export class BufferCompletionSource extends CompletionSource { ) } - for (const option of options) { - node.appendChild(option.html) + /* console.log('updateOptions end', this.waiting, this.optionContainer) */ + this.options = options + if (this.waiting) this.waiting() + } + + /** Call to replace the current display */ + // TODO: optionContainer.replaceWith and optionContainer.remove don't work. + // I don't know why, but it means we can't replace the div in one go. Maybe + // an iframe thing. + private updateDisplay() { + /* const newContainer = html`
` */ + + while (this.optionContainer.hasChildNodes()) { + this.optionContainer.removeChild(this.optionContainer.lastChild) } - return new BufferCompletionSource(options, node) + for (const option of this.options) { + /* newContainer.appendChild(option.html) */ + this.optionContainer.appendChild(option.html) + } + + /* console.log('updateDisplay', this.optionContainer, newContainer) */ + + /* let result1 = this.optionContainer.remove() */ + /* let res2 = this.node.appendChild(newContainer) */ + /* console.log('results', result1, res2) */ } - activate() { - // TODO... this bit of the interface isn't super clear to me yet. - } - - async filter(exstr: string) { - // Remove the `${prefix} ` bit. - const query = exstr.slice(exstr.indexOf(' ') + 1) + async onFilter(query, exstr) { + // Wait if options is not populated yet. It's possible that it will + // never resolve if this.waiting is overridden with a different + // callback. That's intended behaviour. + let needsCommit + if (! this.options) { + await new Promise(resolve => this.waiting = () => resolve()) + needsCommit = true + } + // Else filter by query if query is not empty if (query) { - let matches = this.fuse.search(query).map(toNumber) as number[] - - for (const [index, option] of enumerate(this.options)) { - if (! matches.includes(index)) option.hide() - else option.show() - } - - if (matches.length) this.options[matches[0]].focus() + this.setStateFromScore(this.scoredOptions(query)) + // Else show all options } else { - for (const option of this.options) { - option.show() - } + this.options.forEach(option => option.state = 'normal') } - return this + // + this.updateDisplay() + + // Schedule an update, if you like. Not very useful for buffers, but + // will be for other things. + setTimeout(() => this.updateOptions(), 0) } } -// }}} - // {{{ UNUSED: MANAGING ASYNC CHANGES -/* If first to modify completions, update it. */ +/** If first to modify epoch, commit change. May want to change epoch after commiting. */ async function commitIfCurrent(epochref: any, asyncFunc: Function, commitFunc: Function, ...args: any[]): Promise { // I *think* sync stuff in here is guaranteed to happen immediately after // being called, up to the first await, despite this being an async @@ -165,7 +319,10 @@ async function commitIfCurrent(epochref: any, asyncFunc: Function, commitFunc: F else console.error(new Error("Update failed: epoch out of date!")) } -/* Indicate changes to completions we would like. */ +/** Indicate changes to completions we would like. + + This will probably never be used for original designed purpose. +*/ function updateCompletions(filter: string, sources: CompletionSource[]) { for (let [index, source] of enumerate(sources)) { // Tell each compOpt to filter, and if they finish fast enough they: diff --git a/src/excmds.ts b/src/excmds.ts index 878c51ba..2a7ca3f2 100644 --- a/src/excmds.ts +++ b/src/excmds.ts @@ -41,8 +41,6 @@ import * as Messaging from "./messaging" import {l} from './lib/webext' -//#content_omit_line -import * as CommandLineContent from "./commandline_content" //#content_omit_line import "./number.clamp" //#content_helper @@ -65,6 +63,8 @@ import * as keydown from "./keydown_background" import {activeTab, activeTabId, firefoxVersionAtLeast} from './lib/webext' //#content_helper import {incrementUrl, getUrlRoot, getUrlParent} from "./url_util" +//#background_helper +import * as CommandLineBackground from './commandline_background' /** @hidden */ //#background_helper @@ -146,6 +146,7 @@ function tabSetActive(id: number) { // }}} + // {{{ PAGE CONTEXT /** Blur (unfocus) the active element */ @@ -545,19 +546,10 @@ export function composite(...cmds: string[]) { cmds.forEach(controller.acceptExCmd) } -/** Don't use this */ -// TODO: These two don't really make sense as excmds, they're internal things. -//#content +/** Please use fillcmdline instead */ +//#background export function showcmdline() { - CommandLineContent.show() - CommandLineContent.focus() -} - -/** Don't use this */ -//#content -export function hidecmdline() { - CommandLineContent.hide() - CommandLineContent.blur() + CommandLineBackground.show() } /** Set the current value of the commandline to string *with* a trailing space */ @@ -619,7 +611,7 @@ export async function clipboard(excmd: "open"|"yank"|"tabopen" = "open", ...toYa // todo: maybe we should have some common error and error handler throw new Error(`[clipboard] unknown excmd: ${excmd}`) } - hidecmdline() + CommandLineBackground.hide() } // {{{ Buffer/completion stuff diff --git a/src/static/commandline.css b/src/static/commandline.css index 084d4146..b4286c41 100644 --- a/src/static/commandline.css +++ b/src/static/commandline.css @@ -33,14 +33,17 @@ input { color: black; display: inline-block; font-size: 10pt; - max-height: calc(20 * var(--option-height)); - min-height: calc(10 * var(--option-height)); font-family: "monospace"; overflow: hidden; width: 100%; border-top: 0.5px solid grey; } +#completions .BufferCompletionSource { + max-height: calc(20 * var(--option-height)); + min-height: calc(10 * var(--option-height)); +} + #completions img { display: inline; vertical-align: middle;