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 = [
210            ramp_value(0).get("hsl.l") * 100,
211            ramp_value(1).get("hsl.l") * 100,
212        ]
213        const low = Math.min(...lightnessValues)
214        const high = Math.max(...lightnessValues)
215        const range = high - low
216
217        color_family[ramp as keyof RampSet] = {
218            low,
219            high,
220            range,
221            scaling_value: 100 / range,
222        }
223    }
224
225    return color_family
226}
227
228function lowest_layer(ramps: RampSet): Layer {
229    return {
230        base: build_style_set(ramps.neutral, 0.2, 1),
231        variant: build_style_set(ramps.neutral, 0.2, 0.7),
232        on: build_style_set(ramps.neutral, 0.1, 1),
233        accent: build_style_set(ramps.blue, 0.1, 0.5),
234        positive: build_style_set(ramps.green, 0.1, 0.5),
235        warning: build_style_set(ramps.yellow, 0.1, 0.5),
236        negative: build_style_set(ramps.red, 0.1, 0.5),
237    }
238}
239
240function middle_layer(ramps: RampSet): Layer {
241    return {
242        base: build_style_set(ramps.neutral, 0.1, 1),
243        variant: build_style_set(ramps.neutral, 0.1, 0.7),
244        on: build_style_set(ramps.neutral, 0, 1),
245        accent: build_style_set(ramps.blue, 0.1, 0.5),
246        positive: build_style_set(ramps.green, 0.1, 0.5),
247        warning: build_style_set(ramps.yellow, 0.1, 0.5),
248        negative: build_style_set(ramps.red, 0.1, 0.5),
249    }
250}
251
252function highest_layer(ramps: RampSet): Layer {
253    return {
254        base: build_style_set(ramps.neutral, 0, 1),
255        variant: build_style_set(ramps.neutral, 0, 0.7),
256        on: build_style_set(ramps.neutral, 0.1, 1),
257        accent: build_style_set(ramps.blue, 0.1, 0.5),
258        positive: build_style_set(ramps.green, 0.1, 0.5),
259        warning: build_style_set(ramps.yellow, 0.1, 0.5),
260        negative: build_style_set(ramps.red, 0.1, 0.5),
261    }
262}
263
264function build_style_set(
265    ramp: Scale,
266    background_base: number,
267    foreground_base: number,
268    step = 0.08
269): StyleSet {
270    const style_definitions = build_style_definition(
271        background_base,
272        foreground_base,
273        step
274    )
275
276    function color_string(index_or_color: number | Color): string {
277        if (typeof index_or_color === "number") {
278            return ramp(index_or_color).hex()
279        } else {
280            return index_or_color.hex()
281        }
282    }
283
284    function build_style(style: Styles): Style {
285        return {
286            background: color_string(style_definitions.background[style]),
287            border: color_string(style_definitions.border[style]),
288            foreground: color_string(style_definitions.foreground[style]),
289        }
290    }
291
292    return {
293        default: build_style("default"),
294        hovered: build_style("hovered"),
295        pressed: build_style("pressed"),
296        active: build_style("active"),
297        disabled: build_style("disabled"),
298        inverted: build_style("inverted"),
299    }
300}
301
302function build_style_definition(bg_base: number, fg_base: number, step = 0.08) {
303    return {
304        background: {
305            default: bg_base,
306            hovered: bg_base + step,
307            pressed: bg_base + step * 1.5,
308            active: bg_base + step * 2.2,
309            disabled: bg_base,
310            inverted: fg_base + step * 6,
311        },
312        border: {
313            default: bg_base + step * 1,
314            hovered: bg_base + step,
315            pressed: bg_base + step,
316            active: bg_base + step * 3,
317            disabled: bg_base + step * 0.5,
318            inverted: bg_base - step * 3,
319        },
320        foreground: {
321            default: fg_base,
322            hovered: fg_base,
323            pressed: fg_base,
324            active: fg_base + step * 6,
325            disabled: bg_base + step * 4,
326            inverted: bg_base + step * 2,
327        },
328    }
329}