mirror of https://github.com/onivim/oni.git
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:
parent
6a6c302e12
commit
71b5891d93
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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"])
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue