Introduce a profile selector dropdown (#282)

This adds much better UI for selecting different profiles within a single import.

![Kapture 2020-05-30 at 21 34 06](https://user-images.githubusercontent.com/150329/83344564-595ce400-a2bd-11ea-8306-e5d8f647b65e.gif)

You can now hover over the middle of the toolbar or hit `t` on your keyboard to bring up the profile selector. From there, you can use fuzzy-find to switch to the profile you want, and hit "enter" to select it. The up and down arrow keys can be used while the profile selector filter input is focused to move through the list of profiles.

I think the "next" and "prev" buttons are now totally useless, so I removed them.

Fixes #167
This commit is contained in:
Jamie Wong 2020-05-30 21:42:27 -07:00 committed by GitHub
parent dead3f9ad9
commit 8620432cbc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 809 additions and 53 deletions

View File

@ -115,6 +115,7 @@ Once a profile has loaded, the main view is split into two: the top area is the
* `Cmd+O`/`Ctrl+O` to open a new profile
* `n`: Go to next profile/thread if one is available
* `p`: Go to previous profile/thread if one is available
* `t`: Open the profile/thread selector if available
## Contributing

View File

@ -0,0 +1,81 @@
import {fuzzyMatchStrings} from './fuzzy-find'
import {sortBy} from './utils'
function assertMatches(texts: string[], pattern: string, expectedResults: string[]) {
const results: {score: number; highlighted: string}[] = []
for (let text of texts) {
const match = fuzzyMatchStrings(text, pattern)
if (match == null) {
continue
}
let highlighted = ''
let last = 0
for (let range of match.matchedRanges) {
highlighted += `${text.slice(last, range[0])}[${text.slice(range[0], range[1])}]`
last = range[1]
}
highlighted += text.slice(last)
results.push({score: match.score, highlighted})
}
// Sort scores in descending order
sortBy(results, r => -r.score)
expect(results.map(r => r.highlighted)).toEqual(expectedResults)
}
function assertMatch(text: string, pattern: string, expected: string) {
assertMatches([text], pattern, [expected])
}
function assertNoMatch(text: string, pattern: string) {
assertMatches([text], pattern, [])
}
describe('fuzzyMatchStrings', () => {
test('no match', () => {
assertNoMatch('a', 'b')
assertNoMatch('aa', 'ab')
assertNoMatch('a', 'aa')
assertNoMatch('ca', 'ac')
})
test('full text match', () => {
assertMatch('hello', 'hello', '[hello]')
assertMatch('multiple words', 'multiple words', '[multiple words]')
})
test('case sensitivity', () => {
assertMatch('HELLO', 'hello', '[HELLO]')
assertMatch('Hello', 'hello', '[Hello]')
assertNoMatch('hello', 'Hello')
assertNoMatch('hello', 'HELLO')
})
test('multiple occurrences', () => {
assertMatch('hello hello', 'hello', '[hello] hello')
assertMatch('hellohello', 'hello', '[hello]hello')
})
test('prefer earlier matches', () => {
assertMatches(['cab', 'ab'], 'ab', ['[ab]', 'c[ab]'])
})
test('prefer shorter matches', () => {
assertMatches(['abbc', 'abc', 'abbbc'], 'ac', ['[a]b[c]', '[a]bb[c]', '[a]bbb[c]'])
})
test('prefer word boundaries', () => {
assertMatches(['abc', 'a c'], 'ac', ['[a] [c]', '[a]b[c]'])
})
test('prefer camelCase matches', () => {
assertMatches(['downtown', 'OutNode'], 'n', ['Out[N]ode', 'dow[n]town'])
})
test('prefer number prefix matches', () => {
assertMatches(['211', 'a123'], '1', ['a[1]23', '2[1]1'])
})
})

246
src/lib/fuzzy-find.ts Normal file
View File

@ -0,0 +1,246 @@
/**
* This file contains an implementation of fuzzy string matching.
*/
export interface FuzzyMatch {
// List of [start, end] indices in the haystack string that match the needle string
matchedRanges: [number, number][]
// The score of the match for relative ranking. Higher scores indicate
// "better" matches.
score: number
}
export function fuzzyMatchStrings(text: string, pattern: string): FuzzyMatch | null {
return fzfFuzzyMatchV1(text, pattern)
}
// The implementation here is based on FuzzyMatchV1, as described here:
// https://github.com/junegunn/fzf/blob/f81feb1e69e5cb75797d50817752ddfe4933cd68/src/algo/algo.go#L8-L15
//
// This is a hand-port to better understand what the code is doing and for added
// clarity.
//
// Capitalized letters only match capitalized letters, but lower-case letters
// match both.
//
// Note: fzf includes a normalization table for homoglyphs. I'm going to ignore that too
// https://github.com/junegunn/fzf/blob/master/src/algo/normalize.go
const charCodeLowerA = 'a'.charCodeAt(0)
const charCodeLowerZ = 'z'.charCodeAt(0)
const charCodeUpperA = 'A'.charCodeAt(0)
const charCodeUpperZ = 'Z'.charCodeAt(0)
const charCodeDigit0 = '0'.charCodeAt(0)
const charCodeDigit9 = '9'.charCodeAt(0)
enum fzfCharClass {
charNonWord,
charLower,
charUpper,
charNumber,
}
function fzfCharClassOf(char: string): fzfCharClass {
const code = char.charCodeAt(0)
if (charCodeLowerA <= code && code <= charCodeLowerZ) {
return fzfCharClass.charLower
} else if (charCodeUpperA <= code && code <= charCodeUpperZ) {
return fzfCharClass.charUpper
} else if (charCodeDigit0 <= code && code <= charCodeDigit9) {
return fzfCharClass.charNumber
}
return fzfCharClass.charNonWord
}
function charsMatch(textChar: string, patternChar: string): boolean {
if (textChar === patternChar) return true
const patternCharCode = patternChar.charCodeAt(0)
if (charCodeLowerA <= patternCharCode && patternCharCode <= charCodeLowerZ) {
return textChar.charCodeAt(0) === patternCharCode - charCodeLowerA + charCodeUpperA
}
return false
}
function fzfFuzzyMatchV1(text: string, pattern: string): FuzzyMatch | null {
if (pattern.length == 0) {
return {matchedRanges: [], score: 0}
}
// I removed the fzfAsciiFuzzyIndex code because it's not actually clear to
// me that it's a very helpful optimization.
let pidx = 0
let sidx = -1
let eidx = -1
let lenRunes = text.length
let lenPattern = pattern.length
// Forward pass: scan over the text pattern, identifying the earliest start
// and the latest end to consider.
for (let index = 0; index < lenRunes; index++) {
let char = text[index]
let pchar = pattern[pidx]
if (charsMatch(char, pchar)) {
if (sidx < 0) {
sidx = index
}
pidx++
if (pidx == lenPattern) {
// We found the last character in the pattern! eidx is exclusive, so
// we'll set it to the current index + 1.
eidx = index + 1
break
}
}
}
if (eidx == -1) {
// We couldn't find all the characters in the pattern. No match.
return null
}
// Assuming we found all the characters in the pattern, perform the backwards
// pass.
pidx--
for (let index = eidx - 1; index >= sidx; index--) {
const char = text[index]
const pchar = pattern[pidx]
if (charsMatch(char, pchar)) {
pidx--
if (pidx < 0) {
// We found the first character of the pattern, scanning
// backwards. This *may* have narrowed the match further.
// For example, for the following inputs:
//
// text = "xxx a b c abc xxx"
// pattern = "abc"
//
// For the forward pass, you get:
//
// "xxx a b c abc xxx"
// start^ ^end
//
// But after the backward pass, we can narrow this to:
//
// "xxx a b c abc xxx"
// start^ ^end
sidx = index
return fzfCalculateScore(text, pattern, sidx, eidx)
}
}
}
// This should be unreachable.
throw new Error('Implementation error. This must be a bug in fzfFuzzyMatchV1')
}
const fzfScoreMatch = 16
const fzfScoreGapStart = -3
const fzfScoreGapExtension = -1
const fzfBonusBoundary = fzfScoreMatch / 2
const fzfBonusNonWord = fzfScoreMatch / 2
const fzfBonusCamel123 = fzfBonusBoundary + fzfScoreGapExtension
const fzfBonusConsecutive = -(fzfScoreGapStart + fzfScoreGapExtension)
const fzfBonusFirstCharMultiplier = 2
function bonusFor(prevClass: fzfCharClass, curClass: fzfCharClass): number {
if (prevClass === fzfCharClass.charNonWord && curClass !== fzfCharClass.charNonWord) {
// Prefer matching at word boundaries
//
// This should prefer "a c" over "abc" for a pattern of "ac".
return fzfBonusBoundary
}
if (
(prevClass === fzfCharClass.charLower && curClass == fzfCharClass.charUpper) ||
(prevClass !== fzfCharClass.charNumber && curClass == fzfCharClass.charNumber)
) {
// Prefer matching at the transition point between lower & upper for camelCase,
// and from transition from letter to number for identifiers like letter123.
//
// This should prefer "OutNode" over "phone" for a pattern of "n",
// and "abc123" over "x211" for a pattern of "1".
return fzfBonusCamel123
}
if (curClass === fzfCharClass.charNonWord) {
return fzfBonusNonWord
}
return 0
}
function fzfCalculateScore(text: string, pattern: string, sidx: number, eidx: number): FuzzyMatch {
let pidx = 0
let score = 0
let inGap = false
let consecutive = 0
let firstBonus = 0
let pos: number[] = new Array(pattern.length)
let prevClass = fzfCharClass.charNonWord
if (sidx > 0) {
prevClass = fzfCharClassOf(text[sidx - 1])
}
for (let idx = sidx; idx < eidx; idx++) {
let char = text[idx]
let curClass = fzfCharClassOf(char)
if (charsMatch(char, pattern[pidx])) {
pos[pidx] = idx
score += fzfScoreMatch
let bonus = bonusFor(prevClass, curClass)
if (consecutive == 0) {
firstBonus = bonus
} else {
// Break consecutive chunk
if (bonus === fzfBonusBoundary) {
firstBonus = bonus
}
bonus = Math.max(bonus, firstBonus, fzfBonusConsecutive)
}
if (pidx === 0) {
score += bonus * fzfBonusFirstCharMultiplier
} else {
score += bonus
}
inGap = false
consecutive++
pidx++
} else {
if (inGap) {
// Penalize gaps (this bonus is negative)
score += fzfScoreGapExtension
} else {
// Penalize the beginning of gaps more harshly
score += fzfScoreGapStart
}
inGap = true
consecutive = 0
firstBonus = 0
}
prevClass = curClass
}
if (pidx !== pattern.length) {
throw new Error(
'fzfCalculateScore should only be called when pattern is found between sidx and eidx',
)
}
let matchedRanges: [number, number][] = [[pos[0], pos[0] + 1]]
for (let i = 1; i < pos.length; i++) {
const curPos = pos[i]
const curRange = matchedRanges[matchedRanges.length - 1]
if (curRange[1] === curPos) {
curRange[1] = curPos + 1
} else {
matchedRanges.push([curPos, curPos + 1])
}
}
return {
score,
matchedRanges,
}
}

View File

@ -0,0 +1,410 @@
import {Profile} from '../lib/profile'
import {h, JSX, ComponentChild, Ref} from 'preact'
import {useCallback, useState, useMemo, useEffect, useRef} from 'preact/hooks'
import {StyleSheet, css} from 'aphrodite'
import {Colors, ZIndex, Sizes} from './style'
import {fuzzyMatchStrings} from '../lib/fuzzy-find'
import {sortBy} from '../lib/utils'
interface ProfileSelectRowProps {
setProfileIndexToView: (profileIndex: number) => void
setHoveredProfileIndex: (profileIndex: number) => void
profile: Profile
matchedRanges: [number, number][]
hovered: boolean
selected: boolean
indexInProfileGroup: number
indexInFilteredListView: number
profileCount: number
nodeRef?: Ref<HTMLDivElement>
closeProfileSelect: () => void
}
function highlightRanges(text: string, ranges: [number, number][]): JSX.Element {
const spans: ComponentChild[] = []
let last = 0
for (let range of ranges) {
spans.push(text.slice(last, range[0]))
spans.push(<span className={css(style.highlighted)}>{text.slice(range[0], range[1])}</span>)
last = range[1]
}
spans.push(text.slice(last))
return <span>{spans}</span>
}
export function ProfileSelectRow({
setProfileIndexToView,
setHoveredProfileIndex,
profile,
selected,
hovered,
profileCount,
nodeRef,
closeProfileSelect,
indexInProfileGroup,
matchedRanges,
indexInFilteredListView,
}: ProfileSelectRowProps) {
const onMouseUp = useCallback(() => {
closeProfileSelect()
setProfileIndexToView(indexInProfileGroup)
}, [closeProfileSelect, setProfileIndexToView, indexInProfileGroup])
const onMouseEnter = useCallback(
(ev: Event) => {
setHoveredProfileIndex(indexInProfileGroup)
},
[setHoveredProfileIndex, indexInProfileGroup],
)
const name = profile.getName()
const maxDigits = 1 + Math.floor(Math.log10(profileCount))
const highlighted = useMemo(() => {
const result = highlightRanges(name, matchedRanges)
return result
}, [name, matchedRanges])
// TODO(jlfwong): There's a really gnarly edge-case here where the highlighted
// ranges are part of the text truncated by ellipsis. I'm just going to punt
// on solving for that.
return (
<div
ref={nodeRef}
onMouseUp={onMouseUp}
onMouseEnter={onMouseEnter}
title={name}
className={css(
style.profileRow,
indexInFilteredListView % 2 === 0 && style.profileRowEven,
selected && style.profileRowSelected,
hovered && style.profileRowHovered,
)}
>
<span className={css(style.profileIndex)} style={{width: maxDigits + 'em'}}>
{indexInProfileGroup + 1}:
</span>{' '}
{highlighted}
</div>
)
}
interface ProfileSelectProps {
setProfileIndexToView: (profileIndex: number) => void
indexToView: number
profiles: Profile[]
closeProfileSelect: () => void
visible: boolean
}
function stopPropagation(ev: Event) {
ev.stopPropagation()
}
interface FilteredProfile {
indexInProfileGroup: number
profile: Profile
matchedRanges: [number, number][]
score: number
}
function getSortedFilteredProfiles(profiles: Profile[], filterText: string): FilteredProfile[] {
const filtered: FilteredProfile[] = []
for (let i = 0; i < profiles.length; i++) {
const profile = profiles[i]
const match = fuzzyMatchStrings(profile.getName(), filterText)
if (!match) continue
filtered.push({
indexInProfileGroup: i,
profile,
...match,
})
}
sortBy(filtered, p => -p.score)
return filtered
}
export function ProfileSelect({
profiles,
closeProfileSelect,
indexToView,
visible,
setProfileIndexToView,
}: ProfileSelectProps) {
const [filterText, setFilterText] = useState('')
const onFilterTextChange = useCallback(
(ev: Event) => {
const value = (ev.target as HTMLInputElement).value
setFilterText(value)
},
[setFilterText],
)
const focusFilterInput = useCallback(
(node: HTMLInputElement | null) => {
if (node) {
if (visible) {
node.select()
} else {
node.blur()
}
}
},
[visible],
)
const filteredProfiles = useMemo(() => {
return getSortedFilteredProfiles(profiles, filterText)
}, [profiles, filterText])
const [hoveredProfileIndex, setHoveredProfileIndex] = useState<number | null>(0)
const selectedNodeRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (visible) {
// Whenever the profile select becomes visible...
// Clear any hovered element
setHoveredProfileIndex(null)
// And scroll the selected profile into view, if possible
if (selectedNodeRef.current !== null) {
selectedNodeRef.current.scrollIntoView({
behavior: 'auto',
block: 'nearest',
inline: 'nearest',
})
}
}
}, [visible])
// TODO(jlfwong): Hi-jacking the behavior of enter and the arrow keys won't
// work well for some composition methods (e.g. a Chinese character
// composition keyboard input method).
const onFilterKeyUp = useCallback(
(ev: KeyboardEvent) => {
// Prevent the key-press from propagating to other keyboard shortcut
// handlers in other components.
ev.stopPropagation()
let newHoveredIndexInFilteredList: number | null = null
switch (ev.key) {
case 'Enter': {
if (hoveredProfileIndex != null) {
closeProfileSelect()
setProfileIndexToView(hoveredProfileIndex)
}
break
}
case 'Escape': {
closeProfileSelect()
break
}
case 'ArrowDown': {
ev.preventDefault()
newHoveredIndexInFilteredList = 0
if (hoveredProfileIndex != null) {
const indexInFilteredList = filteredProfiles.findIndex(
p => p.indexInProfileGroup === hoveredProfileIndex,
)
if (indexInFilteredList !== -1) {
newHoveredIndexInFilteredList = indexInFilteredList + 1
}
}
break
}
case 'ArrowUp': {
ev.preventDefault()
newHoveredIndexInFilteredList = filteredProfiles.length - 1
if (hoveredProfileIndex != null) {
const indexInFilteredList = filteredProfiles.findIndex(
p => p.indexInProfileGroup === hoveredProfileIndex,
)
if (indexInFilteredList !== -1) {
newHoveredIndexInFilteredList = indexInFilteredList - 1
}
}
break
}
}
if (
newHoveredIndexInFilteredList != null &&
newHoveredIndexInFilteredList >= 0 &&
newHoveredIndexInFilteredList < filteredProfiles.length
) {
const indexInProfileGroup =
filteredProfiles[newHoveredIndexInFilteredList].indexInProfileGroup
setHoveredProfileIndex(indexInProfileGroup)
setPendingForcedScroll(true)
}
},
[closeProfileSelect, setProfileIndexToView, hoveredProfileIndex, filteredProfiles],
)
const [pendingForcedScroll, setPendingForcedScroll] = useState(false)
useEffect(() => {
// Whenever the list of filtered profiles changes, set the first element hovered.
if (filteredProfiles.length > 0) {
setHoveredProfileIndex(filteredProfiles[0].indexInProfileGroup)
setPendingForcedScroll(true)
}
}, [setHoveredProfileIndex, filteredProfiles])
const hoveredNodeRef = useCallback(
(hoveredNode: HTMLDivElement | null) => {
if (pendingForcedScroll && hoveredNode) {
hoveredNode.scrollIntoView({
behavior: 'auto',
block: 'nearest',
inline: 'nearest',
})
setPendingForcedScroll(false)
}
},
[pendingForcedScroll, setPendingForcedScroll],
)
const selectedHoveredRef = useCallback(
(node: HTMLDivElement | null) => {
selectedNodeRef.current = node
hoveredNodeRef(node)
},
[selectedNodeRef, hoveredNodeRef],
)
// We allow ProfileSelect to be aware of its own visibility in order to retain
// its scroll offset state between times when it's hidden & shown, and also to
// scroll the selected node into view once it becomes shown again after the
// selected profile has changed.
return (
<div className={css(style.profileSelectOuter)}>
<div className={css(style.caret)} />
<div className={css(style.profileSelectBox)}>
{/* We stop event propagation for key events on the input to prevent
this from triggering keyboard shortcuts. */}
<div className={css(style.filterInputContainer)}>
<input
type="text"
ref={focusFilterInput}
placeholder={'Filter...'}
value={filterText}
onInput={onFilterTextChange}
onKeyDown={onFilterKeyUp}
onKeyUp={stopPropagation}
onKeyPress={stopPropagation}
/>
</div>
<div className={css(style.profileSelectScrolling)}>
{filteredProfiles.map(({profile, matchedRanges, indexInProfileGroup}, indexInList) => {
let ref: Ref<HTMLDivElement> | undefined = undefined
const selected = indexInProfileGroup === indexToView
const hovered = indexInProfileGroup === hoveredProfileIndex
if (selected && hovered) {
ref = selectedHoveredRef
} else if (selected) {
ref = selectedNodeRef
} else if (hovered) {
ref = hoveredNodeRef
}
return (
<ProfileSelectRow
setHoveredProfileIndex={setHoveredProfileIndex}
indexInProfileGroup={indexInProfileGroup}
indexInFilteredListView={indexInList}
hovered={indexInProfileGroup == hoveredProfileIndex}
selected={indexInProfileGroup === indexToView}
profile={profile}
profileCount={profiles.length}
nodeRef={ref}
matchedRanges={matchedRanges}
setProfileIndexToView={setProfileIndexToView}
closeProfileSelect={closeProfileSelect}
/>
)
})}
{filteredProfiles.length === 0 ? (
<div className={css(style.profileRow)}>No results match filter "{filterText}"</div>
) : null}
</div>
</div>
</div>
)
}
const paddingHeight = 10
const style = StyleSheet.create({
filterInputContainer: {
display: 'flex',
flexDirection: 'column',
padding: 10,
alignItems: 'stretch',
},
caret: {
width: 0,
height: 0,
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderBottom: '5px solid black',
},
highlighted: {
background: Colors.PALE_DARK_BLUE,
},
padding: {
height: paddingHeight,
background: Colors.BLACK,
},
profileRow: {
height: Sizes.FRAME_HEIGHT - 2,
border: '1px solid transparent',
textAlign: 'left',
paddingLeft: 10,
paddingRight: 10,
background: Colors.BLACK,
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
cursor: 'pointer',
},
profileRowHovered: {
border: `1px solid ${Colors.DARK_BLUE}`,
},
profileRowSelected: {
background: Colors.DARK_BLUE,
},
profileRowEven: {
background: Colors.DARK_GRAY,
},
profileSelectScrolling: {
maxHeight: `min(calc(100vh - ${Sizes.TOOLBAR_HEIGHT - 2 * paddingHeight}px), ${
20 * Sizes.FRAME_HEIGHT
}px)`,
overflow: 'auto',
},
profileSelectBox: {
width: '100%',
paddingBottom: 10,
background: Colors.BLACK,
color: Colors.WHITE,
},
profileSelectOuter: {
width: '100%',
maxWidth: 480,
margin: '0 auto',
position: 'relative',
zIndex: ZIndex.PROFILE_SELECT,
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
},
profileIndex: {
textAlign: 'right',
display: 'inline-block',
color: Colors.LIGHT_GRAY,
},
})

