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}