blob: c5dd6f33da891a12b9d46ea9e61004c6fb6976e3 [file] [log] [blame] [raw]
// 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 * as local from './local.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 '../lib/common-utils.js';
import {assert, unwrapString} from './assert.js';
import {LanguageKey} from '../types/languages.interfaces.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;
enableCommunityAds: boolean;
enableCtrlS: string;
enableSharingPopover: boolean;
enableCtrlStree: boolean;
editorsFFont: string;
editorsFLigatures: boolean;
executorCompileOnChange: 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($(`<option value="${e.label}">${e.desc}</option>`));
}
}
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();
this.setSettings(this.settings);
this.handleThemes();
}
public static getStoredSettings(): SiteSettings {
return JSON.parse(local.get('settings', '{}'));
}
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<T extends BaseSetting>(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],
['.compileOnChange', 'compileOnChange', true],
['.editorsFLigatures', 'editorsFLigatures', false],
['.enableCodeLens', 'enableCodeLens', true],
['.enableCommunityAds', 'enableCommunityAds', true],
['.enableCtrlStree', 'enableCtrlStree', true],
['.enableSharingPopover', 'enableSharingPopover', true],
['.executorCompileOnChange', 'executorCompileOnChange', 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 = <Name extends keyof SiteSettings>(
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};
});
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<ColourScheme>(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, newTheme?: AppTheme) {
colourSchemeSelect.empty();
if (newTheme === 'system') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
newTheme = themes.dark.id;
} else {
newTheme = themes.default.id;
}
}
for (const scheme of colour.schemes) {
if (this.isSchemeUsable(scheme, newTheme)) {
colourSchemeSelect.append($(`<option value="${scheme.name}">${scheme.desc}</option>`));
}
}
}
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');
}
}