completions 1

This commit is contained in:
Colin Caine 2017-11-22 18:05:54 +00:00
parent f982233a49
commit 8d771e578c
5 changed files with 326 additions and 158 deletions

View file

@ -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<onLineCallback>()
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<browser.tabs.Tab[]> {
return await browser.tabs.query({currentWindow:true})
}
async function allWindowTabs(): Promise<browser.tabs.Tab[]> {
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<onLineCallback>()
/** 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<browser.tabs.Tab[]> {
return await browser.tabs.query({currentWindow:true})
}
async function allWindowTabs(): Promise<browser.tabs.Tab[]> {
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,
}))

View file

@ -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){

View file

@ -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<CompletionOption>()
/** Update [[node]] to display completions relevant to exstr */
public abstract filter(exstr: string): Promise<void>
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<CompletionSource>
// <Do some async work that doesn't mutate any non-local vars>
// 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`<div class="optionContainer">`
constructor(private prefixes, className: string, title?: string) {
super()
this.node = html
`<div class="${className} hidden">
<div class="sectionHeader">${title || className}</div>
</div>`
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`<div class="BufferCompletionOption option">
<span>${pre}</span>
<span>${pre.padEnd(2)}</span>
<img src=${favIconUrl} />
<span>${tab.index + 1}: ${tab.title}</span>
<a class="url" href=${tab.url}>${tab.url}</a>
</div>`
}
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`<div class="BufferCompletionSource">
<div class="sectionHeader">Buffers</div>`
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`<div>` */
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<any> {
// 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:

View file

@ -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

View file

@ -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;