forked from VladimirMarkelov/clui
-
Notifications
You must be signed in to change notification settings - Fork 0
/
theme.go
545 lines (477 loc) · 16.1 KB
/
theme.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
package clui
import (
"bufio"
"fmt"
term "github.com/nsf/termbox-go"
"io/ioutil"
"os"
"strings"
"sync"
"unicode/utf8"
)
/*
ThemeManager support for controls.
The current implementation is limited but later the manager will be
able to load a requested theme on demand and use deep inheritance.
Theme 'default' exists always - it is predefinded and always complete.
User-defined themes may omit any theme section, all omitted items
are loaded from parent theme. The only required property that a user-
defined theme must have is a theme name.
Theme file is a simple text file that has similar to INI file format:
1. Every line started with '#' or '/' is a comment line.
2. Invalid lines - lines that do not contain symbol '=' - are skipped.
3. Valid lines are split in two parts:
key - the text before the first '=' in the line
value - the text after the first '=' in the line (so, values can
include '=')
key and value are trimmed - spaces are removed from both ends.
If line starts and ends with quote or double quote symbol then
these symbols are removed, too. It is done to be able to start
or finish the object with a space rune
4. There is no mandatory keys - all of them are optional
5. Available system keys that used to describe the theme:
'title' - the theme title
'author' - theme author
'version' - theme version
'parent' - name of the parent theme. If it is not set then the
'default' is used as a parent
6. Non-system keys are divided into two groups: Colors and Objects
Colors are the keys that end with 'Back' or 'Text' - background
and text color, respectively. If theme manager cannot
value to color it uses black color. See Color*Back * Color*Text
constants, just drop 'Color' at the beginning of key name.
Rules of converting text to color:
1. If the value does not end neither with 'Back' nor with 'Text'
it is considered as raw attribute value(e.g, 'green bold')
2. If the value ends with 'Back' or 'Text' it means that one
of earlier defined attribute must be used. If the current
scheme does not have that attribute defined (e.g, it is
defined later in file) then parent theme attribute with
the same name is used. One can force using parent theme
colors - just add prefix 'parent.' to color name. This
may be useful if one wants some parent colors reversed.
Example:
ViewBack=ViewText
ViewText=ViewBack
this makes both colors the same because ViewBack is defined
before ViewText. Only ViewBack value is loaded from parent theme.
Better way is:
Viewback=parent.ViewText
ViewText=parent.ViewBack
Converting text to real color fails and returns black color if
a) the string does not look like real color(e.g, typo as in
'grean bold'), b) parent theme has not loaded yet, c) parent
theme does not have the color
with the same name
Other keys are considered as objects - see Obj* constants, just drop
'Obj' at the beginning of the key name
One is not limited with only predefined color and object names.
The theme can introduce its own objects, e.g. to provide a runes or
colors for new control that is not in standard library
To see the real world example of full featured theme, please see
included theme 'turbovision'
*/
type ThemeManager struct {
// available theme list
themes map[string]theme
// name of the current theme
current string
themePath string
version string
}
const defaultTheme = "default"
const themeSuffix = ".theme"
var (
themeManager *ThemeManager
thememtx sync.RWMutex
)
// ThemeDesc is a detailed information about theme:
// title, author, version number
type ThemeDesc struct {
parent string
title string
author string
version string
}
/*
A theme structure. It keeps all colors, characters for the theme.
Parent property determines a theme name that is used if a requested
theme object is not declared in the current one. If no parent is
defined then the library uses default built-in theme.
*/
type theme struct {
parent string
title string
author string
version string
colors map[string]term.Attribute
objects map[string]string
}
// NewThemeManager creates a new theme manager
func initThemeManager() {
themeManager = new(ThemeManager)
ThemeReset()
}
// ThemeReset removes all loaded themes from cache and reinitialize
// the default theme
func ThemeReset() {
thememtx.Lock()
defer thememtx.Unlock()
themeManager.current = defaultTheme
themeManager.themes = make(map[string]theme, 0)
defTheme := theme{parent: "", title: "Default Theme", author: "Vladimir V. Markelov", version: "1.0"}
defTheme.colors = make(map[string]term.Attribute, 0)
defTheme.objects = make(map[string]string, 0)
defTheme.objects[ObjSingleBorder] = "─│┌┐└┘"
defTheme.objects[ObjDoubleBorder] = "═║╔╗╚╝"
defTheme.objects[ObjEdit] = "←→V*"
defTheme.objects[ObjScrollBar] = "░■▲▼◄►"
defTheme.objects[ObjViewButtons] = "^_■[]"
defTheme.objects[ObjCheckBox] = "[] X?"
defTheme.objects[ObjRadio] = "() *"
defTheme.objects[ObjProgressBar] = "░▒"
defTheme.objects[ObjBarChart] = "█─│┌┐└┘┬┴├┤┼"
defTheme.objects[ObjSparkChart] = "█"
defTheme.objects[ObjTableView] = "─│┼▼▲"
defTheme.objects[ObjButton] = "▀█"
defTheme.colors[ColorDisabledText] = ColorBlackBold
defTheme.colors[ColorDisabledBack] = ColorWhite
defTheme.colors[ColorText] = ColorWhite
defTheme.colors[ColorBack] = ColorBlack
defTheme.colors[ColorViewBack] = ColorBlack
defTheme.colors[ColorViewText] = ColorWhite
defTheme.colors[ColorControlText] = ColorWhite
defTheme.colors[ColorControlBack] = ColorBlack
defTheme.colors[ColorControlActiveText] = ColorWhite
defTheme.colors[ColorControlActiveBack] = ColorMagenta
defTheme.colors[ColorControlShadow] = ColorBlue
defTheme.colors[ColorControlDisabledText] = ColorWhite
defTheme.colors[ColorControlDisabledBack] = ColorBlack
defTheme.colors[ColorButtonText] = ColorWhite
defTheme.colors[ColorButtonBack] = ColorGreen
defTheme.colors[ColorButtonActiveText] = ColorWhite
defTheme.colors[ColorButtonActiveBack] = ColorMagenta
defTheme.colors[ColorButtonShadow] = ColorBlue
defTheme.colors[ColorButtonDisabledText] = ColorWhite
defTheme.colors[ColorButtonDisabledBack] = ColorBlack
defTheme.colors[ColorEditText] = ColorBlack
defTheme.colors[ColorEditBack] = ColorWhite
defTheme.colors[ColorEditActiveText] = ColorBlack
defTheme.colors[ColorEditActiveBack] = ColorYellow
defTheme.colors[ColorSelectionText] = ColorYellow
defTheme.colors[ColorSelectionBack] = ColorBlue
defTheme.colors[ColorScrollBack] = ColorBlack
defTheme.colors[ColorScrollText] = ColorWhite
defTheme.colors[ColorThumbBack] = ColorBlack
defTheme.colors[ColorThumbText] = ColorWhite
defTheme.colors[ColorProgressText] = ColorBlue
defTheme.colors[ColorProgressBack] = ColorBlack
defTheme.colors[ColorProgressActiveText] = ColorBlack
defTheme.colors[ColorProgressActiveBack] = ColorBlue
defTheme.colors[ColorProgressTitleText] = ColorWhite
defTheme.colors[ColorBarChartBack] = ColorBlack
defTheme.colors[ColorBarChartText] = ColorWhite
defTheme.colors[ColorSparkChartBack] = ColorBlack
defTheme.colors[ColorSparkChartText] = ColorWhite
defTheme.colors[ColorSparkChartBarBack] = ColorBlack
defTheme.colors[ColorSparkChartBarText] = ColorCyan
defTheme.colors[ColorSparkChartMaxBack] = ColorBlack
defTheme.colors[ColorSparkChartMaxText] = ColorCyanBold
defTheme.colors[ColorTableText] = ColorWhite
defTheme.colors[ColorTableBack] = ColorBlack
defTheme.colors[ColorTableSelectedText] = ColorWhite
defTheme.colors[ColorTableSelectedBack] = ColorBlack
defTheme.colors[ColorTableActiveCellText] = ColorWhiteBold
defTheme.colors[ColorTableActiveCellBack] = ColorBlack
defTheme.colors[ColorTableLineText] = ColorWhite
defTheme.colors[ColorTableHeaderText] = ColorWhite
defTheme.colors[ColorTableHeaderBack] = ColorBlack
themeManager.themes[defaultTheme] = defTheme
}
// SysColor returns attribute by its id for the current theme.
// The method panics if theme loop is detected - check if
// parent attribute is correct
func SysColor(color string) term.Attribute {
thememtx.RLock()
sch, ok := themeManager.themes[themeManager.current]
if !ok {
sch = themeManager.themes[defaultTheme]
}
thememtx.RUnlock()
clr, okclr := sch.colors[color]
if !okclr {
visited := make(map[string]int, 0)
visited[themeManager.current] = 1
if !ok {
visited[defaultTheme] = 1
}
for {
if sch.parent == "" {
break
}
themeManager.loadTheme(sch.parent)
thememtx.RLock()
sch = themeManager.themes[sch.parent]
clr, okclr = sch.colors[color]
thememtx.RUnlock()
if ok {
break
} else {
if _, okSch := visited[sch.parent]; okSch {
panic("Color + " + color + ". Theme loop detected: " + sch.title + " --> " + sch.parent)
} else {
visited[sch.parent] = 1
}
}
}
}
return clr
}
// SysObject returns object look by its id for the current
// theme. E.g, border lines for frame or arrows for scrollbar.
// The method panics if theme loop is detected - check if
// parent attribute is correct
func SysObject(object string) string {
thememtx.RLock()
sch, ok := themeManager.themes[themeManager.current]
if !ok {
sch = themeManager.themes[defaultTheme]
}
thememtx.RUnlock()
obj, okobj := sch.objects[object]
if !okobj {
visited := make(map[string]int, 0)
visited[themeManager.current] = 1
if !ok {
visited[defaultTheme] = 1
}
for {
if sch.parent == "" {
break
}
themeManager.loadTheme(sch.parent)
thememtx.RLock()
sch = themeManager.themes[sch.parent]
obj, okobj = sch.objects[object]
thememtx.RUnlock()
if ok {
break
} else {
if _, okSch := visited[sch.parent]; okSch {
panic("Object: " + object + ". Theme loop detected: " + sch.title + " --> " + sch.parent)
} else {
visited[sch.parent] = 1
}
}
}
}
return obj
}
// ThemeNames returns the list of short theme names (file names)
func ThemeNames() []string {
var str []string
str = append(str, defaultTheme)
path := themeManager.themePath
if path == "" {
path = "." + string(os.PathSeparator)
}
files, err := ioutil.ReadDir(path)
if err != nil {
panic("Failed to read theme directory: " + themeManager.themePath)
}
for _, f := range files {
name := f.Name()
if !f.IsDir() && strings.HasSuffix(name, themeSuffix) {
str = append(str, strings.TrimSuffix(name, themeSuffix))
}
}
return str
}
// CurrentTheme returns name of the current theme
func CurrentTheme() string {
thememtx.RLock()
defer thememtx.RUnlock()
return themeManager.current
}
// SetCurrentTheme changes the current theme.
// Returns false if changing failed - e.g, theme does not exist
func SetCurrentTheme(name string) bool {
thememtx.RLock()
_, ok := themeManager.themes[name]
thememtx.RUnlock()
if !ok {
tnames := ThemeNames()
for _, theme := range tnames {
if theme == name {
themeManager.loadTheme(theme)
break
}
}
}
thememtx.Lock()
defer thememtx.Unlock()
if _, ok := themeManager.themes[name]; ok {
themeManager.current = name
return true
}
return false
}
// ThemePath returns the current directory with theme inside it
func ThemePath() string {
return themeManager.themePath
}
// SetThemePath changes the directory that contains themes.
// If new path does not equal old one, theme list reloads
func SetThemePath(path string) {
if path == themeManager.themePath {
return
}
themeManager.themePath = path
ThemeReset()
}
// loadTheme loads the theme if it is not in the cache already.
// If theme is in the cache loadTheme does nothing
func (s *ThemeManager) loadTheme(name string) {
thememtx.Lock()
defer thememtx.Unlock()
if _, ok := s.themes[name]; ok {
return
}
theme := theme{parent: defaultTheme, title: "", author: ""}
theme.colors = make(map[string]term.Attribute, 0)
theme.objects = make(map[string]string, 0)
file, err := os.Open(s.themePath + string(os.PathSeparator) + name + themeSuffix)
if err != nil {
panic("Failed to open theme " + name + " : " + err.Error())
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimSpace(line)
// skip comments
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, "/") {
continue
}
// skip invalid lines
if !strings.Contains(line, "=") {
continue
}
parts := strings.SplitN(line, "=", 2)
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) ||
(strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) {
toTrim, _ := utf8.DecodeRuneInString(value)
value = strings.Trim(value, string(toTrim))
}
low := strings.ToLower(key)
if low == "parent" {
theme.parent = value
} else if low == "author" {
theme.author = value
} else if low == "name" || low == "title" {
theme.title = value
} else if low == "version" {
theme.version = value
} else if strings.HasSuffix(key, "Back") || strings.HasSuffix(key, "Text") {
// the first case is a reference to existing color (of this or parent theme)
// the second is the real color
if strings.HasSuffix(value, "Back") || strings.HasSuffix(value, "Text") {
clr, ok := theme.colors[value]
if !ok {
v := value
// if color starts with 'parent.' it means the parent color
// must be used always. It may be useful to load inversed
// text and background colors of parent theme
if strings.HasPrefix(v, "parent.") {
v = strings.TrimPrefix(v, "parent.")
}
sch, schOk := s.themes[theme.parent]
if schOk {
clr, ok = sch.colors[v]
} else {
panic(fmt.Sprintf("%v: Parent theme '%v' not found", name, theme.parent))
}
}
if ok {
theme.colors[key] = clr
} else {
panic(fmt.Sprintf("%v: Failed to find color '%v' by reference", name, value))
}
} else {
c := StringToColor(value)
if c%32 == 0 {
panic("Failed to read color: " + value)
}
theme.colors[key] = c
}
} else {
theme.objects[key] = value
}
}
s.themes[name] = theme
}
// ReloadTheme refresh cache entry for the theme with new
// data loaded from file. Use it to apply theme changes on
// the fly without resetting manager or restarting application
func ReloadTheme(name string) {
if name == defaultTheme {
// default theme cannot be reloaded
return
}
thememtx.Lock()
if _, ok := themeManager.themes[name]; ok {
delete(themeManager.themes, name)
}
thememtx.Unlock()
themeManager.loadTheme(name)
}
// ThemeInfo returns detailed info about theme
func ThemeInfo(name string) ThemeDesc {
themeManager.loadTheme(name)
thememtx.RLock()
defer thememtx.RUnlock()
var theme ThemeDesc
if t, ok := themeManager.themes[name]; !ok {
theme.parent = t.parent
theme.title = t.title
theme.version = t.version
}
return theme
}
// RealColor returns attribute that should be applied to an
// object. By default all attributes equal ColorDefault and
// the real color should be retrieved from the current theme.
// Attribute selection work this way: if color is not ColorDefault,
// it is returned as is, otherwise the function tries to load
// color from the theme.
//
// With the style argument themes may be grouped by control, i.e
// an application may have multiple list controls where they all share
// the same theme attributes however the same application may have
// one specific list control with some different theme attributes,
// in that case the user may call control.SetStyle("custom") and define
// a set of custom.* attributes, i.e:
//
// custom.EditBox = white
// custom.EditText = black bold
// ...
//
// clr - current object color
// style - the theme prefix style set
// id - color ID in theme
func RealColor(clr term.Attribute, style string, id string) term.Attribute {
var prefix string
if style != "" {
prefix = fmt.Sprintf("%s.", style)
}
ccolor := fmt.Sprintf("%s%s", prefix, id)
if clr == ColorDefault {
clr = SysColor(ccolor)
}
if clr == ColorDefault {
panic("Failed to load color value for " + ccolor)
}
return clr
}