create_theme.ts

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