View File

@ -40,7 +40,8 @@ export enum Duration {
}
export enum ZIndex {
HOVERTIP = 1,
PROFILE_SELECT = 1,
HOVERTIP = 2,
}
export const commonStyle = StyleSheet.create({

View File

@ -1,9 +1,13 @@
import {ApplicationProps} from './application'
import {ViewMode} from '../store'
import {h, JSX, Fragment} from 'preact'
import {useCallback} from 'preact/hooks'
import {useCallback, useState, useEffect} from 'preact/hooks'
import {StyleSheet, css} from 'aphrodite'
import {Sizes, Colors, FontFamily, FontSize, Duration} from './style'
import {ProfileSelect} from './profile-select'
import {ProfileGroupState} from '../store/profiles-state'
import {Profile} from '../lib/profile'
import {objectsHaveShallowEquality} from '../lib/utils'
interface ToolbarProps extends ApplicationProps {
browseForFile(): void
@ -54,44 +58,77 @@ function ToolbarLeftContent(props: ToolbarProps) {
)
}
const getCachedProfileList = (() => {
// TODO(jlfwong): It would be nice to just implement this as useMemo, but if
// we do that using profileGroup or profileGroup.profiles as the cache key,
// then it will invalidate whenever *anything* changes, because
// profileGroup.profiles is ProfileState[], which contains component state
// information for each tab for each profile. So whenever any property in any
// persisted view state changes for *any* view in *any* profile, the profiles
// list will get re-generated.
let cachedProfileList: Profile[] | null = null
return (profileGroup: ProfileGroupState): Profile[] | null => {
let nextProfileList = profileGroup?.profiles.map(p => p.profile) || null
if (
cachedProfileList === null ||
(nextProfileList != null && !objectsHaveShallowEquality(cachedProfileList, nextProfileList))
) {
cachedProfileList = nextProfileList
}
return cachedProfileList
}
})()
function ToolbarCenterContent(props: ToolbarProps): JSX.Element {
const {activeProfileState, profileGroup} = props
if (activeProfileState && profileGroup) {
const {index} = activeProfileState
const profiles = getCachedProfileList(profileGroup)
const [profileSelectShown, setProfileSelectShown] = useState(false)
const openProfileSelect = useCallback(() => {
setProfileSelectShown(true)
}, [setProfileSelectShown])
const closeProfileSelect = useCallback(() => {
setProfileSelectShown(false)
}, [setProfileSelectShown])
useEffect(() => {
const onWindowKeyPress = (ev: KeyboardEvent) => {
if (ev.key === 't') {
ev.preventDefault()
setProfileSelectShown(true)
}
}
window.addEventListener('keypress', onWindowKeyPress)
return () => {
window.removeEventListener('keypress', onWindowKeyPress)
}
}, [setProfileSelectShown])
if (activeProfileState && profileGroup && profiles) {
if (profileGroup.profiles.length === 1) {
return <Fragment>{activeProfileState.profile.getName()}</Fragment>
} else {
function makeNavButton(content: string, disabled: boolean, onClick: () => void) {
return (
<button
disabled={disabled}
onClick={onClick}
className={css(
style.emoji,
style.toolbarProfileNavButton,
disabled && style.toolbarProfileNavButtonDisabled,
)}
>
{content}
</button>
)
}
const prevButton = makeNavButton('⬅️', index === 0, () =>
props.setProfileIndexToView(index - 1),
)
const nextButton = makeNavButton('➡️', index >= profileGroup.profiles.length - 1, () =>
props.setProfileIndexToView(index + 1),
)
return (
<div className={css(style.toolbarCenter)}>
{prevButton}
{activeProfileState.profile.getName()}{' '}
<span className={css(style.toolbarProfileIndex)}>
({activeProfileState.index + 1}/{profileGroup.profiles.length})
<div className={css(style.toolbarCenter)} onMouseLeave={closeProfileSelect}>
<span onMouseOver={openProfileSelect}>
{activeProfileState.profile.getName()}{' '}
<span className={css(style.toolbarProfileIndex)}>
({activeProfileState.index + 1}/{profileGroup.profiles.length})
</span>
</span>
{nextButton}
<div style={{display: profileSelectShown ? 'block' : 'none'}}>
<ProfileSelect
setProfileIndexToView={props.setProfileIndexToView}
indexToView={profileGroup.indexToView}
profiles={profiles}
closeProfileSelect={closeProfileSelect}
visible={profileSelectShown}
/>
</div>
</div>
)
}
@ -177,26 +214,6 @@ const style = StyleSheet.create({
toolbarProfileIndex: {
color: Colors.LIGHT_GRAY,
},
toolbarProfileNavButton: {
opacity: 0.8,
fontSize: FontSize.TITLE,
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
':hover': {
opacity: 1.0,
},
background: 'none',
border: 'none',
padding: 0,
marginLeft: '0.3em',
marginRight: '0.3em',
transition: `all ${Duration.HOVER_CHANGE} ease-in`,
},
toolbarProfileNavButtonDisabled: {
opacity: 0.5,
':hover': {
opacity: 0.5,
},
},
toolbarTab: {
background: Colors.DARK_GRAY,
marginTop: Sizes.SEPARATOR_HEIGHT,