import { html , htext } from './html' ; export function translateKey ( ev ) { switch ( ev . key ) { case 'Alt' : case 'Control' : case 'Shift' : case 'Unidentified' : return '' ; } if ( ev . key >= '0' && ev . key <= '9' ) return '' ; let name = '' ; if ( ev . altKey ) name += 'M-' ; if ( ev . ctrlKey ) name += 'C-' ; if ( ev . shiftKey && ev . key . length > 1 ) name += 'S-' ; name += ev . key ; return name ; } export interface CompleteRequest { input : string ; pos : number ; } export interface CompleteResponse { completions : string [ ] ; pos : number ; } class CompletePopup { dom = html ( 'div' , { className : 'popup' , style : { overflow : 'hidden' } } ) ; textSize ! : { width : number ; height : number ; } ; selection = - 1 ; delegates = { oncommit : ( text , pos ) : void => { } , } ; constructor ( readonly req , readonly resp ) { } show ( parent ) { this . textSize = this . measure ( parent , this . req . input . substring ( 0 , this . resp . pos ) + '\u200b' ) ; for ( const comp of this . resp . completions ) { const dom = html ( 'div' , { className : 'completion' } , htext ( comp ) ) ; dom . addEventListener ( 'mousedown' , ( event ) => { this . delegates . oncommit ( comp , this . resp . pos ) ; event . preventDefault ( ) ; } ) ; this . dom . appendChild ( dom ) ; } parent . appendChild ( this . dom ) ; this . position ( ) ; this . selectCompletion ( 0 ) ; this . dom . focus ( ) ; } /** Measures the size of the given text as if it were contained in the parent. */ private measure ( parent , text ) { const measure = html ( 'div' , { style : { position : 'absolute' , visibility : 'hidden' , whiteSpace : 'pre' , } , } , htext ( text ) ) ; parent . appendChild ( measure ) ; const { width , height } = getComputedStyle ( measure ) ; parent . removeChild ( measure ) ; return { width : parseFloat ( width ) , height : parseFloat ( height ) } ; } /** Positions this.dom. */ private position ( ) { const promptY = ( this . dom . parentNode as HTMLElement ) . getClientRects ( ) [ 0 ] . y ; const popupHeight = this . dom . offsetHeight ; const spaceAbove = promptY ; const spaceBelow = window . innerHeight - ( promptY + this . textSize . height ) ; let placeBelow ; if ( spaceBelow >= popupHeight ) { placeBelow = true ; } else if ( spaceAbove >= popupHeight ) { placeBelow = false ; } else { placeBelow = spaceBelow >= spaceAbove ; } const popupPaddingY = 2 + 2 ; const popupShadowY = 4 ; const popupSizeMargin = popupPaddingY + popupShadowY ; if ( placeBelow ) { this . dom . style . top = `${ this . textSize . height }px` ; this . dom . style . bottom = '' ; this . dom . style . height = spaceBelow >= popupHeight ? '' : `${ spaceBelow - popupSizeMargin }px` ; } else { this . dom . style . top = '' ; this . dom . style . bottom = `${ this . textSize . height }px` ; this . dom . style . height = spaceAbove >= popupHeight ? '' : `${ spaceAbove - popupSizeMargin }px` ; } const popupPaddingLeft = 4 ; this . dom . style . left = `${ this . textSize . width - popupPaddingLeft }px` ; } hide ( ) { this . dom . parentNode ! . removeChild ( this . dom ) ; } private selectCompletion ( index ) { if ( this . selection !== - 1 ) { this . dom . children [ this . selection ] . classList . remove ( 'selected' ) ; } this . selection = ( index + this . resp . completions . length ) % this . resp . completions . length ; this . dom . children [ this . selection ] . classList . add ( 'selected' ) ; } param key handleKey ( key ) { switch ( key ) { case 'ArrowDown' : case 'Tab' : case 'C-n' : this . selectCompletion ( this . selection + 1 ) ; return true ; case 'ArrowUp' : case 'S-Tab' : case 'C-p' : this . selectCompletion ( this . selection - 1 ) ; return true ; case 'Enter' : this . delegates . oncommit ( this . resp . completions [ this . selection ] , this . resp . pos ) ; return true ; case 'Escape' : this . delegates . oncommit ( '' , this . resp . pos ) ; return true ; } return false ; } } /** Returns the length of the longest prefix shared by all input strings. */ function longestSharedPrefixLength ( strs ) { for ( let len = 0 ; ; len ++ ) { let c = - 1 ; for ( const str of strs ) { if ( len === str . length ) return len ; if ( c === - 1 ) c = str . charCodeAt ( len ) ; else if ( str . charCodeAt ( len ) !== c ) return len ; } } } export function backwardWordBoundary ( text , pos ) { for ( ; pos > 0 ; pos -- ) { if ( text . charAt ( pos - 1 ) !== ' ' ) break ; } for ( ; pos > 0 ; pos -- ) { if ( text . charAt ( pos - 1 ) === ' ' ) break ; } return pos ; } function forwardWordBoundary ( text , pos ) { for ( ; pos < text . length ; pos ++ ) { if ( text . charAt ( pos ) === ' ' ) break ; } for ( ; pos < text . length ; pos ++ ) { if ( text . charAt ( pos ) !== ' ' ) break ; } return pos ; } export interface InputState { text : string ; start : number ; end : number ; } export interface InputHandler { onEnter ( state ) : void ; tabComplete ( state ) : void ; setText ( text ) : void ; setPos ( pos ) : void ; showHistory ( delta ) : void ; } export function interpretKey ( state , key , handler ) { const { text , start } = state ; switch ( key ) { case 'Enter' : handler . onEnter ( state ) ; return true ; case 'Tab' : handler . tabComplete ( state ) ; return true ; case 'Delete' : case 'M-Backspace' : { const wordStart = backwardWordBoundary ( text , start ) ; handler . setPos ( wordStart ) ; handler . setText ( text . substring ( 0 , wordStart ) + text . substring ( start ) ) ; return true ; } case 'C-a' : case 'Home' : handler . setPos ( 0 ) ; return true ; case 'C-b' : handler . setPos ( start - 1 ) ; return true ; case 'M-b' : handler . setPos ( backwardWordBoundary ( text , start ) ) ; return true ; case 'M-d' : { const delEnd = forwardWordBoundary ( text , start ) ; handler . setText ( text . substring ( 0 , start ) + text . substring ( delEnd ) ) ; return true ; } case 'C-e' : case 'End' : handler . setPos ( text . length ) ; return true ; case 'C-f' : handler . setPos ( start + 1 ) ; return true ; case 'M-f' : handler . setPos ( forwardWordBoundary ( text , start ) ) ; return true ; case 'C-k' : handler . setText ( text . substr ( 0 , start ) ) ; return true ; case 'C-n' : case 'ArrowDown' : handler . showHistory ( - 1 ) ; return true ; case 'C-p' : case 'ArrowUp' : handler . showHistory ( 1 ) ; return true ; case 'C-u' : handler . setText ( text . substr ( start ) ) ; return true ; case 'C-x' : case 'C-c' : case 'C-v' : case 'C-J' : case 'C-l' : case 'C-R' : return false ; default : handler . showHistory ( 0 ) ; return false ; } } export interface History { add ( cmd ) : void ; get ( ofs ) : string | undefined ; } export class ReadLine { dom = html ( 'div' , { className : 'readline' } ) ; prompt = html ( 'div' , { className : 'prompt' } ) ; inputBox = html ( 'div' , { className : 'input-box' } ) ; input = html ( 'input' , { spellcheck : false , } ) as HTMLInputElement ; delegates = { oncommit : ( text ) : void => { } , oncomplete : async ( req ) : Promise < CompleteResponse > => { throw 'notimpl' ; } , } ; pendingComplete : Promise < CompleteResponse > | undefined ; popup : CompletePopup | undefined ; /** Offset into the history: "we have gone N commands back". */ historyPosition = 0 ; /**
     * The selection span at time of last blur.
     * This is restored on focus, to defeat the browser behavior of
     * select all on focus.
     */ selection : [ number , number ] = [ 0 , 0 ] ; constructor ( private history ) { this . dom . appendChild ( this . prompt ) ; this . inputBox . appendChild ( this . input ) ; this . dom . appendChild ( this . inputBox ) ; this . input . onkeydown = ( ev ) => { const key = translateKey ( ev ) ; if ( ! key ) return ; if ( this . handleKey ( key ) ) ev . preventDefault ( ) ; } ; this . input . onkeypress = ( ev ) => { const key = ev . key ; if ( ! key ) return ; if ( this . handleKey ( key ) ) ev . preventDefault ( ) ; } ; this . input . addEventListener ( 'blur' , ( ) => { this . selection = [ this . input . selectionStart ! , this . input . selectionEnd ! ] ; this . pendingComplete = undefined ; this . hidePopup ( ) ; } ) ; this . input . addEventListener ( 'focus' , ( ) => { [ this . input . selectionStart , this . input . selectionEnd ] = this . selection ; } ) ; } setPrompt ( text ) { this . prompt . innerText = `${ text }$ ` ; } setText ( text ) { this . input . value = text ; this . historyPosition = 0 ; } setPos ( pos ) { pos = Math . max ( 0 , Math . min ( this . input . value . length , pos ) ) ; this . input . selectionStart = this . input . selectionEnd = pos ; } showHistory ( delta ) { switch ( delta ) { case - 1 : { if ( this . historyPosition === 0 ) return ; this . historyPosition -- ; const cmd = this . history . get ( this . historyPosition ) || '' ; this . input . value = cmd ; return ; } case 1 : { const cmd = this . history . get ( this . historyPosition + 1 ) ; if ( ! cmd ) return ; this . historyPosition ++ ; this . input . value = cmd ; return ; } case 0 : this . historyPosition = 0 ; return ; } } focus ( ) { this . input . focus ( ) ; } hidePopup ( ) { if ( ! this . popup ) return ; this . popup . hide ( ) ; this . popup = undefined ; } param key handleKey ( key ) { if ( this . popup && this . popup . handleKey ( key ) ) return true ; if ( this . pendingComplete ) this . pendingComplete = undefined ; this . hidePopup ( ) ; const state = { text : this . input . value , start : this . input . selectionStart ?? 0 , end : this . input . selectionEnd ?? 0 , } ; return interpretKey ( state , key , this ) ; } tabComplete ( state ) { const pos = state . start ; const req = { input : state . text , pos } ; const pending = ( this . pendingComplete = this . delegates . oncomplete ( req ) ) ; pending . then ( ( resp ) => { if ( pending !== this . pendingComplete ) return ; this . pendingComplete = undefined ; if ( resp . completions . length === 0 ) return ; const len = longestSharedPrefixLength ( resp . completions ) ; if ( len > 0 ) { this . applyCompletion ( resp . completions [ 0 ] . substring ( 0 , len ) , resp . pos ) ; } if ( resp . completions . length > 1 ) { this . popup = new CompletePopup ( req , resp ) ; this . popup . show ( this . inputBox ) ; this . popup . delegates = { oncommit : ( text , pos ) => { this . applyCompletion ( text , pos ) ; this . hidePopup ( ) ; } , } ; } } ) ; } applyCompletion ( text , pos ) { let overlap = 0 ; while ( pos + overlap < this . input . value . length && this . input . value [ pos + overlap ] === text [ overlap ] ) { overlap ++ ; } this . setText ( this . input . value . substring ( 0 , pos ) + text + this . input . value . substring ( pos + overlap ) ) ; } onEnter ( ) { const text = this . input . value ; this . history . add ( text ) ; this . delegates . oncommit ( text ) ; } }