Organize misc files into theme, themes and styleTrees

This commit is contained in:
Nate Butler
2023-06-07 12:50:37 -04:00
parent 6269cec4f1
commit 29de420b59
37 changed files with 141 additions and 136 deletions
+5
View File
@@ -0,0 +1,5 @@
import chroma from "chroma-js"
export function withOpacity(color: string, opacity: number): string {
return chroma(color).alpha(opacity).hex()
}
+286
View File
@@ -0,0 +1,286 @@
import { Scale, Color } from "chroma-js"
import { Syntax, ThemeSyntax, SyntaxHighlightStyle } from "./syntax"
export { Syntax, ThemeSyntax, SyntaxHighlightStyle }
import {
ThemeConfig,
ThemeAppearance,
ThemeConfigInputColors,
} from "./themeConfig"
import { getRamps } from "./ramps"
export interface ColorScheme {
name: string
isLight: boolean
lowest: Layer
middle: Layer
highest: Layer
ramps: RampSet
popoverShadow: Shadow
modalShadow: Shadow
players: Players
syntax?: Partial<ThemeSyntax>
}
export interface Meta {
name: string
author: string
url: string
license: License
}
export interface License {
SPDX: SPDXExpression
}
// License name -> License text
export interface Licenses {
[key: string]: string
}
// FIXME: Add support for the SPDX expression syntax
export type SPDXExpression = "MIT"
export interface Player {
cursor: string
selection: string
}
export interface Players {
"0": Player
"1": Player
"2": Player
"3": Player
"4": Player
"5": Player
"6": Player
"7": Player
}
export interface Shadow {
blur: number
color: string
offset: number[]
}
export type StyleSets = keyof Layer
export interface Layer {
base: StyleSet
variant: StyleSet
on: StyleSet
accent: StyleSet
positive: StyleSet
warning: StyleSet
negative: StyleSet
}
export interface RampSet {
neutral: Scale
red: Scale
orange: Scale
yellow: Scale
green: Scale
cyan: Scale
blue: Scale
violet: Scale
magenta: Scale
}
export type Styles = keyof StyleSet
export interface StyleSet {
default: Style
active: Style
disabled: Style
hovered: Style
pressed: Style
inverted: Style
}
export interface Style {
background: string
border: string
foreground: string
}
export function createColorScheme(theme: ThemeConfig): ColorScheme {
const {
name,
appearance,
inputColor,
override: { syntax },
} = theme
const isLight = appearance === ThemeAppearance.Light
const colorRamps: ThemeConfigInputColors = inputColor
// Chromajs scales from 0 to 1 flipped if isLight is true
const ramps = getRamps(isLight, colorRamps)
const lowest = lowestLayer(ramps)
const middle = middleLayer(ramps)
const highest = highestLayer(ramps)
const popoverShadow = {
blur: 4,
color: ramps
.neutral(isLight ? 7 : 0)
.darken()
.alpha(0.2)
.hex(), // TODO used blend previously. Replace with something else
offset: [1, 2],
}
const modalShadow = {
blur: 16,
color: ramps
.neutral(isLight ? 7 : 0)
.darken()
.alpha(0.2)
.hex(), // TODO used blend previously. Replace with something else
offset: [0, 2],
}
const players = {
"0": player(ramps.blue),
"1": player(ramps.green),
"2": player(ramps.magenta),
"3": player(ramps.orange),
"4": player(ramps.violet),
"5": player(ramps.cyan),
"6": player(ramps.red),
"7": player(ramps.yellow),
}
return {
name,
isLight,
ramps,
lowest,
middle,
highest,
popoverShadow,
modalShadow,
players,
syntax,
}
}
function player(ramp: Scale): Player {
return {
selection: ramp(0.5).alpha(0.24).hex(),
cursor: ramp(0.5).hex(),
}
}
function lowestLayer(ramps: RampSet): Layer {
return {
base: buildStyleSet(ramps.neutral, 0.2, 1),
variant: buildStyleSet(ramps.neutral, 0.2, 0.7),
on: buildStyleSet(ramps.neutral, 0.1, 1),
accent: buildStyleSet(ramps.blue, 0.1, 0.5),
positive: buildStyleSet(ramps.green, 0.1, 0.5),
warning: buildStyleSet(ramps.yellow, 0.1, 0.5),
negative: buildStyleSet(ramps.red, 0.1, 0.5),
}
}
function middleLayer(ramps: RampSet): Layer {
return {
base: buildStyleSet(ramps.neutral, 0.1, 1),
variant: buildStyleSet(ramps.neutral, 0.1, 0.7),
on: buildStyleSet(ramps.neutral, 0, 1),
accent: buildStyleSet(ramps.blue, 0.1, 0.5),
positive: buildStyleSet(ramps.green, 0.1, 0.5),
warning: buildStyleSet(ramps.yellow, 0.1, 0.5),
negative: buildStyleSet(ramps.red, 0.1, 0.5),
}
}
function highestLayer(ramps: RampSet): Layer {
return {
base: buildStyleSet(ramps.neutral, 0, 1),
variant: buildStyleSet(ramps.neutral, 0, 0.7),
on: buildStyleSet(ramps.neutral, 0.1, 1),
accent: buildStyleSet(ramps.blue, 0.1, 0.5),
positive: buildStyleSet(ramps.green, 0.1, 0.5),
warning: buildStyleSet(ramps.yellow, 0.1, 0.5),
negative: buildStyleSet(ramps.red, 0.1, 0.5),
}
}
function buildStyleSet(
ramp: Scale,
backgroundBase: number,
foregroundBase: number,
step: number = 0.08
): StyleSet {
let styleDefinitions = buildStyleDefinition(
backgroundBase,
foregroundBase,
step
)
function colorString(indexOrColor: number | Color): string {
if (typeof indexOrColor === "number") {
return ramp(indexOrColor).hex()
} else {
return indexOrColor.hex()
}
}
function buildStyle(style: Styles): Style {
return {
background: colorString(styleDefinitions.background[style]),
border: colorString(styleDefinitions.border[style]),
foreground: colorString(styleDefinitions.foreground[style]),
}
}
return {
default: buildStyle("default"),
hovered: buildStyle("hovered"),
pressed: buildStyle("pressed"),
active: buildStyle("active"),
disabled: buildStyle("disabled"),
inverted: buildStyle("inverted"),
}
}
function buildStyleDefinition(
bgBase: number,
fgBase: number,
step: number = 0.08
) {
return {
background: {
default: bgBase,
hovered: bgBase + step,
pressed: bgBase + step * 1.5,
active: bgBase + step * 2.2,
disabled: bgBase,
inverted: fgBase + step * 6,
},
border: {
default: bgBase + step * 1,
hovered: bgBase + step,
pressed: bgBase + step,
active: bgBase + step * 3,
disabled: bgBase + step * 0.5,
inverted: bgBase - step * 3,
},
foreground: {
default: fgBase,
hovered: fgBase,
pressed: fgBase,
active: fgBase + step * 6,
disabled: bgBase + step * 4,
inverted: bgBase + step * 2,
},
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./colorScheme"
export * from "./ramps"
export * from "./syntax"
export * from "./themeConfig"
+47
View File
@@ -0,0 +1,47 @@
import chroma, { Color, Scale } from "chroma-js"
import { RampSet } from "./colorScheme"
import {
ThemeConfigInputColors,
ThemeConfigInputColorsKeys,
} from "./themeConfig"
export function colorRamp(color: Color): Scale {
let endColor = color.desaturate(1).brighten(5)
let startColor = color.desaturate(1).darken(4)
return chroma.scale([startColor, color, endColor]).mode("lab")
}
/**
* Chromajs mutates the underlying ramp when you call domain. This causes problems because
we now store the ramps object in the theme so that we can pull colors out of them.
So instead of calling domain and storing the result, we have to construct new ramps for each
theme so that we don't modify the passed in ramps.
This combined with an error in the type definitions for chroma js means we have to cast the colors
function to any in order to get the colors back out from the original ramps.
* @param isLight
* @param colorRamps
* @returns
*/
export function getRamps(
isLight: boolean,
colorRamps: ThemeConfigInputColors
): RampSet {
const ramps: RampSet = {} as any
const colorsKeys = Object.keys(colorRamps) as ThemeConfigInputColorsKeys[]
if (isLight) {
for (const rampName of colorsKeys) {
ramps[rampName] = chroma.scale(
colorRamps[rampName].colors(100).reverse()
)
}
ramps.neutral = chroma.scale(colorRamps.neutral.colors(100).reverse())
} else {
for (const rampName of colorsKeys) {
ramps[rampName] = chroma.scale(colorRamps[rampName].colors(100))
}
ramps.neutral = chroma.scale(colorRamps.neutral.colors(100))
}
return ramps
}
+319
View File
@@ -0,0 +1,319 @@
import deepmerge from "deepmerge"
import { FontWeight, fontWeights } from "../common"
import { ColorScheme } from "./colorScheme"
import chroma from "chroma-js"
export interface SyntaxHighlightStyle {
color?: string
weight?: FontWeight
underline?: boolean
italic?: boolean
}
export interface Syntax {
// == Text Styles ====== /
comment: SyntaxHighlightStyle
// elixir: doc comment
"comment.doc": SyntaxHighlightStyle
primary: SyntaxHighlightStyle
predictive: SyntaxHighlightStyle
// === Formatted Text ====== /
emphasis: SyntaxHighlightStyle
"emphasis.strong": SyntaxHighlightStyle
title: SyntaxHighlightStyle
linkUri: SyntaxHighlightStyle
linkText: SyntaxHighlightStyle
/** md: indented_code_block, fenced_code_block, code_span */
"text.literal": SyntaxHighlightStyle
// == Punctuation ====== /
punctuation: SyntaxHighlightStyle
/** Example: `(`, `[`, `{`...*/
"punctuation.bracket": SyntaxHighlightStyle
/**., ;*/
"punctuation.delimiter": SyntaxHighlightStyle
// js, ts: ${, } in a template literal
// yaml: *, &, ---, ...
"punctuation.special": SyntaxHighlightStyle
// md: list_marker_plus, list_marker_dot, etc
"punctuation.list_marker": SyntaxHighlightStyle
// == Strings ====== /
string: SyntaxHighlightStyle
// css: color_value
// js: this, super
// toml: offset_date_time, local_date_time...
"string.special": SyntaxHighlightStyle
// elixir: atom, quoted_atom, keyword, quoted_keyword
// ruby: simple_symbol, delimited_symbol...
"string.special.symbol"?: SyntaxHighlightStyle
// elixir, python, yaml...: escape_sequence
"string.escape"?: SyntaxHighlightStyle
// Regular expressions
"string.regex"?: SyntaxHighlightStyle
// == Types ====== /
// We allow Function here because all JS objects literals have this property
constructor: SyntaxHighlightStyle | Function
variant: SyntaxHighlightStyle
type: SyntaxHighlightStyle
// js: predefined_type
"type.builtin"?: SyntaxHighlightStyle
// == Values
variable: SyntaxHighlightStyle
// this, ...
// css: -- (var(--foo))
// lua: self
"variable.special"?: SyntaxHighlightStyle
// c: statement_identifier,
label: SyntaxHighlightStyle
// css: tag_name, nesting_selector, universal_selector...
tag: SyntaxHighlightStyle
// css: attribute, pseudo_element_selector (tag_name),
attribute: SyntaxHighlightStyle
// css: class_name, property_name, namespace_name...
property: SyntaxHighlightStyle
// true, false, null, nullptr
constant: SyntaxHighlightStyle
// css: @media, @import, @supports...
// js: declare, implements, interface, keyof, public...
keyword: SyntaxHighlightStyle
// note: js enum is currently defined as a keyword
enum: SyntaxHighlightStyle
// -, --, ->, !=, &&, ||, <=...
operator: SyntaxHighlightStyle
number: SyntaxHighlightStyle
boolean: SyntaxHighlightStyle
// elixir: __MODULE__, __DIR__, __ENV__, etc
// go: nil, iota
"constant.builtin"?: SyntaxHighlightStyle
// == Functions ====== /
function: SyntaxHighlightStyle
// lua: assert, error, loadfile, tostring, unpack...
"function.builtin"?: SyntaxHighlightStyle
// go: call_expression, method_declaration
// js: call_expression, method_definition, pair (key, arrow function)
// rust: function_item name: (identifier)
"function.definition"?: SyntaxHighlightStyle
// rust: macro_definition name: (identifier)
"function.special.definition"?: SyntaxHighlightStyle
"function.method"?: SyntaxHighlightStyle
// ruby: identifier/"defined?" // Nate note: I don't fully understand this one.
"function.method.builtin"?: SyntaxHighlightStyle
// == Unsorted ====== /
// lua: hash_bang_line
preproc: SyntaxHighlightStyle
// elixir, python: interpolation (ex: foo in ${foo})
// js: template_substitution
embedded: SyntaxHighlightStyle
}
export type ThemeSyntax = Partial<Syntax>
const defaultSyntaxHighlightStyle: Omit<SyntaxHighlightStyle, "color"> = {
weight: "normal",
underline: false,
italic: false,
}
function buildDefaultSyntax(colorScheme: ColorScheme): Syntax {
// Make a temporary object that is allowed to be missing
// the "color" property for each style
const syntax: {
[key: string]: Omit<SyntaxHighlightStyle, "color">
} = {}
const light = colorScheme.isLight
// then spread the default to each style
for (const key of Object.keys({} as Syntax)) {
syntax[key as keyof Syntax] = {
...defaultSyntaxHighlightStyle,
}
}
// Mix the neutral and blue colors to get a
// predictive color distinct from any other color in the theme
const predictive = chroma
.mix(
colorScheme.ramps.neutral(0.4).hex(),
colorScheme.ramps.blue(0.4).hex(),
0.45,
"lch"
)
.hex()
const color = {
primary: colorScheme.ramps.neutral(1).hex(),
comment: colorScheme.ramps.neutral(0.71).hex(),
punctuation: colorScheme.ramps.neutral(0.86).hex(),
predictive: predictive,
emphasis: colorScheme.ramps.blue(0.5).hex(),
string: colorScheme.ramps.orange(0.5).hex(),
function: colorScheme.ramps.yellow(0.5).hex(),
type: colorScheme.ramps.cyan(0.5).hex(),
constructor: colorScheme.ramps.blue(0.5).hex(),
variant: colorScheme.ramps.blue(0.5).hex(),
property: colorScheme.ramps.blue(0.5).hex(),
enum: colorScheme.ramps.orange(0.5).hex(),
operator: colorScheme.ramps.orange(0.5).hex(),
number: colorScheme.ramps.green(0.5).hex(),
boolean: colorScheme.ramps.green(0.5).hex(),
constant: colorScheme.ramps.green(0.5).hex(),
keyword: colorScheme.ramps.blue(0.5).hex(),
}
// Then assign colors and use Syntax to enforce each style getting it's own color
const defaultSyntax: Syntax = {
...syntax,
comment: {
color: color.comment,
},
"comment.doc": {
color: color.comment,
},
primary: {
color: color.primary,
},
predictive: {
color: color.predictive,
italic: true,
},
emphasis: {
color: color.emphasis,
},
"emphasis.strong": {
color: color.emphasis,
weight: fontWeights.bold,
},
title: {
color: color.primary,
weight: fontWeights.bold,
},
linkUri: {
color: colorScheme.ramps.green(0.5).hex(),
underline: true,
},
linkText: {
color: colorScheme.ramps.orange(0.5).hex(),
italic: true,
},
"text.literal": {
color: color.string,
},
punctuation: {
color: color.punctuation,
},
"punctuation.bracket": {
color: color.punctuation,
},
"punctuation.delimiter": {
color: color.punctuation,
},
"punctuation.special": {
color: colorScheme.ramps.neutral(0.86).hex(),
},
"punctuation.list_marker": {
color: color.punctuation,
},
string: {
color: color.string,
},
"string.special": {
color: color.string,
},
"string.special.symbol": {
color: color.string,
},
"string.escape": {
color: color.comment,
},
"string.regex": {
color: color.string,
},
constructor: {
color: colorScheme.ramps.blue(0.5).hex(),
},
variant: {
color: colorScheme.ramps.blue(0.5).hex(),
},
type: {
color: color.type,
},
variable: {
color: color.primary,
},
label: {
color: colorScheme.ramps.blue(0.5).hex(),
},
tag: {
color: colorScheme.ramps.blue(0.5).hex(),
},
attribute: {
color: colorScheme.ramps.blue(0.5).hex(),
},
property: {
color: colorScheme.ramps.blue(0.5).hex(),
},
constant: {
color: color.constant,
},
keyword: {
color: color.keyword,
},
enum: {
color: color.enum,
},
operator: {
color: color.operator,
},
number: {
color: color.number,
},
boolean: {
color: color.boolean,
},
function: {
color: color.function,
},
preproc: {
color: color.primary,
},
embedded: {
color: color.primary,
},
}
return defaultSyntax
}
function mergeSyntax(defaultSyntax: Syntax, colorScheme: ColorScheme): Syntax {
if (!colorScheme.syntax) {
return defaultSyntax
}
return deepmerge<Syntax, Partial<ThemeSyntax>>(
defaultSyntax,
colorScheme.syntax,
{
arrayMerge: (destinationArray, sourceArray) => [
...destinationArray,
...sourceArray,
],
}
)
}
export function buildSyntax(colorScheme: ColorScheme): Syntax {
const defaultSyntax: Syntax = buildDefaultSyntax(colorScheme)
const syntax = mergeSyntax(defaultSyntax, colorScheme)
return syntax
}
+143
View File
@@ -0,0 +1,143 @@
import { Scale, Color } from "chroma-js"
import { Syntax } from "./syntax"
interface ThemeMeta {
/** The name of the theme */
name: string
/** The theme's appearance. Either `light` or `dark`. */
appearance: ThemeAppearance
/** The author of the theme
*
* Ideally formatted as `Full Name <email>`
*
* Example: `John Doe <john@doe.com>`
*/
author: string
/** SPDX License string
*
* Example: `MIT`
*/
licenseType?: string | ThemeLicenseType
licenseUrl?: string
licenseFile: string
themeUrl?: string
}
export interface ThemeConfigInputColors {
neutral: Scale<Color>
red: Scale<Color>
orange: Scale<Color>
yellow: Scale<Color>
green: Scale<Color>
cyan: Scale<Color>
blue: Scale<Color>
violet: Scale<Color>
magenta: Scale<Color>
}
export type ThemeConfigInputColorsKeys = keyof ThemeConfigInputColors
/** Allow any part of a syntax highlight style to be overriden by the theme
*
* Example:
* ```ts
* override: {
* syntax: {
* boolean: {
* underline: true,
* },
* },
* }
* ```
*/
export type ThemeConfigInputSyntax = Partial<Syntax>
interface ThemeConfigOverrides {
syntax: ThemeConfigInputSyntax
}
type ThemeConfigProperties = ThemeMeta & {
inputColor: ThemeConfigInputColors
override: ThemeConfigOverrides
}
// This should be the format a theme is defined as
export type ThemeConfig = {
[K in keyof ThemeConfigProperties]: ThemeConfigProperties[K]
}
interface ThemeColors {
neutral: string[]
red: string[]
orange: string[]
yellow: string[]
green: string[]
cyan: string[]
blue: string[]
violet: string[]
magenta: string[]
}
type ThemeSyntax = Required<Syntax>
export type ThemeProperties = ThemeMeta & {
color: ThemeColors
syntax: ThemeSyntax
}
// This should be a theme after all its properties have been resolved
export type Theme = {
[K in keyof ThemeProperties]: ThemeProperties[K]
}
export enum ThemeAppearance {
Light = "light",
Dark = "dark",
}
export enum ThemeLicenseType {
MIT = "MIT",
Apache2 = "Apache License 2.0",
}
export type ThemeFamilyItem =
| ThemeConfig
| { light: ThemeConfig; dark: ThemeConfig }
type ThemeFamilyProperties = Partial<Omit<ThemeMeta, "name" | "appearance">> & {
name: string
default: ThemeFamilyItem
variants: {
[key: string]: ThemeFamilyItem
}
}
// Idea: A theme family is a collection of themes that share the same name
// For example, a theme family could be `One Dark` and have a `light` and `dark` variant
// The Ayu family could have `light`, `mirage`, and `dark` variants
type ThemeFamily = {
[K in keyof ThemeFamilyProperties]: ThemeFamilyProperties[K]
}
/** The collection of all themes
*
* Example:
* ```ts
* {
* one_dark,
* one_light,
* ayu: {
* name: 'Ayu',
* default: 'ayu_mirage',
* variants: {
* light: 'ayu_light',
* mirage: 'ayu_mirage',
* dark: 'ayu_dark',
* },
* },
* ...
* }
* ```
*/
export type ThemeIndex = Record<string, ThemeFamily | ThemeConfig>