// Copyright (c) 2022, Compiler Explorer Authors // All rights reserved. // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: // // * Redistributions of source code must retain the above copyright notice, // this list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above copyright // notice, this list of conditions and the following disclaimer in the // documentation and/or other materials provided with the distribution. // // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE // ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE // LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR // CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF // SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS // INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN // CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE // POSSIBILITY OF SUCH DAMAGE. import $ from 'jquery'; import {options} from './options.js'; import * as colour from './colour.js'; import {themes, Themes} from './themes.js'; import {AppTheme, ColourScheme, ColourSchemeInfo} from './colour.js'; import {Hub} from './hub.js'; import {EventHub} from './event-hub.js'; import {keys, isString} from '../shared/common-utils.js'; import {assert, unwrapString} from './assert.js'; import {LanguageKey} from '../types/languages.interfaces.js'; import {localStorage} from './local.js'; export type FormatBase = 'Google' | 'LLVM' | 'Mozilla' | 'Chromium' | 'WebKit' | 'Microsoft' | 'GNU'; export interface SiteSettings { autoCloseBrackets: boolean; autoCloseQuotes: boolean; autoSurround: boolean; autoIndent: boolean; allowStoreCodeDebug: boolean; alwaysEnableAllSchemes: boolean; colouriseAsm: boolean; colourScheme: ColourScheme; compileOnChange: boolean; defaultLanguage?: LanguageKey; delayAfterChange: number; enableCodeLens: boolean; colouriseBrackets: boolean; enableCommunityAds: boolean; enableCtrlS: string; enableSharingPopover: boolean; enableCtrlStree: boolean; editorsFFont: string; editorsFLigatures: boolean; executorCompileOnChange: boolean; shakeStatusIconOnWarnings: boolean; defaultFontScale?: number; // the font scale widget can check this setting before the default has been populated formatBase: FormatBase; formatOnCompile: boolean; hoverShowAsmDoc: boolean; hoverShowSource: boolean; indefiniteLineHighlight: boolean; keepMultipleTabs: boolean; keepSourcesOnLangChange: boolean; newEditorLastLang: boolean; showMinimap: boolean; showQuickSuggestions: boolean; tabWidth: number; theme: Themes | undefined; useCustomContextMenu: boolean; useSpaces: boolean; useVim: boolean; wordWrap: boolean; } class BaseSetting { constructor( public elem: JQuery, public name: string, ) {} // Can be undefined if the element doesn't exist which is the case in embed mode protected val(): string | number | string[] | undefined { return this.elem.val(); } getUi(): any { return this.val(); } putUi(value: any): void { this.elem.val(value); } } class Checkbox extends BaseSetting { override getUi(): boolean { return !!this.elem.prop('checked'); } override putUi(value: any) { this.elem.prop('checked', !!value); } } class Select extends BaseSetting { constructor(elem: JQuery, name: string, populate: {label: string; desc: string}[]) { super(elem, name); elem.empty(); for (const e of populate) { elem.append($(``)); } } override putUi(value: string | number | boolean | null) { this.elem.val(value?.toString() ?? ''); } } class NumericSelect extends Select { constructor(elem: JQuery, name: string, populate: {label: string; desc: string}[]) { super(elem, name, populate); } override getUi(): number { return Number(this.val()); } } interface SliderSettings { min: number; max: number; step: number; display: JQuery; formatter: (number) => string; } class Slider extends BaseSetting { private readonly formatter: (number) => string; private display: JQuery; private max: number; private min: number; constructor(elem: JQuery, name: string, sliderSettings: SliderSettings) { super(elem, name); this.formatter = sliderSettings.formatter; this.display = sliderSettings.display; this.max = sliderSettings.max || 100; this.min = sliderSettings.min || 1; elem.prop('max', this.max) .prop('min', this.min) .prop('step', sliderSettings.step || 1); elem.on('change', this.updateDisplay.bind(this)); } override putUi(value: number) { this.elem.val(value); this.updateDisplay(); } override getUi(): number { return parseInt(this.val()?.toString() ?? '0'); } private updateDisplay() { this.display.text(this.formatter(this.getUi())); } } class Textbox extends BaseSetting {} class Numeric extends BaseSetting { private readonly min: number; private readonly max: number; constructor(elem: JQuery, name: string, params: Record<'min' | 'max', number>) { super(elem, name); this.min = params.min; this.max = params.max; elem.attr('min', this.min).attr('max', this.max); } override getUi(): number { return this.clampValue(parseInt(this.val()?.toString() ?? '0')); } override putUi(value: number) { this.elem.val(this.clampValue(value)); } private clampValue(value: number): number { return Math.min(Math.max(value, this.min), this.max); } } export class Settings { private readonly settingsObjs: BaseSetting[]; private eventHub: EventHub; constructor( hub: Hub, private root: JQuery, private settings: SiteSettings, private onChange: (siteSettings: SiteSettings) => void, private subLangId: string | undefined, ) { this.eventHub = hub.createEventHub(); this.settings = settings; this.settingsObjs = []; this.addCheckboxes(); this.addSelectors(); this.addSliders(); this.addNumerics(); this.addTextBoxes(); // The color scheme dropdown needs to be populated otherwise the .val won't stick and it'll default this.fillColourSchemeSelector(this.root.find('.colourScheme'), this.settings.theme); this.setSettings(this.settings); this.handleThemes(); } public static getStoredSettings(): SiteSettings { return JSON.parse(localStorage.get('settings', '{}')); } public static setStoredSettings(newSettings: SiteSettings) { localStorage.set('settings', JSON.stringify(newSettings)); } public setSettings(newSettings: SiteSettings) { this.onSettingsChange(newSettings); this.onChange(newSettings); } private onUiChange() { for (const setting of this.settingsObjs) { this.settings[setting.name] = setting.getUi(); } this.onChange(this.settings); } private onSettingsChange(settings: SiteSettings) { this.settings = settings; for (const setting of this.settingsObjs) { setting.putUi(this.settings[setting.name]); } } private add(setting: T, defaultValue: any) { const key = setting.name; if (this.settings[key] === undefined) this.settings[key] = defaultValue; this.settingsObjs.push(setting); setting.elem.on('change', this.onUiChange.bind(this)); } private addCheckboxes() { // Known checkbox options in order [selector, key, defaultValue] const checkboxes: [string, keyof SiteSettings, boolean][] = [ ['.allowStoreCodeDebug', 'allowStoreCodeDebug', true], ['.alwaysEnableAllSchemes', 'alwaysEnableAllSchemes', false], ['.autoCloseBrackets', 'autoCloseBrackets', true], ['.autoCloseQuotes', 'autoCloseQuotes', true], ['.autoSurround', 'autoSurround', true], ['.autoIndent', 'autoIndent', true], ['.colourise', 'colouriseAsm', true], ['.colouriseBrackets', 'colouriseBrackets', true], ['.compileOnChange', 'compileOnChange', true], ['.editorsFLigatures', 'editorsFLigatures', false], ['.enableCodeLens', 'enableCodeLens', true], ['.enableCommunityAds', 'enableCommunityAds', true], ['.enableCtrlStree', 'enableCtrlStree', true], ['.enableSharingPopover', 'enableSharingPopover', true], ['.executorCompileOnChange', 'executorCompileOnChange', true], ['.shakeStatusIconOnWarnings', 'shakeStatusIconOnWarnings', true], ['.formatOnCompile', 'formatOnCompile', false], ['.hoverShowAsmDoc', 'hoverShowAsmDoc', true], ['.hoverShowSource', 'hoverShowSource', true], ['.indefiniteLineHighlight', 'indefiniteLineHighlight', false], ['.keepMultipleTabs', 'keepMultipleTabs', false], ['.keepSourcesOnLangChange', 'keepSourcesOnLangChange', false], ['.newEditorLastLang', 'newEditorLastLang', true], ['.showMinimap', 'showMinimap', true], ['.showQuickSuggestions', 'showQuickSuggestions', false], ['.useCustomContextMenu', 'useCustomContextMenu', true], ['.useSpaces', 'useSpaces', true], ['.useVim', 'useVim', false], ['.wordWrap', 'wordWrap', false], ]; for (const [selector, name, defaultValue] of checkboxes) { this.add(new Checkbox(this.root.find(selector), name), defaultValue); } } private addSelectors() { const addSelector = ( selector: string, name: Name, populate: {label: string; desc: string}[], defaultValue: SiteSettings[Name], component = Select, ) => { const instance = new component(this.root.find(selector), name, populate); this.add(instance, defaultValue); return instance; }; // We need theme data to populate the colour schemes; We don't add the selector until later const themesData = keys(themes).map((theme: Themes) => { return {label: themes[theme].id, desc: themes[theme].name}; }); const defaultThemeId = themes.system.id; const colourSchemesData = colour.schemes .filter(scheme => this.isSchemeUsable(scheme, defaultThemeId)) .map(scheme => ({label: scheme.name, desc: scheme.desc})); let defaultColourScheme = colour.schemes[0].name; if (window.matchMedia('(prefers-color-scheme: dark)').matches) { defaultColourScheme = 'gray-shade'; } addSelector('.colourScheme', 'colourScheme', colourSchemesData, defaultColourScheme); // Now add the theme selector addSelector('.theme', 'theme', themesData, defaultThemeId); const langs = options.languages; const defaultLanguageSelector = this.root.find('.defaultLanguage'); const defLang = this.settings.defaultLanguage || Object.keys(langs)[0] || 'c++'; const defaultLanguageData = Object.keys(langs).map(lang => { return {label: langs[lang].id, desc: langs[lang].name}; }); defaultLanguageData.sort((a, b) => a.desc.localeCompare(b.desc)); addSelector('.defaultLanguage', 'defaultLanguage', defaultLanguageData, defLang as LanguageKey); if (this.subLangId) { defaultLanguageSelector .prop('disabled', true) .prop('title', 'Default language inherited from subdomain') .css('cursor', 'not-allowed'); } const defaultFontScale = options.defaultFontScale; const fontScales: {label: string; desc: string}[] = []; for (let i = 8; i <= 30; i++) { fontScales.push({label: i.toString(), desc: i.toString()}); } const defaultFontScaleSelector = addSelector( '.defaultFontScale', 'defaultFontScale', fontScales, defaultFontScale, NumericSelect, ).elem; defaultFontScaleSelector.on('change', e => { assert(e.target instanceof HTMLSelectElement); this.eventHub.emit('broadcastFontScale', parseInt(e.target.value)); }); const formats: FormatBase[] = ['Google', 'LLVM', 'Mozilla', 'Chromium', 'WebKit', 'Microsoft', 'GNU']; const formatsData = formats.map(format => { return {label: format, desc: format}; }); addSelector('.formatBase', 'formatBase', formatsData, formats[0]); const enableCtrlSData = [ {label: 'true', desc: 'Save To Local File'}, {label: 'false', desc: 'Create Short Link'}, {label: '2', desc: 'Reformat code'}, {label: '3', desc: 'Do nothing'}, ]; addSelector('.enableCtrlS', 'enableCtrlS', enableCtrlSData, 'true'); } private addSliders() { // Handle older settings if (this.settings.delayAfterChange === 0) { this.settings.delayAfterChange = 750; this.settings.compileOnChange = false; } const delayAfterChangeSettings: SliderSettings = { max: 3000, step: 250, min: 250, display: this.root.find('.delay-current-value'), formatter: x => (x / 1000.0).toFixed(2) + 's', }; this.add(new Slider(this.root.find('.delay'), 'delayAfterChange', delayAfterChangeSettings), 750); } private addNumerics() { this.add( new Numeric(this.root.find('.tabWidth'), 'tabWidth', { min: 1, max: 80, }), 4, ); } private addTextBoxes() { this.add( new Textbox(this.root.find('.editorsFFont'), 'editorsFFont'), 'Consolas, "Liberation Mono", Courier, monospace', ); } private handleThemes() { const themeSelect = this.root.find('.theme'); themeSelect.on('change', () => { this.onThemeChange(); $.data(themeSelect, 'last-theme', unwrapString(themeSelect.val())); }); const colourSchemeSelect = this.root.find('.colourScheme'); colourSchemeSelect.on('change', e => { const currentTheme = this.settings.theme; $.data(themeSelect, 'theme-' + currentTheme, unwrapString(colourSchemeSelect.val())); }); const enableAllSchemesCheckbox = this.root.find('.alwaysEnableAllSchemes'); enableAllSchemesCheckbox.on('change', this.onThemeChange.bind(this)); // In embed mode themeSelect.length can be zero and thus themeSelect.val() isn't a string // TODO(jeremy-rifkin) Is last-theme ever read? Can it just be removed? $.data(themeSelect, 'last-theme', themeSelect.val() ?? ''); this.onThemeChange(); } private fillColourSchemeSelector(colourSchemeSelect: JQuery, theme?: AppTheme) { colourSchemeSelect.empty(); if (theme === 'system') { if (window.matchMedia('(prefers-color-scheme: dark)').matches) { theme = themes.dark.id; } else { theme = themes.default.id; } } for (const scheme of colour.schemes) { if (this.isSchemeUsable(scheme, theme)) { colourSchemeSelect.append($(``)); } } } private isSchemeUsable(scheme: ColourSchemeInfo, newTheme?: AppTheme): boolean { return ( this.settings.alwaysEnableAllSchemes || scheme.themes.length === 0 || (newTheme && scheme.themes.includes(newTheme)) || scheme.themes.includes('all') ); } private selectorHasOption(selector: JQuery, option: string): boolean { return selector.children(`[value=${option}]`).length > 0; } private onThemeChange() { // We can be called when: // Site is initializing (settings and dropdowns are already done) // "Make all colour schemes available" changes // Selected theme changes const themeSelect = this.root.find('.theme'); const colourSchemeSelect = this.root.find('.colourScheme'); const oldScheme = colourSchemeSelect.val() as colour.AppTheme | undefined; const newTheme = themeSelect.val() as colour.AppTheme | undefined; // Small check to make sure we aren't getting something completely unexpected, like a string[] or number assert( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition isString(oldScheme) || oldScheme === undefined || oldScheme == null, 'Unexpected value received from colourSchemeSelect.val()', ); assert( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition isString(newTheme) || newTheme === undefined || newTheme == null, 'Unexpected value received from colourSchemeSelect.val()', ); this.fillColourSchemeSelector(colourSchemeSelect, newTheme); const newThemeStoredScheme = $.data(themeSelect, 'theme-' + newTheme) as colour.AppTheme | undefined; // If nothing else, set the new scheme to the first of the available ones let newScheme = colourSchemeSelect.first().val() as colour.AppTheme | undefined; // If we have one old one stored, check if it's still valid and set it if so if (newThemeStoredScheme && this.selectorHasOption(colourSchemeSelect, newThemeStoredScheme)) { newScheme = newThemeStoredScheme; } else if (isString(oldScheme) && this.selectorHasOption(colourSchemeSelect, oldScheme)) { newScheme = oldScheme; } if (newScheme) { colourSchemeSelect.val(newScheme); } colourSchemeSelect.trigger('change'); } }