jevstrudel.git / website / src / repl / components / panel / SettingsTab.jsx
SettingsTab.jsx419 lines · 14.4 KB · raw
1import { defaultSettings, settingsMap, useSettings, storePrebakeScript, setSettingsTab } from '../../../settings.mjs';
2import { themes } from '@strudel/codemirror';
3import { PrebakeCodeMirror } from '../../../repl/prebakeCodeMirror.mjs';
4import { confirmAndReloadPage, isUdels } from '../../util.mjs';
5import { ButtonGroup } from './Forms.jsx';
6import { AudioDeviceSelector } from './AudioDeviceSelector.jsx';
7import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx';
8import { confirmDialog } from '../../util.mjs';
9import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony, setMultiChannelOrbits } from '@strudel/webaudio';
10import { ActionButton } from '../button/action-button.jsx';
11import { exportScript, ImportPrebakeScriptButton } from './ImportPrebakeScriptButton.jsx';
12import { useEffect, useRef } from 'react';
13import cx from '@src/cx.mjs';
14
15const inputClass =
16  'bg-background text-xs h-8 max-h-8 border border-box rounded-0 text-foreground border-muted placeholder-muted focus:outline-none focus:ring-0 focus:border-foreground';
17
18export function Textbox({ onChange, className, ...inputProps }) {
19  return (
20    <input className={cx('px-2', inputClass, className)} onChange={(e) => onChange(e.target.value)} {...inputProps} />
21  );
22}
23
24function Checkbox({ label, value, onChange, disabled = false }) {
25  return (
26    <label className="text-xs">
27      <input
28        className={cx(
29          'bg-background text-sm border border-muted focus:outline-none focus:ring-0 focus:border-foreground',
30        )}
31        disabled={disabled}
32        type="checkbox"
33        checked={value}
34        onChange={onChange}
35      />
36      {' ' + label}
37    </label>
38  );
39}
40
41//      value: ?ID, options: Map<ID, any>, onChange: ID => null, onClick: event => void, isDisabled: boolean
42export function SelectInputDuplicate({ value, options, onChange, onClick, isDisabled }) {
43  return (
44    <select
45      disabled={isDisabled}
46      onClick={onClick}
47      className={cx('p-2', inputClass)}
48      value={value ?? ''}
49      onChange={(e) => onChange(e.target.value)}
50    >
51      {options.size == 0 && <option value={value}>{`${value ?? 'select an option'}`}</option>}
52      {Array.from(options.keys()).map((id) => (
53        <option key={id} className="bg-background" value={id}>
54          {options.get(id)}
55        </option>
56      ))}
57    </select>
58  );
59}
60
61function SelectInput({ value, options, onChange }) {
62  return (
63    <select className={cx('p-2', inputClass)} value={value} onChange={(e) => onChange(e.target.value)}>
64      {Object.entries(options).map(([k, label]) => (
65        <option key={k} className="bg-background" value={k}>
66          {label}
67        </option>
68      ))}
69    </select>
70  );
71}
72
73function NumberSlider({ value, onChange, step = 1, ...rest }) {
74  return (
75    <div className="flex space-x-2 gap-1 overflow-hidden">
76      <input
77        className="p-2 grow accent-foreground"
78        type="range"
79        value={value}
80        step={step}
81        onChange={(e) => onChange(Number(e.target.value))}
82        {...rest}
83      />
84      <input
85        type="number"
86        value={value}
87        step={step}
88        className={cx('w-16', inputClass)}
89        onChange={(e) => onChange(Number(e.target.value))}
90      />
91    </div>
92  );
93}
94
95function FormItem({ label, children, sublabel }) {
96  return (
97    <div className="grid gap-2 text-xs">
98      <label className="text-sm">{label}</label>
99      {children}
100    </div>
101  );
102}
103
104const themeOptions = Object.fromEntries(Object.keys(themes).map((k) => [k, k]));
105const fontFamilyOptions = {
106  monospace: 'monospace',
107  Courier: 'Courier',
108  CutiePi: 'CutiePi',
109  JetBrains: 'JetBrains',
110  Hack: 'Hack',
111  FiraCode: 'FiraCode',
112  'FiraCode-SemiBold': 'FiraCode SemiBold',
113  teletext: 'teletext',
114  tic80: 'tic80',
115  mode7: 'mode7',
116  BigBlueTerminal: 'BigBlueTerminal',
117  x3270: 'x3270',
118  Monocraft: 'Monocraft',
119  PressStart: 'PressStart2P',
120  'we-come-in-peace': 'we-come-in-peace',
121  galactico: 'galactico',
122};
123
124function MainSettingsContent({ started }) {
125  const {
126    theme,
127    keybindings,
128    isBracketClosingEnabled,
129    isBracketMatchingEnabled,
130    isLineNumbersDisplayed,
131    isPatternHighlightingEnabled,
132    isActiveLineHighlighted,
133    isAutoCompletionEnabled,
134    isTooltipEnabled,
135    isFlashEnabled,
136    isButtonRowHidden,
137    isCSSAnimationDisabled,
138    isSyncEnabled,
139    isLineWrappingEnabled,
140    isMinimapEnabled,
141    fontSize,
142    fontFamily,
143    panelPosition,
144    audioDeviceName,
145    audioEngineTarget,
146    maxPolyphony,
147    multiChannelOrbits,
148    isTabIndentationEnabled,
149    isMultiCursorEnabled,
150    patternAutoStart,
151    isBlockBasedEvalEnabled,
152  } = useSettings();
153  const shouldAlwaysSync = isUdels();
154  const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
155  return (
156    <div className="p-4 text-foreground space-y-4 w-full overflow-auto" style={{ fontFamily }}>
157      {canChangeAudioDevice && (
158        <FormItem label="Audio Output Device">
159          <AudioDeviceSelector
160            isDisabled={started}
161            audioDeviceName={audioDeviceName}
162            onChange={(audioDeviceName) => {
163              confirmAndReloadPage(() => {
164                settingsMap.setKey('audioDeviceName', audioDeviceName);
165              });
166            }}
167          />
168        </FormItem>
169      )}
170      <FormItem label="Audio Engine Target">
171        <AudioEngineTargetSelector
172          target={audioEngineTarget}
173          onChange={(target) => {
174            confirmAndReloadPage(() => {
175              settingsMap.setKey('audioEngineTarget', target);
176            });
177          }}
178        />
179      </FormItem>
180
181      <FormItem label="Maximum Polyphony">
182        <Textbox
183          min={1}
184          max={Infinity}
185          onBlur={(e) => {
186            let v = parseInt(e.target.value);
187            v = isNaN(v) ? DEFAULT_MAX_POLYPHONY : v;
188            setMaxPolyphony(v);
189            settingsMap.setKey('maxPolyphony', v);
190          }}
191          onChange={(v) => {
192            v = Math.max(1, parseInt(v));
193            settingsMap.setKey('maxPolyphony', isNaN(v) ? undefined : v);
194          }}
195          type="number"
196          placeholder=""
197          value={maxPolyphony ?? ''}
198        />
199      </FormItem>
200      <FormItem>
201        <Checkbox
202          label="Multi Channel Orbits"
203          onChange={(cbEvent) => {
204            const val = cbEvent.target.checked;
205            confirmAndReloadPage(() => {
206              settingsMap.setKey('multiChannelOrbits', val);
207              setMultiChannelOrbits(val);
208            });
209          }}
210          value={multiChannelOrbits}
211        />
212      </FormItem>
213      <FormItem label="Theme">
214        <SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} />
215      </FormItem>
216      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
217        <FormItem label="Font Family">
218          <SelectInput
219            options={fontFamilyOptions}
220            value={fontFamily}
221            onChange={(fontFamily) => settingsMap.setKey('fontFamily', fontFamily)}
222          />
223        </FormItem>
224        <FormItem label="Font Size">
225          <NumberSlider
226            value={fontSize}
227            onChange={(fontSize) => settingsMap.setKey('fontSize', fontSize)}
228            min={10}
229            max={40}
230            step={2}
231          />
232        </FormItem>
233      </div>
234
235      <FormItem label="Keybindings">
236        <ButtonGroup
237          value={keybindings}
238          onChange={(keybindings) => settingsMap.setKey('keybindings', keybindings)}
239          items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', helix: 'Helix', vscode: 'VSCode' }}
240        ></ButtonGroup>
241      </FormItem>
242      <FormItem label="Panel Position">
243        <ButtonGroup
244          value={panelPosition}
245          onChange={(value) => settingsMap.setKey('panelPosition', value)}
246          items={{ bottom: 'Bottom', right: 'Right' }}
247        ></ButtonGroup>
248      </FormItem>
249      <FormItem label="More Settings">
250        <Checkbox
251          label="Enable bracket matching"
252          onChange={(cbEvent) => settingsMap.setKey('isBracketMatchingEnabled', cbEvent.target.checked)}
253          value={isBracketMatchingEnabled}
254        />
255        <Checkbox
256          label="Auto close brackets"
257          onChange={(cbEvent) => settingsMap.setKey('isBracketClosingEnabled', cbEvent.target.checked)}
258          value={isBracketClosingEnabled}
259        />
260        <Checkbox
261          label="Display line numbers"
262          onChange={(cbEvent) => settingsMap.setKey('isLineNumbersDisplayed', cbEvent.target.checked)}
263          value={isLineNumbersDisplayed}
264        />
265        <Checkbox
266          label="Highlight active line"
267          onChange={(cbEvent) => settingsMap.setKey('isActiveLineHighlighted', cbEvent.target.checked)}
268          value={isActiveLineHighlighted}
269        />
270        <Checkbox
271          label="Highlight events in code"
272          onChange={(cbEvent) => settingsMap.setKey('isPatternHighlightingEnabled', cbEvent.target.checked)}
273          value={isPatternHighlightingEnabled}
274        />
275        <Checkbox
276          label="Enable auto-completion"
277          onChange={(cbEvent) => settingsMap.setKey('isAutoCompletionEnabled', cbEvent.target.checked)}
278          value={isAutoCompletionEnabled}
279        />
280        <Checkbox
281          label="Enable tooltips on Ctrl and hover"
282          onChange={(cbEvent) => settingsMap.setKey('isTooltipEnabled', cbEvent.target.checked)}
283          value={isTooltipEnabled}
284        />
285        <Checkbox
286          label="Show the minimap (the playing lines light up on it)"
287          onChange={(cbEvent) => settingsMap.setKey('isMinimapEnabled', cbEvent.target.checked)}
288          value={isMinimapEnabled}
289        />
290        <Checkbox
291          label="Enable line wrapping"
292          onChange={(cbEvent) => settingsMap.setKey('isLineWrappingEnabled', cbEvent.target.checked)}
293          value={isLineWrappingEnabled}
294        />
295        <Checkbox
296          label="Enable Tab indentation"
297          onChange={(cbEvent) => settingsMap.setKey('isTabIndentationEnabled', cbEvent.target.checked)}
298          value={isTabIndentationEnabled}
299        />
300        <Checkbox
301          label="Enable Multi-Cursor (Cmd/Ctrl+Click)"
302          onChange={(cbEvent) => settingsMap.setKey('isMultiCursorEnabled', cbEvent.target.checked)}
303          value={isMultiCursorEnabled}
304        />
305        <Checkbox
306          label="Enable Block-based Evaluation (EXPERIMENTAL)"
307          onChange={(cbEvent) => settingsMap.setKey('isBlockBasedEvalEnabled', cbEvent.target.checked)}
308          value={isBlockBasedEvalEnabled}
309        />
310        <Checkbox
311          label="Enable flashing on evaluation"
312          onChange={(cbEvent) => settingsMap.setKey('isFlashEnabled', cbEvent.target.checked)}
313          value={isFlashEnabled}
314        />
315        <Checkbox
316          label="Sync across Browser Tabs / Windows"
317          onChange={(cbEvent) => {
318            const newVal = cbEvent.target.checked;
319            confirmAndReloadPage(() => {
320              settingsMap.setKey('isSyncEnabled', newVal);
321            });
322          }}
323          disabled={shouldAlwaysSync}
324          value={isSyncEnabled}
325        />
326        <Checkbox
327          label="Hide action buttons"
328          onChange={(cbEvent) => settingsMap.setKey('isButtonRowHidden', cbEvent.target.checked)}
329          value={isButtonRowHidden}
330        />
331        <Checkbox
332          label="Disable CSS Animations"
333          onChange={(cbEvent) => settingsMap.setKey('isCSSAnimationDisabled', cbEvent.target.checked)}
334          value={isCSSAnimationDisabled}
335        />
336        <Checkbox
337          label="Auto-start pattern on pattern change"
338          onChange={(cbEvent) => settingsMap.setKey('patternAutoStart', cbEvent.target.checked)}
339          value={patternAutoStart}
340        />
341      </FormItem>
342      <FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem>
343      <FormItem label="Reset Settings">
344        <ActionButton
345          onClick={() => {
346            confirmDialog('Sure?').then((r) => {
347              if (r) {
348                const { userPatterns } = settingsMap.get(); // keep current patterns
349                settingsMap.set({ ...defaultSettings, userPatterns });
350              }
351            });
352          }}
353          className="bg-background p-2 max-w-[300px] hover:opacity-50"
354        >
355          restore default settings
356        </ActionButton>
357      </FormItem>
358    </div>
359  );
360}
361
362function PrebakeSettingsContent() {
363  const { fontFamily, includePrebakeScriptInShare, prebakeScript } = useSettings();
364  const editorRef = useRef();
365  useEffect(() => {
366    return () => {
367      editorRef.current?.cleanup();
368    };
369  });
370
371  return (
372    <div className="flex flex-col h-full text-foreground w-full overflow-auto" style={{ fontFamily }}>
373      <div className="flex flex-col grow overflow-hidden h-full bg-background">
374        <section
375          className="pb-0 overflow-auto grow z-10 code-container"
376          ref={(el) => {
377            if (editorRef.current) {
378              return;
379            }
380            editorRef.current = new PrebakeCodeMirror(prebakeScript, (code) => storePrebakeScript(code), el);
381          }}
382        ></section>
383      </div>
384      <div className="flex justify-between items-center border-t border-muted px-4 whitespace-nowrap">
385        <Checkbox
386          label="share with patterns"
387          className="whitespace-nowrap max-w-[200px]"
388          onChange={(cbEvent) => settingsMap.setKey('includePrebakeScriptInShare', cbEvent.target.checked)}
389          value={includePrebakeScriptInShare}
390        />
391        <div className="py-2 flex flex-row items-center space-x-3 ">
392          <ImportPrebakeScriptButton updateEditor={(code) => editorRef?.current.setCode(code)} />
393          <ActionButton onClick={() => exportScript(prebakeScript)}>export</ActionButton>
394          <ActionButton onClick={() => editorRef.current?.savePrebake()}>save</ActionButton>
395        </div>
396      </div>
397    </div>
398  );
399}
400export function SettingsTab({ started }) {
401  const { settingsTab } = useSettings();
402  return (
403    <div className="w-full h-full text-foreground flex flex-col overflow-hidden">
404      <div className="px-2 shrink-0 h-8 space-x-4 flex max-w-full overflow-x-auto border-b border-muted">
405        <ButtonGroup
406          wrap
407          value={settingsTab}
408          onChange={(value) => setSettingsTab(value)}
409          items={{
410            settings: 'settings',
411            prebake: 'prebake',
412          }}
413        ></ButtonGroup>
414      </div>
415      {settingsTab === 'settings' && <MainSettingsContent started={started} />}
416      {settingsTab === 'prebake' && <PrebakeSettingsContent />}
417    </div>
418  );
419}