Sneak: Implement async provider API (#1661)

* Move sneak, split out activate method

* Split out SneakView

* Add sneak state store

* Split out functionality to sneak store

* Wire up store

* Add tests for store

* Use provider for sneaks

* Hook up async sneak provider completely

* Fix lint issues
This commit is contained in:
Bryan Phelps 2018-02-27 18:42:24 -08:00 committed by GitHub
parent 6a6c302e12
commit 71b5891d93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 380 additions and 237 deletions

View File

@ -1,236 +0,0 @@
/**
* Sneak.tsx
*
* Provides the 'sneak layer' UI
*/
import * as React from "react"
import { Shapes } from "oni-api"
import { IDisposable } from "oni-types"
import { CallbackCommand, CommandManager } from "./CommandManager"
import { Overlay, OverlayManager } from "./Overlay"
import { TextInputView } from "./../UI/components/LightweightText"
export interface ISneakInfo {
rectangle: Shapes.Rectangle
callback: () => void
}
export interface IAugmentedSneakInfo extends ISneakInfo {
triggerKeys: string
}
export type SneakProvider = () => ISneakInfo[]
export class Sneak {
private _activeOverlay: Overlay
private _providers: SneakProvider[] = []
constructor(private _overlayManager: OverlayManager) {}
public get isActive(): boolean {
return !!this._activeOverlay
}
public addSneakProvider(provider: SneakProvider): IDisposable {
this._providers.push(provider)
const dispose = () => (this._providers = this._providers.filter(prov => prov !== provider))
return { dispose }
}
public show(): void {
const rects = this._collectSneakRectangles()
const augmentedRects = this._augmentSneakRectangles(rects)
if (this._activeOverlay) {
this._activeOverlay.hide()
this._activeOverlay = null
}
this._activeOverlay = this._overlayManager.createItem()
this._activeOverlay.setContents(
<SneakView sneaks={augmentedRects} onComplete={info => this._onComplete(info)} />,
)
this._activeOverlay.show()
}
public close(): void {
if (this._activeOverlay) {
this._activeOverlay.hide()
this._activeOverlay = null
}
}
private _onComplete(sneakInfo: ISneakInfo): void {
this.close()
sneakInfo.callback()
}
private _augmentSneakRectangles(sneaks: ISneakInfo[]): IAugmentedSneakInfo[] {
return sneaks.map((sneak, idx) => {
return {
...sneak,
triggerKeys: this._getLabelFromIndex(idx, sneaks.length),
}
})
}
private _getLabelFromIndex(index: number, max: number): string {
const firstDigit = Math.floor(index / 26)
const secondDigit = index - firstDigit * 26
return String.fromCharCode(97 + firstDigit, 97 + secondDigit).toUpperCase()
}
private _collectSneakRectangles(): ISneakInfo[] {
const ret = this._providers.reduce((prev: ISneakInfo[], cur: SneakProvider) => {
const sneaks = cur().filter(s => !!s)
return [...prev, ...sneaks]
}, [])
return ret
}
}
export interface ISneakViewProps {
sneaks: IAugmentedSneakInfo[]
onComplete: (sneakInfo: ISneakInfo) => void
}
export const TestSneaks = [
{
triggerKeys: "AA",
rectangle: Shapes.Rectangle.create(10, 10, 100, 100),
callback: () => {
alert("testing")
},
},
{
triggerKeys: "AB",
rectangle: Shapes.Rectangle.create(50, 50, 50, 50),
callback: () => {
alert("testing2")
},
},
]
import { boxShadow, OverlayWrapper } from "./../UI/components/common"
export interface ISneakViewState {
filterText: string
}
// Render a keyboard input?
// Grab input while 'sneaking'?
export class SneakView extends React.PureComponent<ISneakViewProps, ISneakViewState> {
constructor(props: ISneakViewProps) {
super(props)
this.state = {
filterText: "",
}
}
public render(): JSX.Element {
const normalizedFilterText = this.state.filterText.toUpperCase()
const filteredSneaks = this.props.sneaks.filter(
sneak => sneak.triggerKeys.indexOf(normalizedFilterText) === 0,
)
const sneaks = filteredSneaks.map(si => (
<SneakItemView sneak={si} filterLength={normalizedFilterText.length} />
))
if (filteredSneaks.length === 1) {
this.props.onComplete(filteredSneaks[0])
}
return (
<OverlayWrapper style={{ backgroundColor: "rgba(0, 0, 0, 0.1)" }}>
<div style={{ opacity: 0.01 }}>
<TextInputView
onChange={evt => {
this.setState({ filterText: evt.currentTarget.value })
}}
/>
</div>
{sneaks}
</OverlayWrapper>
)
}
}
export interface ISneakItemViewProps {
sneak: IAugmentedSneakInfo
filterLength: number
}
import styled from "styled-components"
const SneakItemWrapper = styled.div`
${boxShadow} background-color: ${props => props.theme["highlight.mode.visual.background"]};
color: ${props => props.theme["highlight.mode.visual.foreground"]};
`
const SneakItemViewSize = 20
const px = (num: number): string => num.toString() + "px"
export class SneakItemView extends React.PureComponent<ISneakItemViewProps, {}> {
public render(): JSX.Element {
const style: React.CSSProperties = {
position: "absolute",
left: px(this.props.sneak.rectangle.x),
top: px(this.props.sneak.rectangle.y),
width: px(SneakItemViewSize),
height: px(SneakItemViewSize),
}
return (
<SneakItemWrapper style={style}>
<span style={{ fontWeight: "bold" }}>
{this.props.sneak.triggerKeys.substring(0, this.props.filterLength)}
</span>
<span>
{this.props.sneak.triggerKeys.substring(
this.props.filterLength,
this.props.sneak.triggerKeys.length,
)}
</span>
</SneakItemWrapper>
)
}
}
let _sneak: Sneak
export const activate = (commandManager: CommandManager, overlayManager: OverlayManager) => {
_sneak = new Sneak(overlayManager)
commandManager.registerCommand(
new CallbackCommand(
"sneak.show",
"Sneak: Current Window",
"Show commands for current window",
() => {
_sneak.show()
},
),
)
commandManager.registerCommand(
new CallbackCommand(
"sneak.hide",
"Sneak: Hide",
"Hide sneak view",
() => _sneak.close(),
() => _sneak.isActive,
),
)
}
export const getInstance = (): Sneak => {
return _sneak
}

View File

@ -0,0 +1,81 @@
/**
* Sneak.tsx
*
* Provides the 'sneak layer' UI
*/
import * as React from "react"
import { Provider } from "react-redux"
import { Store } from "redux"
import { IDisposable } from "oni-types"
import { Overlay, OverlayManager } from "./../Overlay"
import { createStore as createSneakStore, ISneakInfo, ISneakState } from "./SneakStore"
import { ConnectedSneakView } from "./SneakView"
export type SneakProvider = () => Promise<ISneakInfo[]>
export class Sneak {
private _activeOverlay: Overlay
private _providers: SneakProvider[] = []
private _store: Store<ISneakState>
constructor(private _overlayManager: OverlayManager) {
this._store = createSneakStore()
}
public get isActive(): boolean {
return !!this._activeOverlay
}
public addSneakProvider(provider: SneakProvider): IDisposable {
this._providers.push(provider)
const dispose = () => (this._providers = this._providers.filter(prov => prov !== provider))
return { dispose }
}
public show(): void {
if (this._activeOverlay) {
this._activeOverlay.hide()
this._activeOverlay = null
}
this._store.dispatch({ type: "START" })
this._collectSneakRectangles()
this._activeOverlay = this._overlayManager.createItem()
this._activeOverlay.setContents(
<Provider store={this._store}>
<ConnectedSneakView onComplete={info => this._onComplete(info)} />
</Provider>,
)
this._activeOverlay.show()
}
public close(): void {
if (this._activeOverlay) {
this._store.dispatch({ type: "END" })
this._activeOverlay.hide()
this._activeOverlay = null
}
}
private _onComplete(sneakInfo: ISneakInfo): void {
this.close()
sneakInfo.callback()
}
private _collectSneakRectangles(): void {
this._providers.forEach(async provider => {
const sneaks = await provider()
const normalizedSneaks = sneaks.filter(s => !!s)
this._store.dispatch({
type: "ADD_SNEAKS",
sneaks: normalizedSneaks,
})
})
}
}

View File

@ -0,0 +1,85 @@
/**
* SneakStore.ts
*
* State management for Sneaks
*/
import { Reducer, Store } from "redux"
import { Shapes } from "oni-api"
import { createStore as createReduxStore } from "./../../Redux"
export interface ISneakInfo {
rectangle: Shapes.Rectangle
callback: () => void
}
export interface IAugmentedSneakInfo extends ISneakInfo {
triggerKeys: string
}
export interface ISneakState {
isActive: boolean
sneaks: IAugmentedSneakInfo[]
}
const DefaultSneakState: ISneakState = {
isActive: true,
sneaks: [],
}
export type SneakAction =
| {
type: "START"
}
| {
type: "END"
}
| {
type: "ADD_SNEAKS"
sneaks: ISneakInfo[]
}
export const sneakReducer: Reducer<ISneakState> = (
state: ISneakState = DefaultSneakState,
action: SneakAction,
) => {
switch (action.type) {
case "START":
return DefaultSneakState
case "END":
return {
...DefaultSneakState,
isActive: false,
}
case "ADD_SNEAKS":
if (!state.isActive) {
return state
}
const newSneaks: IAugmentedSneakInfo[] = action.sneaks.map((sneak, idx) => {
return {
...sneak,
triggerKeys: getLabelFromIndex(idx + state.sneaks.length),
}
})
return {
...state,
sneaks: [...state.sneaks, ...newSneaks],
}
default:
return state
}
}
export const getLabelFromIndex = (index: number): string => {
const firstDigit = Math.floor(index / 26)
const secondDigit = index - firstDigit * 26
return String.fromCharCode(97 + firstDigit, 97 + secondDigit).toUpperCase()
}
export const createStore = (): Store<ISneakState> => {
return createReduxStore("Sneaks", sneakReducer, DefaultSneakState)
}

View File

@ -0,0 +1,116 @@
/**
* SneakView.tsx
*
* UX for the sneak functionality
*/
import * as React from "react"
import { connect } from "react-redux"
import { boxShadow, OverlayWrapper } from "./../../UI/components/common"
import { TextInputView } from "./../../UI/components/LightweightText"
import { IAugmentedSneakInfo, ISneakInfo, ISneakState } from "./SneakStore"
export interface ISneakContainerProps {
onComplete: (sneakInfo: ISneakInfo) => void
}
export interface ISneakViewProps extends ISneakContainerProps {
sneaks: IAugmentedSneakInfo[]
}
export interface ISneakViewState {
filterText: string
}
// Render a keyboard input?
// Grab input while 'sneaking'?
export class SneakView extends React.PureComponent<ISneakViewProps, ISneakViewState> {
constructor(props: ISneakViewProps) {
super(props)
this.state = {
filterText: "",
}
}
public render(): JSX.Element {
const normalizedFilterText = this.state.filterText.toUpperCase()
const filteredSneaks = this.props.sneaks.filter(
sneak => sneak.triggerKeys.indexOf(normalizedFilterText) === 0,
)
const sneaks = filteredSneaks.map(si => (
<SneakItemView sneak={si} filterLength={normalizedFilterText.length} />
))
if (filteredSneaks.length === 1) {
this.props.onComplete(filteredSneaks[0])
}
return (
<OverlayWrapper style={{ backgroundColor: "rgba(0, 0, 0, 0.1)" }}>
<div style={{ opacity: 0.01 }}>
<TextInputView
onChange={evt => {
this.setState({ filterText: evt.currentTarget.value })
}}
/>
</div>
{sneaks}
</OverlayWrapper>
)
}
}
export interface ISneakItemViewProps {
sneak: IAugmentedSneakInfo
filterLength: number
}
import styled from "styled-components"
const SneakItemWrapper = styled.div`
${boxShadow} background-color: ${props => props.theme["highlight.mode.visual.background"]};
color: ${props => props.theme["highlight.mode.visual.foreground"]};
`
const SneakItemViewSize = 20
const px = (num: number): string => num.toString() + "px"
export class SneakItemView extends React.PureComponent<ISneakItemViewProps, {}> {
public render(): JSX.Element {
const style: React.CSSProperties = {
position: "absolute",
left: px(this.props.sneak.rectangle.x),
top: px(this.props.sneak.rectangle.y),
width: px(SneakItemViewSize),
height: px(SneakItemViewSize),
}
return (
<SneakItemWrapper style={style}>
<span style={{ fontWeight: "bold" }}>
{this.props.sneak.triggerKeys.substring(0, this.props.filterLength)}
</span>
<span>
{this.props.sneak.triggerKeys.substring(
this.props.filterLength,
this.props.sneak.triggerKeys.length,
)}
</span>
</SneakItemWrapper>
)
}
}
const mapStateToProps = (
state: ISneakState,
containerProps?: ISneakContainerProps,
): ISneakViewProps => {
return {
...containerProps,
sneaks: state.sneaks || [],
}
}
export const ConnectedSneakView = connect(mapStateToProps)(SneakView)

View File

@ -0,0 +1,41 @@
/**
* Sneak/index.tsx
*
* Entry point for sneak functionality
*/
import { CallbackCommand, CommandManager } from "./../CommandManager"
import { OverlayManager } from "./../Overlay"
import { Sneak } from "./Sneak"
let _sneak: Sneak
export const activate = (commandManager: CommandManager, overlayManager: OverlayManager) => {
_sneak = new Sneak(overlayManager)
commandManager.registerCommand(
new CallbackCommand(
"sneak.show",
"Sneak: Current Window",
"Show commands for current window",
() => {
_sneak.show()
},
),
)
commandManager.registerCommand(
new CallbackCommand(
"sneak.hide",
"Sneak: Hide",
"Hide sneak view",
() => _sneak.close(),
() => _sneak.isActive,
),
)
}
export const getInstance = (): Sneak => {
return _sneak
}

View File

@ -22,7 +22,7 @@ export class Sneakable extends React.PureComponent<ISneakableProps, {}> {
public componentDidMount() {
this._cleanupSubscription()
this._subscription = getSneak().addSneakProvider(() => {
this._subscription = getSneak().addSneakProvider(async () => {
if (this._element) {
const rect = this._element.getBoundingClientRect()

View File

@ -0,0 +1,56 @@
import * as assert from "assert"
import * as Oni from "oni-api"
import { createStore, ISneakInfo } from "./../../../src/Services/Sneak/SneakStore"
const createTestSneak = (callback: () => void): ISneakInfo => {
return {
rectangle: Oni.Shapes.Rectangle.create(0, 0, 0, 0),
callback,
}
}
describe("SneakStore", () => {
it("ADD_SNEAKS is ignored if not active", () => {
const store = createStore()
const sneaks = [createTestSneak(null), createTestSneak(null)]
store.dispatch({
type: "END",
})
store.dispatch({
type: "ADD_SNEAKS",
sneaks,
})
const state = store.getState()
assert.deepEqual(state.sneaks, [])
})
it("Multiple ADD_SNEAKS actions are correctly labelled", () => {
const store = createStore()
const sneaksRound1 = [createTestSneak(null), createTestSneak(null)]
store.dispatch({ type: "START" })
store.dispatch({
type: "ADD_SNEAKS",
sneaks: sneaksRound1,
})
const sneaksRound2 = [createTestSneak(null), createTestSneak(null)]
store.dispatch({
type: "ADD_SNEAKS",
sneaks: sneaksRound2,
})
const keys = store.getState().sneaks.map(s => s.triggerKeys)
assert.deepEqual(keys, ["AA", "AB", "AC", "AD"])
})
})