diff --git a/README.md b/README.md index 4cf0897..56b22ee 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/lib/fuzzy-find.test.ts b/src/lib/fuzzy-find.test.ts new file mode 100644 index 0000000..fea49b2 --- /dev/null +++ b/src/lib/fuzzy-find.test.ts @@ -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']) + }) +}) diff --git a/src/lib/fuzzy-find.ts b/src/lib/fuzzy-find.ts new file mode 100644 index 0000000..acbc994 --- /dev/null +++ b/src/lib/fuzzy-find.ts @@ -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, + } +} diff --git a/src/views/profile-select.tsx b/src/views/profile-select.tsx new file mode 100644 index 0000000..8f58ea0 --- /dev/null +++ b/src/views/profile-select.tsx @@ -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 + 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({text.slice(range[0], range[1])}) + last = range[1] + } + spans.push(text.slice(last)) + + return {spans} +} + +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 ( +
+ + {indexInProfileGroup + 1}: + {' '} + {highlighted} +
+ ) +} + +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(0) + + const selectedNodeRef = useRef(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 ( +
+
+
+ {/* We stop event propagation for key events on the input to prevent + this from triggering keyboard shortcuts. */} +
+ +
+
+ {filteredProfiles.map(({profile, matchedRanges, indexInProfileGroup}, indexInList) => { + let ref: Ref | 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 ( + + ) + })} + {filteredProfiles.length === 0 ? ( +
No results match filter "{filterText}"
+ ) : null} +
+
+
+ ) +} + +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, + }, +}) diff --git a/src/views/style.ts b/src/views/style.ts index c212689..dca459c 100644 --- a/src/views/style.ts +++ b/src/views/style.ts @@ -40,7 +40,8 @@ export enum Duration { } export enum ZIndex { - HOVERTIP = 1, + PROFILE_SELECT = 1, + HOVERTIP = 2, } export const commonStyle = StyleSheet.create({ diff --git a/src/views/toolbar.tsx b/src/views/toolbar.tsx index e60e73f..b9efe5b 100644 --- a/src/views/toolbar.tsx +++ b/src/views/toolbar.tsx @@ -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 {activeProfileState.profile.getName()} } else { - function makeNavButton(content: string, disabled: boolean, onClick: () => void) { - return ( - - ) - } - - const prevButton = makeNavButton('⬅️', index === 0, () => - props.setProfileIndexToView(index - 1), - ) - const nextButton = makeNavButton('➡️', index >= profileGroup.profiles.length - 1, () => - props.setProfileIndexToView(index + 1), - ) - return ( -
- {prevButton} - {activeProfileState.profile.getName()}{' '} - - ({activeProfileState.index + 1}/{profileGroup.profiles.length}) +
+ + {activeProfileState.profile.getName()}{' '} + + ({activeProfileState.index + 1}/{profileGroup.profiles.length}) + - {nextButton} +
+ +
) } @@ -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,