diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 3f2b9136159..04996c83c73 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -1230,6 +1230,10 @@ export function asCSSUrl(uri: URI): string { return `url('${FileAccess.asBrowserUri(uri).toString(true).replace(/'/g, '%27')}')`; } +export function asCSSPropertyValue(value: string) { + return `'${value.replace(/'/g, '%27')}'`; +} + export function triggerDownload(dataOrUri: Uint8Array | URI, name: string): void { // If the data is provided as Buffer, we create a diff --git a/src/vs/editor/standalone/browser/standaloneThemeServiceImpl.ts b/src/vs/editor/standalone/browser/standaloneThemeServiceImpl.ts index f61c9de8586..c83016ed920 100644 --- a/src/vs/editor/standalone/browser/standaloneThemeServiceImpl.ts +++ b/src/vs/editor/standalone/browser/standaloneThemeServiceImpl.ts @@ -16,7 +16,7 @@ import { ColorIdentifier, Extensions, IColorRegistry } from 'vs/platform/theme/c import { Extensions as ThemingExtensions, ICssStyleCollector, IFileIconTheme, IThemingRegistry, ITokenStyle } from 'vs/platform/theme/common/themeService'; import { IDisposable, Disposable } from 'vs/base/common/lifecycle'; import { ColorScheme } from 'vs/platform/theme/common/theme'; -import { getIconRegistry } from 'vs/platform/theme/common/iconRegistry'; +import { getIconsStyleSheet } from 'vs/platform/theme/browser/iconsStyleSheet'; const VS_THEME_NAME = 'vs'; const VS_DARK_THEME_NAME = 'vs-dark'; @@ -214,9 +214,9 @@ export class StandaloneThemeServiceImpl extends Disposable implements IStandalon this._knownThemes.set(VS_DARK_THEME_NAME, newBuiltInTheme(VS_DARK_THEME_NAME)); this._knownThemes.set(HC_BLACK_THEME_NAME, newBuiltInTheme(HC_BLACK_THEME_NAME)); - const iconRegistry = getIconRegistry(); + const iconsStyleSheet = getIconsStyleSheet(); - this._codiconCSS = iconRegistry.getCSS(); + this._codiconCSS = iconsStyleSheet.getCSS(); this._themeCSS = ''; this._allCSS = `${this._codiconCSS}\n${this._themeCSS}`; this._globalStyleElement = null; @@ -224,8 +224,8 @@ export class StandaloneThemeServiceImpl extends Disposable implements IStandalon this._colorMapOverride = null; this.setTheme(VS_THEME_NAME); - iconRegistry.onDidChange(() => { - this._codiconCSS = iconRegistry.getCSS(); + iconsStyleSheet.onDidChange(() => { + this._codiconCSS = iconsStyleSheet.getCSS(); this._updateCSS(); }); } diff --git a/src/vs/platform/theme/browser/iconsStyleSheet.ts b/src/vs/platform/theme/browser/iconsStyleSheet.ts new file mode 100644 index 00000000000..3e10a2d0053 --- /dev/null +++ b/src/vs/platform/theme/browser/iconsStyleSheet.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ThemeIcon } from 'vs/platform/theme/common/themeService'; +import { getIconRegistry, IconContribution, IconFontContribution } from 'vs/platform/theme/common/iconRegistry'; +import { asCSSPropertyValue, asCSSUrl } from 'vs/base/browser/dom'; +import { Event, Emitter } from 'vs/base/common/event'; + + +export interface IIconsStyleSheet { + getCSS(): string; + readonly onDidChange: Event; +} + +export function getIconsStyleSheet(): IIconsStyleSheet { + const onDidChangeEmmiter = new Emitter(); + const iconRegistry = getIconRegistry(); + iconRegistry.onDidChange(() => onDidChangeEmmiter.fire()); + + return { + onDidChange: onDidChangeEmmiter.event, + getCSS() { + const usedFontIds: { [id: string]: IconFontContribution } = {}; + const formatIconRule = (contribution: IconContribution): string | undefined => { + let definition = contribution.defaults; + while (ThemeIcon.isThemeIcon(definition)) { + const c = iconRegistry.getIcon(definition.id); + if (!c) { + return undefined; + } + definition = c.defaults; + } + const fontId = definition.fontId; + if (fontId) { + const fontContribution = iconRegistry.getIconFont(fontId); + if (fontContribution) { + usedFontIds[fontId] = fontContribution; + return `.codicon-${contribution.id}:before { content: '${definition.character}'; font-family: ${asCSSPropertyValue(fontId)}; }`; + } + } + return `.codicon-${contribution.id}:before { content: '${definition.character}'; }`; + }; + + const rules = []; + for (let contribution of iconRegistry.getIcons()) { + const rule = formatIconRule(contribution); + if (rule) { + rules.push(rule); + } + } + for (let id in usedFontIds) { + const fontContribution = usedFontIds[id]; + const src = fontContribution.definition.src.map(l => `${asCSSUrl(l.location)} format('${l.format}')`).join(', '); + rules.push(`@font-face { src: ${src}; font-family: ${asCSSPropertyValue(id)}; }`); + } + return rules.join('\n'); + } + }; +} diff --git a/src/vs/platform/theme/common/iconRegistry.ts b/src/vs/platform/theme/common/iconRegistry.ts index c2f5d6b8e04..15432878dc7 100644 --- a/src/vs/platform/theme/common/iconRegistry.ts +++ b/src/vs/platform/theme/common/iconRegistry.ts @@ -11,11 +11,11 @@ import { localize } from 'vs/nls'; import { Extensions as JSONExtensions, IJSONContributionRegistry } from 'vs/platform/jsonschemas/common/jsonContributionRegistry'; import { RunOnceScheduler } from 'vs/base/common/async'; import * as Codicons from 'vs/base/common/codicons'; +import { URI } from 'vs/base/common/uri'; // ------ API types - -// color registry +// icon registry export const Extensions = { IconContribution: 'base.contributions.icons' }; @@ -34,6 +34,15 @@ export interface IconContribution { defaults: IconDefaults; } +export interface IconFontContribution { + id: string; + definition: IconFontDefinition; +} + +export interface IconFontDefinition { + src: { location: URI, format: string; }[] +} + export interface IIconRegistry { readonly onDidChange: Event; @@ -42,12 +51,12 @@ export interface IIconRegistry { * Register a icon to the registry. * @param id The icon id * @param defaults The default values - * @description the description + * @param description The description */ registerIcon(id: string, defaults: IconDefaults, description?: string): ThemeIcon; /** - * Register a icon to the registry. + * Deregister a icon from the registry. */ deregisterIcon(id: string): void; @@ -62,7 +71,7 @@ export interface IIconRegistry { getIcon(id: string): IconContribution | undefined; /** - * JSON schema for an object to assign icon values to one of the color contributions. + * JSON schema for an object to assign icon values to one of the icon contributions. */ getIconSchema(): IJSONSchema; @@ -72,10 +81,26 @@ export interface IIconRegistry { getIconReferenceSchema(): IJSONSchema; /** - * The CSS for all icons + * Register a icon font to the registry. + * @param id The icon font id + * @param definition The iocn font definition */ - getCSS(): string; + registerIconFont(id: string, definition: IconFontDefinition): IconFontContribution; + /** + * Deregister an icon font to the registry. + */ + deregisterIconFont(id: string): void; + + /** + * Get all icon font contributions + */ + getIconFonts(): IconFontContribution[]; + + /** + * Get the icon font for the given id + */ + getIconFont(id: string): IconFontContribution | undefined; } class IconRegistry implements IIconRegistry { @@ -99,10 +124,13 @@ class IconRegistry implements IIconRegistry { type: 'object', properties: {} }; - private iconReferenceSchema: IJSONSchema & { enum: string[], enumDescriptions: string[] } = { type: 'string', enum: [], enumDescriptions: [] }; + private iconReferenceSchema: IJSONSchema & { enum: string[], enumDescriptions: string[] } = { type: 'string', pattern: `^${Codicons.CSSIcon.iconNameExpression}$`, enum: [], enumDescriptions: [] }; + + private iconFontsById: { [key: string]: IconFontContribution }; constructor() { this.iconsById = {}; + this.iconFontsById = {}; } public registerIcon(id: string, defaults: IconDefaults, description?: string, deprecationMessage?: string): ThemeIcon { @@ -164,27 +192,27 @@ class IconRegistry implements IIconRegistry { return this.iconReferenceSchema; } - public getCSS() { - const rules = []; - for (let id in this.iconsById) { - const rule = this.formatRule(id); - if (rule) { - rules.push(rule); - } + public registerIconFont(id: string, definition: IconFontDefinition): IconFontContribution { + const existing = this.iconFontsById[id]; + if (existing) { + return existing; } - return rules.join('\n'); + let iconFontContribution: IconFontContribution = { id, definition }; + this.iconFontsById[id] = iconFontContribution; + this._onDidChange.fire(); + return iconFontContribution; } - private formatRule(id: string): string | undefined { - let definition = this.iconsById[id].defaults; - while (ThemeIcon.isThemeIcon(definition)) { - const c = this.iconsById[definition.id]; - if (!c) { - return undefined; - } - definition = c.defaults; - } - return `.codicon-${id}:before { content: '${definition.character}'; }`; + public deregisterIconFont(id: string): void { + delete this.iconFontsById[id]; + } + + public getIconFonts(): IconFontContribution[] { + return Object.keys(this.iconFontsById).map(id => this.iconFontsById[id]); + } + + public getIconFont(id: string): IconFontContribution | undefined { + return this.iconFontsById[id]; } public toString() { diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 1e3169fd5e0..583887ec6ad 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -11,6 +11,7 @@ import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle // --- other interested parties import { JSONValidationExtensionPoint } from 'vs/workbench/api/common/jsonValidationExtensionPoint'; import { ColorExtensionPoint } from 'vs/workbench/services/themes/common/colorExtensionPoint'; +import { IconExtensionPoint, IconFontExtensionPoint } from 'vs/workbench/services/themes/common/iconExtensionPoint'; import { TokenClassificationExtensionPoints } from 'vs/workbench/services/themes/common/tokenClassificationExtensionPoint'; import { LanguageConfigurationFileHandler } from 'vs/workbench/contrib/codeEditor/browser/languageConfigurationExtensionPoint'; @@ -79,6 +80,8 @@ export class ExtensionPoints implements IWorkbenchContribution { // Classes that handle extension points... this.instantiationService.createInstance(JSONValidationExtensionPoint); this.instantiationService.createInstance(ColorExtensionPoint); + this.instantiationService.createInstance(IconExtensionPoint); + this.instantiationService.createInstance(IconFontExtensionPoint); this.instantiationService.createInstance(TokenClassificationExtensionPoints); this.instantiationService.createInstance(LanguageConfigurationFileHandler); } diff --git a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts index 65f25cd6475..5611ff53613 100644 --- a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts @@ -38,7 +38,7 @@ import { ColorScheme } from 'vs/platform/theme/common/theme'; import { IHostColorSchemeService } from 'vs/workbench/services/themes/common/hostColorSchemeService'; import { RunOnceScheduler, Sequencer } from 'vs/base/common/async'; import { IUserDataInitializationService } from 'vs/workbench/services/userData/browser/userDataInit'; -import { getIconRegistry } from 'vs/platform/theme/common/iconRegistry'; +import { getIconsStyleSheet } from 'vs/platform/theme/browser/iconsStyleSheet'; // implementation @@ -183,13 +183,13 @@ export class WorkbenchThemeService implements IWorkbenchThemeService { const codiconStyleSheet = createStyleSheet(); codiconStyleSheet.id = 'codiconStyles'; - const iconRegistry = getIconRegistry(); + const iconsStyleSheet = getIconsStyleSheet(); function updateAll() { - codiconStyleSheet.textContent = iconRegistry.getCSS(); + codiconStyleSheet.textContent = iconsStyleSheet.getCSS(); } const delayer = new RunOnceScheduler(updateAll, 0); - iconRegistry.onDidChange(() => delayer.schedule()); + iconsStyleSheet.onDidChange(() => delayer.schedule()); delayer.schedule(); } diff --git a/src/vs/workbench/services/themes/common/iconExtensionPoint.ts b/src/vs/workbench/services/themes/common/iconExtensionPoint.ts new file mode 100644 index 00000000000..20a2c39861e --- /dev/null +++ b/src/vs/workbench/services/themes/common/iconExtensionPoint.ts @@ -0,0 +1,233 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; +import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry'; +import { IIconRegistry, Extensions as IconRegistryExtensions, IconFontDefinition } from 'vs/platform/theme/common/iconRegistry'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { CSSIcon } from 'vs/base/common/codicons'; +import { fontIdRegex } from 'vs/workbench/services/themes/common/productIconThemeSchema'; +import * as resources from 'vs/base/common/resources'; +import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; + +interface IIconExtensionPoint { + id: string; + description: string; + default: { iconFontId: string; character: string; } | string; +} + +interface IIconFontExtensionPoint { + id: string; + src: { + path: string; + format: string; + }[]; +} + +const iconRegistry: IIconRegistry = Registry.as(IconRegistryExtensions.IconContribution); + +const iconReferenceSchema = iconRegistry.getIconReferenceSchema(); +const iconIdPattern = `^${CSSIcon.iconNameExpression}$`; + +const iconConfigurationExtPoint = ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'icons', + jsonSchema: { + description: nls.localize('contributes.icons', 'Contributes extension defined themable icons'), + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: nls.localize('contributes.icon.id', 'The identifier of the themable icon'), + pattern: iconIdPattern, + patternErrorMessage: nls.localize('contributes.icon.id.format', 'Identifiers must only contain letters, digits and minus.'), + }, + description: { + type: 'string', + description: nls.localize('contributes.icon.description', 'The description of the themable icon'), + }, + default: { + anyOf: [ + iconReferenceSchema, + { + type: 'object', + properties: { + iconFontId: { + description: nls.localize('contributes.default.fontId', 'The id of the icon font that defines the default icon.'), + type: 'string' + }, + character: { + description: nls.localize('contributes.default.character', 'The character in the icon font.'), + type: 'string' + } + } + } + ], + description: nls.localize('contributes.default', 'The default of the icon. Either a reference to a '), + } + } + } + } +}); + +const iconFontConfigurationExtPoint = ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'iconFonts', + jsonSchema: { + description: nls.localize('contributes.iconFonts', 'Contributes icons fonts to be used by contributed icons'), + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: nls.localize('contributes.iconFonts.id', 'The ID of the font.'), + pattern: fontIdRegex, + patternErrorMessage: nls.localize('contributes.iconFonts.id.formatError', 'The ID must only contain letters, numbers, underscore and minus.') + }, + src: { + type: 'array', + description: nls.localize('contributes.iconFonts.src', 'The location of the font.'), + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: nls.localize('contributes.iconFonts.font-path', 'The font path, relative to the current product icon theme file.'), + }, + format: { + type: 'string', + description: nls.localize('contributes.iconFonts.font-format', 'The format of the font.'), + enum: ['woff', 'woff2', 'truetype', 'opentype', 'embedded-opentype', 'svg'] + } + }, + required: [ + 'path', + 'format' + ] + } + } + } + } + } +}); + +export class IconExtensionPoint { + + constructor() { + iconConfigurationExtPoint.setHandler((extensions, delta) => { + for (const extension of delta.added) { + const extensionValue = extension.value; + const collector = extension.collector; + + if (!extension.description.enableProposedApi) { + collector.error(nls.localize('invalid.icons.proposedAPI', "'configuration.icons is a proposed constribution point and only available when running out of dev or with the following command line switch: --enable-proposed-api {0}", extension.description.identifier.value)); + return; + } + + if (!extensionValue || !Array.isArray(extensionValue)) { + collector.error(nls.localize('invalid.icons.configuration', "'configuration.icons' must be a array")); + return; + } + + for (const iconContribution of extensionValue) { + if (typeof iconContribution.id !== 'string' || iconContribution.id.length === 0) { + collector.error(nls.localize('invalid.icons.id', "'configuration.icons.id' must be defined and can not be empty")); + return; + } + if (!iconContribution.id.match(iconIdPattern)) { + collector.error(nls.localize('invalid.icons.id.format', "'configuration.icons.id' must only contain letters, digits and minuses")); + return; + } + if (typeof iconContribution.description !== 'string' || iconContribution.id.length === 0) { + collector.error(nls.localize('invalid.icons.description', "'configuration.icons.description' must be defined and can not be empty")); + return; + } + let defaultIcon = iconContribution.default; + if (typeof defaultIcon === 'string') { + iconRegistry.registerIcon(iconContribution.id, { id: defaultIcon }, iconContribution.description); + } else if (typeof defaultIcon === 'object' && typeof defaultIcon.iconFontId === 'string' && typeof defaultIcon.character === 'string') { + iconRegistry.registerIcon(iconContribution.id, { + fontId: getFontId(extension.description, defaultIcon.iconFontId), + character: defaultIcon.character, + }, iconContribution.description); + } else { + collector.error(nls.localize('invalid.icons.default', "'configuration.icons.default' must be either a reference to the id of an other theme icon (string) or a icon definition (object)")); + } + } + } + for (const extension of delta.removed) { + const extensionValue = extension.value; + for (const iconContribution of extensionValue) { + iconRegistry.deregisterIcon(iconContribution.id); + } + } + }); + } +} + +export class IconFontExtensionPoint { + + constructor() { + iconFontConfigurationExtPoint.setHandler((_extensions, delta) => { + for (const extension of delta.added) { + const extensionValue = extension.value; + const collector = extension.collector; + + if (!extension.description.enableProposedApi) { + collector.error(nls.localize('invalid.iconFonts.proposedAPI', "'configuration.iconFonts is a proposed constribution point and only available when running out of dev or with the following command line switch: --enable-proposed-api {0}", extension.description.identifier.value)); + return; + } + + if (!extensionValue || !Array.isArray(extensionValue)) { + collector.error(nls.localize('invalid.iconFonts.configuration', "'configuration.iconFonts' must be a array")); + return; + } + + for (const iconFontContribution of extensionValue) { + if (typeof iconFontContribution.id !== 'string' || iconFontContribution.id.length === 0) { + collector.error(nls.localize('invalid.iconFonts.id', "'configuration.iconFonts.id' must be defined and can not be empty")); + return; + } + if (!iconFontContribution.id.match(fontIdRegex)) { + collector.error(nls.localize('invalid.iconFonts.id.format', "'configuration.iconFonts.id' must only contain letters, numbers, underscore and minus.")); + return; + } + if (!Array.isArray(iconFontContribution.src) || !iconFontContribution.src.length) { + collector.error(nls.localize('invalid.iconFonts.src', "'configuration.iconFonts.src' must be an array with locations of the icon font.")); + return; + } + const def: IconFontDefinition = { src: [] }; + for (const src of iconFontContribution.src) { + if (typeof src === 'object' && typeof src.path === 'string' && typeof src.format === 'string') { + const extensionLocation = extension.description.extensionLocation; + const iconFontLocation = resources.joinPath(extensionLocation, src.path); + if (!resources.isEqualOrParent(iconFontLocation, extensionLocation)) { + collector.warn(nls.localize('invalid.iconFonts.src.path', "Expected `contributes.iconFonts.src.path` ({0}) to be included inside extension's folder ({0}). This might make the extension non-portable.", iconFontLocation.path, extensionLocation.path)); + } + def.src.push({ + location: iconFontLocation, + format: src.format, + }); + } else { + collector.error(nls.localize('invalid.iconFonts.src.item', "Items of 'configuration.iconFonts.src' must be objects with properties 'path' and 'format'")); + } + } + iconRegistry.registerIconFont(getFontId(extension.description, iconFontContribution.id), def); + } + } + for (const extension of delta.removed) { + const extensionValue = extension.value; + for (const iconFontContribution of extensionValue) { + iconRegistry.deregisterIconFont(getFontId(extension.description, iconFontContribution.id)); + } + } + }); + } +} + +function getFontId(description: IExtensionDescription, iconFontId: string) { + return `${description.identifier.value}/${iconFontId}`; +}