create_theme.ts

  1import chroma, { Scale, Color } from "chroma-js"
  2import { Syntax, ThemeSyntax, SyntaxHighlightStyle } from "./syntax"
  3export { Syntax, ThemeSyntax, SyntaxHighlightStyle }
  4import {
  5    ThemeConfig,
  6    ThemeAppearance,
  7    ThemeConfigInputColors,
  8} from "./theme_config"
  9import { get_ramps } from "./ramps"
 10
 11export interface Theme {
 12    name: string
 13    is_light: boolean
 14
 15    /**
 16    * App background, other elements that should sit directly on top of the background.
 17    */
 18    lowest: Layer
 19    /**
 20    * Panels, tabs, other UI surfaces that sit on top of the background.
 21    */
 22    middle: Layer
 23    /**
 24    * Editors like code buffers, conversation editors, etc.
 25    */
 26    highest: Layer
 27
 28    ramps: RampSet
 29
 30    popover_shadow: Shadow
 31    modal_shadow: Shadow
 32
 33    players: Players
 34    syntax?: Partial<ThemeSyntax>
 35    color_family: ColorFamily
 36}
 37
 38export interface Meta {
 39    name: string
 40    author: string
 41    url: string
 42    license: License
 43}
 44
 45export interface License {
 46    SPDX: SPDXExpression
 47}
 48
 49// License name -> License text
 50export interface Licenses {
 51    [key: string]: string
 52}
 53
 54// FIXME: Add support for the SPDX expression syntax
 55export type SPDXExpression = "MIT"
 56
 57export interface Player {
 58    cursor: string
 59    selection: string
 60}
 61
 62export interface Players {
 63    "0": Player
 64    "1": Player
 65    "2": Player
 66    "3": Player
 67    "4": Player
 68    "5": Player
 69    "6": Player
 70    "7": Player
 71}
 72
 73export interface ColorFamily {
 74    range: ColorFamilyRange
 75}
 76
 77export type ColorFamilyRange = Partial<{ [K in keyof RampSet]: number }>
 78
 79export interface Shadow {
 80    blur: number
 81    color: string
 82    offset: number[]
 83}
 84
 85export type StyleSets = keyof Layer
 86export interface Layer {
 87    base: StyleSet
 88    variant: StyleSet
 89    on: StyleSet
 90    accent: StyleSet
 91    positive: StyleSet
 92    warning: StyleSet
 93    negative: StyleSet
 94}
 95
 96export interface RampSet {
 97    neutral: Scale
 98    red: Scale
 99    orange: Scale
100    yellow: Scale
101    green: Scale
102    cyan: Scale
103    blue: Scale
104    violet: Scale
105    magenta: Scale
106}
107
108export type Styles = keyof StyleSet
109export interface StyleSet {
110    default: Style
111    active: Style
112    disabled: Style
113    hovered: Style
114    pressed: Style
115    inverted: Style
116}
117
118export interface Style {
119    background: string
120    border: string
121    foreground: string
122}
123
124export function create_theme(theme: ThemeConfig): Theme {
125    const {
126        name,
127        appearance,
128        input_color,
129        override: { syntax },
130    } = theme
131
132    const is_light = appearance === ThemeAppearance.Light
133    const color_ramps: ThemeConfigInputColors = input_color
134
135    // Chromajs scales from 0 to 1 flipped if is_light is true
136    const ramps = get_ramps(is_light, color_ramps)
137    const lowest = lowest_layer(ramps)
138    const middle = middle_layer(ramps)
139    const highest = highest_layer(ramps)
140
141    const popover_shadow = {
142        blur: 4,
143        color: ramps
144            .neutral(is_light ? 7 : 0)
145            .darken()
146            .alpha(0.2)
147            .hex(), // TODO used blend previously. Replace with something else
148        offset: [1, 2],
149    }
150
151    const modal_shadow = {
152        blur: 16,
153        color: ramps
154            .neutral(is_light ? 7 : 0)
155            .darken()
156            .alpha(0.2)
157            .hex(), // TODO used blend previously. Replace with something else
158        offset: [0, 2],
159    }
160
161    const players = {
162        "0": player(ramps.blue),
163        "1": player(ramps.green),
164        "2": player(ramps.magenta),
165        "3": player(ramps.orange),
166        "4": player(ramps.violet),
167        "5": player(ramps.cyan),
168        "6": player(ramps.red),
169        "7": player(ramps.yellow),
170    }
171
172    const color_family = {
173        range: build_color_family(ramps)
174    }
175
176    return {
177        name,
178        is_light,
179
180        ramps,
181
182        lowest,
183        middle,
184        highest,
185
186        popover_shadow,
187        modal_shadow,
188
189        players,
190        syntax,
191        color_family,
192    }
193}
194
195function player(ramp: Scale): Player {
196    return {
197        selection: ramp(0.5).alpha(0.24).hex(),
198        cursor: ramp(0.5).hex(),
199    }
200}
201
202function build_color_family(ramps: RampSet): ColorFamilyRange {
203    const color_family: ColorFamilyRange = {}
204
205    for (const ramp in ramps) {
206        color_family[ramp as keyof RampSet] = chroma(ramps[ramp as keyof RampSet](0.5)).luminance()
207    }
208
209    return color_family
210}
211
212function lowest_layer(ramps: RampSet): Layer {
213    return {
214        base: build_style_set(ramps.neutral, 0.2, 1),
215        variant: build_style_set(ramps.neutral, 0.2, 0.7),
216        on: build_style_set(ramps.neutral, 0.1, 1),
217        accent: build_style_set(ramps.blue, 0.1, 0.5),
218        positive: build_style_set(ramps.green, 0.1, 0.5),
219        warning: build_style_set(ramps.yellow, 0.1, 0.5),
220        negative: build_style_set(ramps.red, 0.1, 0.5),
221    }
222}
223
224function middle_layer(ramps: RampSet): Layer {
225    return {
226        base: build_style_set(ramps.neutral, 0.1, 1),
227        variant: build_style_set(ramps.neutral, 0.1, 0.7),
228        on: build_style_set(ramps.neutral, 0, 1),
229        accent: build_style_set(ramps.blue, 0.1, 0.5),
230        positive: build_style_set(ramps.green, 0.1, 0.5),
231        warning: build_style_set(ramps.yellow, 0.1, 0.5),
232        negative: build_style_set(ramps.red, 0.1, 0.5),
233    }
234}
235
236function highest_layer(ramps: RampSet): Layer {
237    return {
238        base: build_style_set(ramps.neutral, 0, 1),
239        variant: build_style_set(ramps.neutral, 0, 0.7),
240        on: build_style_set(ramps.neutral, 0.1, 1),
241        accent: build_style_set(ramps.blue, 0.1, 0.5),
242        positive: build_style_set(ramps.green, 0.1, 0.5),
243        warning: build_style_set(ramps.yellow, 0.1, 0.5),
244        negative: build_style_set(ramps.red, 0.1, 0.5),
245    }
246}
247
248function build_style_set(
249    ramp: Scale,
250    background_base: number,
251    foreground_base: number,
252    step = 0.08
253): StyleSet {
254    const style_definitions = build_style_definition(
255        background_base,
256        foreground_base,
257        step
258    )
259
260    function color_string(index_or_color: number | Color): string {
261        if (typeof index_or_color === "number") {
262            return ramp(index_or_color).hex()
263        } else {
264            return index_or_color.hex()
265        }
266    }
267
268    function build_style(style: Styles): Style {
269        return {
270            background: color_string(style_definitions.background[style]),
271            border: color_string(style_definitions.border[style]),
272            foreground: color_string(style_definitions.foreground[style]),
273        }
274    }
275
276    return {
277        default: build_style("default"),
278        hovered: build_style("hovered"),
279        pressed: build_style("pressed"),
280        active: build_style("active"),
281        disabled: build_style("disabled"),
282        inverted: build_style("inverted"),
283    }
284}
285
286function build_style_definition(bg_base: number, fg_base: number, step = 0.08) {
287    return {
288        background: {
289            default: bg_base,
290            hovered: bg_base + step,
291            pressed: bg_base + step * 1.5,
292            active: bg_base + step * 2.2,
293            disabled: bg_base,
294            inverted: fg_base + step * 6,
295        },
296        border: {
297            default: bg_base + step * 1,
298            hovered: bg_base + step,
299            pressed: bg_base + step,
300            active: bg_base + step * 3,
301            disabled: bg_base + step * 0.5,
302            inverted: bg_base - step * 3,
303        },
304        foreground: {
305            default: fg_base,
306            hovered: fg_base,
307            pressed: fg_base,
308            active: fg_base + step * 6,
309            disabled: bg_base + step * 4,
310            inverted: bg_base + step * 2,
311        },
312    }
313}