completions: WIP

This commit is contained in:
Colin Caine 2017-11-16 18:14:34 +00:00
parent e55d174747
commit 1045eadc0e
3 changed files with 145 additions and 64 deletions

View file

@ -1,63 +1,166 @@
/*
Have an array of all completion sources. Completion sources display nothing if the filter doesn't match for them.
On each input event, call updateCompletions on the array. That will mutate the array and update the display as required.
How to handle cached e.g. buffer information going out of date?
*/
import * as Fuse from 'fuse.js'
import {enumerate} from './itertools' import {enumerate} from './itertools'
const DEFAULT_FAVICON = browser.extension.getURL("static/defaultFavicon.svg") const DEFAULT_FAVICON = browser.extension.getURL("static/defaultFavicon.svg")
// HIGH LEVEL OBJECTS // {{{ INTERFACES
interface CompletionOption { interface CompletionOption {
// Highlight this option // Highlight this option
focus: () => void focus: () => void
blur: () => void
// What to fill into cmdline // What to fill into cmdline
value: string value: string
} }
export abstract class CompletionSource { export abstract class CompletionSource {
private obsolete = false private obsolete = false
readonly options = new Array<CompletionOption>() readonly options = new Array<CompletionOption>()
public node: HTMLElement public node: HTMLElement
// Called by updateCompletions on the child that succeeds its parent // Called by updateCompletions on the child that succeeds its parent
abstract activate() abstract activate(): void
// this.node now belongs to you, update it or something :) // this.node now belongs to you, update it or something :)
// Example: Mutate node or call replaceChild on its parent // Example: Mutate node or call replaceChild on its parent
abstract async filter(exstr): Promise<CompletionSource> abstract async filter(exstr): Promise<CompletionSource>
// <Do some async work that doesn't mutate any non-local vars> // <Do some async work that doesn't mutate any non-local vars>
// Make a new CompletionOptions and return it // Make a new CompletionOptions and return it
} }
// CONTEXT SPECIFIC // }}}
// {{{ IMPLEMENTATIONS
class BufferCompletionOption implements CompletionOption { class BufferCompletionOption implements CompletionOption {
constructor(public favIconUrl: string, public index: number, public title: string, public pre: string, public url: string, public value: string) {} // keep a reference to our markup so we can highlight it on focus().
html: HTMLElement // keep a reference to our markup so we can change it html: HTMLElement
render(highlight: boolean = false): void {
const html = document.createElement("div") // For fuzzy matching
if (highlight) html.setAttribute("class", "highlighted") matchStrings: string[]
html.innerHTML = `<span></span><span></span><span></span><span></span><a href="${this.url}" class="url" target="_blank"><span></span></a>`
html.childNodes[0].textContent = ` ${this.pre.padEnd(2)} ` constructor(public value: string, tab: browser.tabs.Tab, isAlternative = false) {
html.childNodes[1].nodeValue = `<img src="${this.favIconUrl}"> ` // Two character buffer properties prefix
if (this.index) html.childNodes[2].textContent = `${this.index}: ` let pre = ""
html.childNodes[3].textContent = `${this.title} ` if (tab.active) pre += "%"
html.childNodes[4].textContent = `${this.url} ` else if (isAlternative) pre += "#"
this.html = html
} if (tab.pinned) {
focus(): void { this.render(true) } pre += "@"
} this.matchStrings.push('@')
class BufferCompletionSource extends CompletionSource {
constructor(readonly options: BufferCompletionOption[], public node: HTMLElement) { super() }
activate(): HTMLElement { return this.node }
async filter(exstr: string): Promise<BufferCompletionSource> {
const match = function(queries: string[], option): boolean {
return queries.every((query) => {
return (/[A-Z]/.test(query)
? option.title.includes(query) || option.url.includes(query)
: option.title.toLowerCase().includes(query) || option.url.toLowerCase().includes(query)
)})}.bind(null, exstr.split(/\s+/))
const filtered: HTMLElement = window.document.createElement("div")
filtered.setAttribute("id", "completions")
for (const option of this.options) {
if (match(option)) { option.render(); filtered.appendChild(option.html) }
} }
return new BufferCompletionSource(this.options, filtered)
pre = pre.padEnd(2)
this.matchStrings = [tab.title, tab.url]
const favIconUrl = tab.favIconUrl ? tab.favIconUrl : DEFAULT_FAVICON
this.html = html`
<div>
<span>${pre}</span>
<img src=${favIconUrl}></img>
<span>${tab.index + 1}: ${tab.title}</span>
<a class="url" href=${tab.url}>${tab.url}</a>
</div>`
}
focus() {
this.html.classList.add("focused")
}
blur() {
this.html.classList.remove("focused")
} }
} }
// MANAGING ASYNC CHANGES export class BufferCompletionSource extends CompletionSource {
private fuse: Fuse
public prefixes = [ "buffer " ]
constructor(
readonly options: BufferCompletionOption[],
public node: HTMLElement,
private selection?: BufferCompletionOption
) {
super()
const fuseOptions = {
shouldSort: true,
includeScore: true,
keys: ["matchStrings"],
}
this.fuse = new Fuse(options, fuseOptions)
}
static fromTabs(tabs: browser.tabs.Tab[]) {
const node = html`<div class="BufferCompletionSource">`
// 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(),
tab,
tab === alt)
)
}
for (const option of options) {
node.appendChild(option.html)
}
return new BufferCompletionSource(options, node)
}
activate() {
// TODO... this bit of the interface isn't super clear to me yet.
}
async filter(exstr: string) {
if (! exstr.startsWith('buffer ')) {
// Disable
}
// Remove the 'buffer ' bit.
const query = exstr.slice(exstr.indexOf(' '))
/** If query is a number, focus corresponding index.
Else fuzzy search
*/
let match: BufferCompletionOption = undefined
if (! isNaN(Number(query)) && Number(query) <= this.options.length) {
match = this.options[Number(query)]
} else {
// Fuzzy search!
match = this.fuse.search('query')[0] as BufferCompletionOption
}
return new BufferCompletionSource(this.options, this.node, match)
}
}
// }}}
// {{{ MANAGING ASYNC CHANGES
/* If first to modify completions, update it. */ /* If first to modify completions, update it. */
async function commitIfCurrent(epochref: any, asyncFunc: Function, commitFunc: Function, ...args: any[]): Promise<any> { async function commitIfCurrent(epochref: any, asyncFunc: Function, commitFunc: Function, ...args: any[]): Promise<any> {
// I *think* sync stuff in here is guaranteed to happen immediately after // I *think* sync stuff in here is guaranteed to happen immediately after
@ -66,8 +169,9 @@ async function commitIfCurrent(epochref: any, asyncFunc: Function, commitFunc: F
const epoch = epochref const epoch = epochref
const res = await asyncFunc(...args) const res = await asyncFunc(...args)
if (epoch === epochref) return commitFunc(res) if (epoch === epochref) return commitFunc(res)
else throw "Update failed: epoch out of date!" 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. */
function updateCompletions(filter: string, sources: CompletionSource[]) { function updateCompletions(filter: string, sources: CompletionSource[]) {
for (let [index, source] of enumerate(sources)) { for (let [index, source] of enumerate(sources)) {
@ -88,29 +192,4 @@ function updateCompletions(filter: string, sources: CompletionSource[]) {
} }
} }
// BUFFER SPECIFIC HELPERS // }}}
/** Create completion item from given tab. */
function optionFromTab(tab: browser.tabs.Tab, prev: boolean = false): BufferCompletionOption {
let pre: string = ""
if (tab.active) pre += "%"
else if (prev) pre += "#"
if (tab.pinned) pre += "@"
return new BufferCompletionOption(
tab.favIconUrl ? tab.favIconUrl : DEFAULT_FAVICON,
tab.index + 1,
tab.title,
pre,
tab.url,
`${tab.index + 1}: ${tab.title}`
)
}
export function getBuffersFromTabs(tabs: browser.tabs.Tab[]): BufferCompletionSource {
const buffers: BufferCompletionOption[] = []
const markup: HTMLElement = window.document.createElement("div")
const prev = tabs.sort((a, b) => { return a.lastAccessed < b.lastAccessed ? 1 : -1 })[1]
tabs.sort((a, b) => { return a.index < b.index ? -1 : 1 })
for (const tab of tabs) { buffers.push(optionFromTab(tab, tab === prev)) }
markup.setAttribute("id", "completions")
for (const buffer of buffers) { buffer.render(); markup.appendChild(buffer.html) }
return new BufferCompletionSource(buffers, markup)
}

5
src/tridactyl.d.ts vendored
View file

@ -14,8 +14,6 @@ interface Number {
interface Window { interface Window {
scrollByLines(n: number): void scrollByLines(n: number): void
scrollByPages(n: number): void scrollByPages(n: number): void
html(strings: TemplateStringsArray, ...values: any[]): HTMLElement
} }
// Fix typescript bugs // Fix typescript bugs
@ -32,3 +30,6 @@ declare namespace browser.find {
declare namespace browser.tabs { declare namespace browser.tabs {
function setZoom(number): any function setZoom(number): any
} }
// html-tagged-template.js
declare function html(strings: TemplateStringsArray, ...values: any[]): HTMLElement

View file

@ -7,7 +7,8 @@
"sourceMap": true, "sourceMap": true,
"target": "ES2017", "target": "ES2017",
"lib": ["ES2017","dom"], "lib": ["ES2017","dom"],
"typeRoots": ["node_modules/@types", "node_modules/web-ext-types/"] "typeRoots": ["node_modules/@types", "node_modules/web-ext-types/"],
"alwaysStrict": true
}, },
"include": [ "include": [
"./src/**/*" "./src/**/*"