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}