diff --git a/js/components/MilkdropWindow/PresetOverlay.js b/js/components/MilkdropWindow/PresetOverlay.js index 7bc97d22..6b98b31c 100644 --- a/js/components/MilkdropWindow/PresetOverlay.js +++ b/js/components/MilkdropWindow/PresetOverlay.js @@ -1,10 +1,59 @@ import React from "react"; import { promptForFileReferences } from "../../fileUtils"; +import { clamp } from "../../utils"; + +const ENTRY_HEIGHT = 14; +const HEIGHT_PADDING = 15; +const WIDTH_PADDING = 20; + +const LoadingState = () => ( +
+ Loading presets +
+); + +const ListWrapper = ({ width, height, children }) => ( +
+
+ +
+
+); class PresetOverlay extends React.Component { constructor(props) { super(props); - this.state = { presetIdx: Math.max(props.currentPreset, 0) }; + const listIndex = this._listIndexFromPresetIndex(props.currentPreset); + this.state = { + selectedListIndex: clamp(listIndex, 0, this._maxListIndex()) + }; } componentDidMount() { @@ -22,23 +71,27 @@ class PresetOverlay extends React.Component { _handleFocusedKeyboardInput = e => { switch (e.keyCode) { case 38: // up arrow - this.setState({ presetIdx: Math.max(this.state.presetIdx - 1, -1) }); + this.setState({ + selectedListIndex: Math.max(this.state.selectedListIndex - 1, 0) + }); e.stopPropagation(); break; case 40: // down arrow this.setState({ - presetIdx: Math.min( - this.state.presetIdx + 1, - this.props.presetKeys.length - 1 + selectedListIndex: Math.min( + this.state.selectedListIndex + 1, + this._maxListIndex() ) }); e.stopPropagation(); break; case 13: // enter - if (this.state.presetIdx === -1) { + if (this.state.selectedListIndex === 0) { this.loadLocalDir(); } else { - this.props.selectPreset(this.state.presetIdx); + this.props.selectPreset( + this._presetIndexFromListIndex(this.state.selectedListIndex) + ); } e.stopPropagation(); break; @@ -49,103 +102,83 @@ class PresetOverlay extends React.Component { } }; + _presetIndexFromListIndex(listIndex) { + return listIndex - 1; + } + + _listIndexFromPresetIndex(listIndex) { + return listIndex + 1; + } + + _maxListIndex() { + // Number of presets, plus one for the "Load Local Directory" option, minus + // one to convert a length to an index. + return this.props.presetKeys.length; // - 1 + 1; + } + async loadLocalDir() { const fileReferences = await promptForFileReferences({ directory: true }); + // TODO: Technically there is a race condition here, since the component + // could get unmounted before the promise resolves. this.props.loadPresets(fileReferences); } - render() { - if (!this.props.presetKeys) { - return ( -
- Loading presets -
- ); - } + _renderList() { + const { presetKeys, currentPreset, height, width } = this.props; + const { selectedListIndex } = this.state; - // display highlighted preset in the middle if possible - const numPresets = this.props.presetKeys.length; - let presetListLen = Math.floor(this.props.height / 20); - presetListLen = Math.min(Math.max(presetListLen, 3), numPresets); - presetListLen = presetListLen % 2 ? presetListLen : presetListLen - 1; - const halfPresetListLen = Math.floor(presetListLen / 2); - let startIdx = Math.max(this.state.presetIdx - halfPresetListLen, -1); - let endIdx = Math.min(startIdx + presetListLen, numPresets); - if (endIdx >= numPresets) { - startIdx = Math.max(endIdx - presetListLen, -1); - endIdx = Math.min(startIdx + presetListLen, numPresets); - } - startIdx = Math.max(startIdx, 0); // ensure startIdx >= 0 after endIdx is calculated - const presets = this.props.presetKeys.slice(startIdx, endIdx); - const presetElms = presets.map((presetName, i) => { - let color; - if (i + startIdx === this.props.currentPreset) { - if (i + startIdx === this.state.presetIdx) { - color = "#FFCC22"; - } else { - color = "#CCFF03"; - } - } else if (i + startIdx === this.state.presetIdx) { - color = "#FF5050"; - } else { - color = "#CCCCCC"; - } - return ( -
  • - {presetName} -
  • - ); - }); + const maxVisibleRows = Math.floor((height - HEIGHT_PADDING) / ENTRY_HEIGHT); + const rowsToShow = Math.floor(maxVisibleRows * 0.75); // Only fill 3/4 of the screen. + const [startIndex, endIndex] = getRangeCenteredOnIndex( + this._maxListIndex() + 1, // Add one to convert an index to a length + rowsToShow, + selectedListIndex + ); - if (this.state.presetIdx - halfPresetListLen < 0) { + const presetElms = []; + for (let i = startIndex; i <= endIndex; i++) { + const presetIndex = this._presetIndexFromListIndex(i); + const isSelected = i === selectedListIndex; + const isCurrent = presetIndex === currentPreset; let color; - if (this.state.presetIdx === -1) { - color = "#FF5050"; + if (isSelected) { + color = isCurrent ? "#FFCC22" : "#FF5050"; } else { - color = "#CCCCCC"; + color = isCurrent ? "#CCFF03" : "#CCCCCC"; } - presetElms.unshift( -
  • - Load Local Directory + presetElms.push( +
  • + {i === 0 ? "Load Local Directory" : presetKeys[presetIndex]}
  • ); } return ( -
    -
    - -
    -
    + + {presetElms} + + ); + } + + render() { + return this.props.presetKeys != null ? ( + this._renderList() + ) : ( + ); } } +// Find a tuple `[startIndex, endIndex]` representing start/end indexes into an +// array of length `length`, that descripe a range of size up to `rangeSize` +// where a best effort is made to center `indexToCenter`. +export function getRangeCenteredOnIndex(length, maxRangeSize, indexToCenter) { + const rangeSize = Math.min(length, maxRangeSize); + const halfRangeSize = Math.floor(rangeSize / 2); + const idealStartIndex = indexToCenter - halfRangeSize; + const startIndex = clamp(idealStartIndex, 0, length - rangeSize); + const endIndex = startIndex + rangeSize - 1; + return [startIndex, endIndex]; +} + export default PresetOverlay; diff --git a/js/components/MilkdropWindow/__tests__/PresetOverlay.test.js b/js/components/MilkdropWindow/__tests__/PresetOverlay.test.js new file mode 100644 index 00000000..488d4b84 --- /dev/null +++ b/js/components/MilkdropWindow/__tests__/PresetOverlay.test.js @@ -0,0 +1,29 @@ +import { getRangeCenteredOnIndex } from "../PresetOverlay"; + +describe("getRangeCenteredOnIndex", () => { + test("gets the whole range when the list is the same size as the range", () => { + expect(getRangeCenteredOnIndex(100, 100, 0)).toEqual([0, 99]); + expect(getRangeCenteredOnIndex(100, 100, 99)).toEqual([0, 99]); + expect(getRangeCenteredOnIndex(100, 100, 50)).toEqual([0, 99]); + }); + test("gets the first elements when index < half of range", () => { + expect(getRangeCenteredOnIndex(100, 50, 0)).toEqual([0, 49]); + expect(getRangeCenteredOnIndex(100, 50, 24)).toEqual([0, 49]); + }); + test("truncates the returned range when the passed range is smaller than items", () => { + expect(getRangeCenteredOnIndex(100, 50, 0)).toEqual([0, 49]); + expect(getRangeCenteredOnIndex(100, 50, 24)).toEqual([0, 49]); + }); + + test("follows an example sliding window", () => { + expect(getRangeCenteredOnIndex(10, 5, 1)).toEqual([0, 4]); + expect(getRangeCenteredOnIndex(10, 5, 2)).toEqual([0, 4]); + expect(getRangeCenteredOnIndex(10, 5, 3)).toEqual([1, 5]); + expect(getRangeCenteredOnIndex(10, 5, 4)).toEqual([2, 6]); + expect(getRangeCenteredOnIndex(10, 5, 5)).toEqual([3, 7]); + expect(getRangeCenteredOnIndex(10, 5, 6)).toEqual([4, 8]); + expect(getRangeCenteredOnIndex(10, 5, 7)).toEqual([5, 9]); + expect(getRangeCenteredOnIndex(10, 5, 8)).toEqual([5, 9]); + expect(getRangeCenteredOnIndex(10, 5, 9)).toEqual([5, 9]); + }); +});