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    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 type ColorFamily = Partial<{ [K in keyof RampSet]: ColorFamilyRange }>
 74
 75export interface ColorFamilyRange {
 76    low: number
 77    high: number
 78    range: number
 79    scaling_value: number
 80}
 81
 82export interface Shadow {
 83    blur: number
 84    color: string
 85    offset: number[]
 86}
 87
 88export type StyleSets = keyof Layer
 89export interface Layer {
 90    base: StyleSet
 91    variant: StyleSet
 92    on: StyleSet
 93    accent: StyleSet
 94    positive: StyleSet
 95    warning: StyleSet
 96    negative: StyleSet
 97}
 98
 99export interface RampSet {
100    neutral: Scale
101    red: Scale
102    orange: Scale
103    yellow: Scale
104    green: Scale
105    cyan: Scale
106    blue: Scale
107    violet: Scale
108    magenta: Scale
109}
110
111export type Styles = keyof StyleSet
112export interface StyleSet {
113    default: Style
114    active: Style
115    disabled: Style
116    hovered: Style
117    pressed: Style
118    inverted: Style
119}
120
121export interface Style {
122    background: string
123    border: string
124    foreground: string
125}
126
127export function create_theme(theme: ThemeConfig): Theme {
128    const {
129        name,
130        appearance,
131        input_color,
132        override: { syntax },
133    } = theme
134
135    const is_light = appearance === ThemeAppearance.Light
136    const color_ramps: ThemeConfigInputColors = input_color
137
138    // Chromajs scales from 0 to 1 flipped if is_light is true
139    const ramps = get_ramps(is_light, color_ramps)
140    const lowest = lowest_layer(ramps)
141    const middle = middle_layer(ramps)
142    const highest = highest_layer(ramps)
143
144    const popover_shadow = {
145        blur: 4,
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: [1, 2],
152    }
153
154    const modal_shadow = {
155        blur: 16,
156        color: ramps
157            .neutral(is_light ? 7 : 0)
158            .darken()
159            .alpha(0.2)
160            .hex(), // TODO used blend previously. Replace with something else
161        offset: [0, 2],
162    }
163
164    const players = {
165        "0": player(ramps.blue),
166        "1": player(ramps.green),
167        "2": player(ramps.magenta),
168        "3": player(ramps.orange),
169        "4": player(ramps.violet),
170        "5": player(ramps.cyan),
171        "6": player(ramps.red),
172        "7": player(ramps.yellow),
173    }
174
175    const color_family = build_color_family(ramps)
176
177    return {
178        name,
179        is_light,
180
181        ramps,
182
183        lowest,
184        middle,
185        highest,
186
187        popover_shadow,
188        modal_shadow,
189
190        players,
191        syntax,
192        color_family,
193    }
194}
195
196function player(ramp: Scale): Player {
197    return {
198        selection: ramp(0.5).alpha(0.24).hex(),
199        cursor: ramp(0.5).hex(),
200    }
201}
202
203function build_color_family(ramps: RampSet): ColorFamily {
204    const color_family: ColorFamily = {}
205
206    for (const ramp in ramps) {
207        const ramp_value = ramps[ramp as keyof RampSet]
208
209        const lightnessValues = [ramp_value(0).get('hsl.l') * 100, ramp_value(1).get('hsl.l') * 100]
210        const low = Math.min(...lightnessValues)
211        const high = Math.max(...lightnessValues)
212        const range = high - low
213
214        color_family[ramp as keyof RampSet] = {
215            low,
216            high,
217            range,
218            scaling_value: 100 / range,
219        }
220    }
221
222    return color_family
223}
224
225function lowest_layer(ramps: RampSet): Layer {
226    return {
227        base: build_style_set(ramps.neutral, 0.2, 1),
228        variant: build_style_set(ramps.neutral, 0.2, 0.7),
229        on: build_style_set(ramps.neutral, 0.1, 1),
230        accent: build_style_set(ramps.blue, 0.1, 0.5),
231        positive: build_style_set(ramps.green, 0.1, 0.5),
232        warning: build_style_set(ramps.yellow, 0.1, 0.5),
233        negative: build_style_set(ramps.red, 0.1, 0.5),
234    }
235}
236
237function middle_layer(ramps: RampSet): Layer {
238    return {
239        base: build_style_set(ramps.neutral, 0.1, 1),
240        variant: build_style_set(ramps.neutral, 0.1, 0.7),
241        on: build_style_set(ramps.neutral, 0, 1),
242        accent: build_style_set(ramps.blue, 0.1, 0.5),
243        positive: build_style_set(ramps.green, 0.1, 0.5),
244        warning: build_style_set(ramps.yellow, 0.1, 0.5),
245        negative: build_style_set(ramps.red, 0.1, 0.5),
246    }
247}
248
249function highest_layer(ramps: RampSet): Layer {
250    return {
251        base: build_style_set(ramps.neutral, 0, 1),
252        variant: build_style_set(ramps.neutral, 0, 0.7),
253        on: build_style_set(ramps.neutral, 0.1, 1),
254        accent: build_style_set(ramps.blue, 0.1, 0.5),
255        positive: build_style_set(ramps.green, 0.1, 0.5),
256        warning: build_style_set(ramps.yellow, 0.1, 0.5),
257        negative: build_style_set(ramps.red, 0.1, 0.5),
258    }
259}
260
261function build_style_set(
262    ramp: Scale,
263    background_base: number,
264    foreground_base: number,
265    step = 0.08
266): StyleSet {
267    const style_definitions = build_style_definition(
268        background_base,
269        foreground_base,
270        step
271    )
272
273    function color_string(index_or_color: number | Color): string {
274        if (typeof index_or_color === "number") {
275            return ramp(index_or_color).hex()
276        } else {
277            return index_or_color.hex()
278        }
279    }
280
281    function build_style(style: Styles): Style {
282        return {
283            background: color_string(style_definitions.background[style]),
284            border: color_string(style_definitions.border[style]),
285            foreground: color_string(style_definitions.foreground[style]),
286        }
287    }
288
289    return {
290        default: build_style("default"),
291        hovered: build_style("hovered"),
292        pressed: build_style("pressed"),
293        active: build_style("active"),
294        disabled: build_style("disabled"),
295        inverted: build_style("inverted"),
296    }
297}
298
299function build_style_definition(bg_base: number, fg_base: number, step = 0.08) {
300    return {
301        background: {
302            default: bg_base,
303            hovered: bg_base + step,
304            pressed: bg_base + step * 1.5,
305            active: bg_base + step * 2.2,
306            disabled: bg_base,
307            inverted: fg_base + step * 6,
308        },
309        border: {
310            default: bg_base + step * 1,
311            hovered: bg_base + step,
312            pressed: bg_base + step,
313            active: bg_base + step * 3,
314            disabled: bg_base + step * 0.5,
315            inverted: bg_base - step * 3,
316        },
317        foreground: {
318            default: fg_base,
319            hovered: fg_base,
320            pressed: fg_base,
321            active: fg_base + step * 6,
322            disabled: bg_base + step * 4,
323            inverted: bg_base + step * 2,
324        },
325    }
326}