1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3
4
5__license__ = 'GPL v3'
6__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
7
8from collections import namedtuple
9
10from qt.core import (
11    QColor, QBrush, QFont, QApplication, QPalette, QComboBox,
12    QPushButton, QIcon, QFormLayout, QLineEdit, QWidget, QScrollArea,
13    QVBoxLayout, Qt, QHBoxLayout, pyqtSignal, QPixmap, QColorDialog, QDialog,
14    QToolButton, QCheckBox, QSize, QLabel, QSplitter, QTextCharFormat, QDialogButtonBox)
15
16from calibre.gui2 import error_dialog
17from calibre.gui2.tweak_book import tprefs
18from calibre.gui2.tweak_book.editor import syntax_text_char_format
19from calibre.gui2.tweak_book.widgets import Dialog
20from polyglot.builtins import iteritems
21
22underline_styles = {'single', 'dash', 'dot', 'dash_dot', 'dash_dot_dot', 'wave', 'spell'}
23
24_default_theme = None
25
26
27def default_theme():
28    global _default_theme
29    if _default_theme is None:
30        isdark = QApplication.instance().palette().color(QPalette.ColorRole.WindowText).lightness() > 128
31        _default_theme = 'wombat-dark' if isdark else 'pyte-light'
32    return _default_theme
33
34
35# The solarized themes {{{
36SLDX = {'base03':'1c1c1c', 'base02':'262626', 'base01':'585858', 'base00':'626262', 'base0':'808080', 'base1':'8a8a8a', 'base2':'e4e4e4', 'base3':'ffffd7', 'yellow':'af8700', 'orange':'d75f00', 'red':'d70000', 'magenta':'af005f', 'violet':'5f5faf', 'blue':'0087ff', 'cyan':'00afaf', 'green':'5f8700'}  # noqa
37SLD  = {'base03':'002b36', 'base02':'073642', 'base01':'586e75', 'base00':'657b83', 'base0':'839496', 'base1':'93a1a1', 'base2':'eee8d5', 'base3':'fdf6e3', 'yellow':'b58900', 'orange':'cb4b16', 'red':'dc322f', 'magenta':'d33682', 'violet':'6c71c4', 'blue':'268bd2', 'cyan':'2aa198', 'green':'859900'}  # noqa
38m = {'base%d'%n:'base%02d'%n for n in range(1, 4)}
39m.update({'base%02d'%n:'base%d'%n for n in range(1, 4)})
40SLL = {m.get(k, k) : v for k, v in iteritems(SLD)}
41SLLX = {m.get(k, k) : v for k, v in iteritems(SLDX)}
42SOLARIZED = \
43    '''
44    CursorLine   bg={base02}
45    CursorColumn bg={base02}
46    ColorColumn  bg={base02}
47    HighlightRegion bg={base00}
48    MatchParen   bg={base02} fg={magenta}
49    Pmenu        fg={base0} bg={base02}
50    PmenuSel     fg={base01} bg={base2}
51
52    Cursor       fg={base03} bg={base0}
53    Normal       fg={base0} bg={base02}
54    LineNr       fg={base01} bg={base02}
55    LineNrC      fg={magenta}
56    Visual       fg={base01} bg={base03}
57
58    Comment      fg={base01} italic
59    Todo         fg={magenta} bold
60    String       fg={cyan}
61    Constant     fg={cyan}
62    Number       fg={cyan}
63    PreProc      fg={orange}
64    Identifier   fg={blue}
65    Function     fg={blue}
66    Type         fg={yellow}
67    Statement    fg={green} bold
68    Keyword      fg={green}
69    Special      fg={red}
70    SpecialCharacter bg={base02}
71
72    Error        us=wave uc={red}
73    SpellError   us=wave uc={orange}
74    Tooltip      fg=black bg=ffffed
75    Link         fg={blue}
76    BadLink      fg={cyan} us=wave uc={red}
77
78    DiffDelete   bg={base02} fg={red}
79    DiffInsert   bg={base02} fg={green}
80    DiffReplace  bg={base02} fg={blue}
81    DiffReplaceReplace bg={base03}
82    '''
83# }}}
84
85THEMES = {
86    'wombat-dark':  # {{{
87    '''
88    CursorLine   bg={cursor_loc}
89    CursorColumn bg={cursor_loc}
90    ColorColumn  bg={cursor_loc}
91    HighlightRegion bg=3d3d3d
92    MatchParen   bg=444444
93    Pmenu        fg=f6f3e8 bg=444444
94    PmenuSel     fg=yellow bg={identifier}
95    Tooltip      fg=black bg=ffffed
96
97    Cursor       bg=656565
98    Normal       fg=f6f3e8 bg=242424
99    LineNr       fg=857b6f bg=000000
100    LineNrC      fg=yellow
101    Visual       fg=black bg=888888
102
103    Comment      fg={comment}
104    Todo         fg=8f8f8f
105    String       fg={string}
106    Constant     fg={constant}
107    Number       fg={constant}
108    PreProc      fg={constant}
109    Identifier   fg={identifier}
110    Function     fg={identifier}
111    Type         fg={identifier}
112    Statement    fg={keyword}
113    Keyword      fg={keyword}
114    Special      fg={special}
115    Error        us=wave uc=red
116    SpellError   us=wave uc=orange
117    SpecialCharacter bg={cursor_loc}
118    Link         fg=cyan
119    BadLink      fg={string} us=wave uc=red
120
121    DiffDelete   bg=341414 fg=642424
122    DiffInsert   bg=143414 fg=246424
123    DiffReplace  bg=141434 fg=242464
124    DiffReplaceReplace bg=002050
125
126    '''.format(
127        cursor_loc='323232',
128        identifier='cae682',
129        comment='99968b',
130        string='95e454',
131        keyword='8ac6f2',
132        constant='e5786d',
133        special='e7f6da'),  # }}}
134
135    'pyte-light':  # {{{
136    '''
137    CursorLine   bg={cursor_loc}
138    CursorColumn bg={cursor_loc}
139    ColorColumn  bg={cursor_loc}
140    HighlightRegion bg=E3F988
141    MatchParen   bg=cfcfcf
142    Pmenu        fg=white bg=808080
143    PmenuSel     fg=white bg=808080
144    Tooltip      fg=black bg=ffffed
145
146    Cursor       fg=black bg=b0b4b8
147    Normal       fg=404850 bg=f0f0f0
148    LineNr       fg=white bg=8090a0
149    LineNrC      fg=yellow
150    Visual       fg=white bg=8090a0
151
152    Comment      fg={comment} italic
153    Todo         fg={comment} italic bold
154    String       fg={string}
155    Constant     fg={constant}
156    Number       fg={constant}
157    PreProc      fg={constant}
158    Identifier   fg={identifier}
159    Function     fg={identifier}
160    Type         fg={identifier}
161    Statement    fg={keyword}
162    Keyword      fg={keyword}
163    Special      fg={special} italic
164    SpecialCharacter bg={cursor_loc}
165    Error        us=wave uc=red
166    SpellError   us=wave uc=magenta
167    Link         fg=blue
168    BadLink      fg={string} us=wave uc=red
169
170    DiffDelete   bg=rgb(255,180,200) fg=rgb(200,80,110)
171    DiffInsert   bg=rgb(180,255,180) fg=rgb(80,210,80)
172    DiffReplace  bg=rgb(206,226,250) fg=rgb(90,130,180)
173    DiffReplaceReplace bg=rgb(180,210,250)
174
175    '''.format(
176        cursor_loc='F8DE7E',
177        identifier='7b5694',
178        comment='a0b0c0',
179        string='4070a0',
180        keyword='007020',
181        constant='a07040',
182        special='70a0d0'),  # }}}
183
184    'solarized-x-dark': SOLARIZED.format(**SLDX),
185    'solarized-dark': SOLARIZED.format(**SLD),
186    'solarized-light': SOLARIZED.format(**SLL),
187    'solarized-x-light': SOLARIZED.format(**SLLX),
188
189}
190
191
192def read_color(col):
193    if QColor.isValidColor(col):
194        return QBrush(QColor(col))
195    if col.startswith('rgb('):
196        r, g, b = map(int, (x.strip() for x in col[4:-1].split(',')))
197        return QBrush(QColor(r, g, b))
198    try:
199        r, g, b = col[0:2], col[2:4], col[4:6]
200        r, g, b = int(r, 16), int(g, 16), int(b, 16)
201        return QBrush(QColor(r, g, b))
202    except Exception:
203        pass
204
205
206Highlight = namedtuple('Highlight', 'fg bg bold italic underline underline_color')
207
208
209def read_theme(raw):
210    ans = {}
211    for line in raw.splitlines():
212        line = line.strip()
213        if not line or line.startswith('#'):
214            continue
215        bold = italic = False
216        fg = bg = name = underline = underline_color = None
217        line = line.partition('#')[0]
218        for i, token in enumerate(line.split()):
219            if i == 0:
220                name = token
221            else:
222                if token == 'bold':
223                    bold = True
224                elif token == 'italic':
225                    italic = True
226                elif '=' in token:
227                    prefix, val = token.partition('=')[0::2]
228                    if prefix == 'us':
229                        underline = val if val in underline_styles else None
230                    elif prefix == 'uc':
231                        underline_color = read_color(val)
232                    elif prefix == 'fg':
233                        fg = read_color(val)
234                    elif prefix == 'bg':
235                        bg = read_color(val)
236        if name is not None:
237            ans[name] = Highlight(fg, bg, bold, italic, underline, underline_color)
238    return ans
239
240
241THEMES = {k:read_theme(raw) for k, raw in iteritems(THEMES)}
242
243
244def u(x):
245    x = {'spell':'SpellCheck', 'dash_dot':'DashDot', 'dash_dot_dot':'DashDotDot'}.get(x, x.capitalize())
246    if 'Dot' in x:
247        return x + 'Line'
248    return x + 'Underline'
249
250
251underline_styles = {x:getattr(QTextCharFormat.UnderlineStyle, u(x)) for x in underline_styles}
252
253
254def to_highlight(data):
255    data = data.copy()
256    for c in ('fg', 'bg', 'underline_color'):
257        data[c] = read_color(data[c]) if data.get(c, None) is not None else None
258    return Highlight(**data)
259
260
261def read_custom_theme(data):
262    dt = THEMES[default_theme()].copy()
263    dt.update({k:to_highlight(v) for k, v in iteritems(data)})
264    return dt
265
266
267def get_theme(name):
268    try:
269        return THEMES[name]
270    except KeyError:
271        try:
272            ans = tprefs['custom_themes'][name]
273        except KeyError:
274            return THEMES[default_theme()]
275        else:
276            return read_custom_theme(ans)
277
278
279def highlight_to_char_format(h):
280    ans = syntax_text_char_format()
281    if h.bold:
282        ans.setFontWeight(QFont.Weight.Bold)
283    if h.italic:
284        ans.setFontItalic(True)
285    if h.fg is not None:
286        ans.setForeground(h.fg)
287    if h.bg is not None:
288        ans.setBackground(h.bg)
289    if h.underline:
290        ans.setUnderlineStyle(underline_styles[h.underline])
291        if h.underline_color is not None:
292            ans.setUnderlineColor(h.underline_color.color())
293    return ans
294
295
296def theme_color(theme, name, attr):
297    try:
298        return getattr(theme[name], attr).color()
299    except (KeyError, AttributeError):
300        return getattr(THEMES[default_theme()][name], attr).color()
301
302
303def theme_format(theme, name):
304    try:
305        h = theme[name]
306    except KeyError:
307        h = THEMES[default_theme()][name]
308    return highlight_to_char_format(h)
309
310
311def custom_theme_names():
312    return tuple(tprefs['custom_themes'])
313
314
315def builtin_theme_names():
316    return tuple(THEMES)
317
318
319def all_theme_names():
320    return builtin_theme_names() + custom_theme_names()
321
322# Custom theme creation/editing {{{
323
324
325class CreateNewTheme(Dialog):
326
327    def __init__(self, parent=None):
328        Dialog.__init__(self, _('Create custom theme'), 'custom-theme-create', parent=parent)
329
330    def setup_ui(self):
331        self.l = l = QFormLayout(self)
332        self.setLayout(l)
333
334        self._name = n = QLineEdit(self)
335        l.addRow(_('&Name of custom theme:'), n)
336
337        self.base = b = QComboBox(self)
338        b.addItems(sorted(builtin_theme_names()))
339        l.addRow(_('&Builtin theme to base on:'), b)
340        idx = b.findText(tprefs['editor_theme'] or default_theme())
341        if idx == -1:
342            idx = b.findText(default_theme())
343        b.setCurrentIndex(idx)
344
345        l.addRow(self.bb)
346
347    @property
348    def theme_name(self):
349        return str(self._name.text()).strip()
350
351    def accept(self):
352        if not self.theme_name:
353            return error_dialog(self, _('No name specified'), _(
354                'You must specify a name for your theme'), show=True)
355        if '*' + self.theme_name in custom_theme_names():
356            return error_dialog(self, _('Name already used'), _(
357                'A custom theme with the name %s already exists') % self.theme_name, show=True)
358        return Dialog.accept(self)
359
360
361def col_to_string(color):
362    return '%02X%02X%02X' % color.getRgb()[:3]
363
364
365class ColorButton(QPushButton):
366
367    changed = pyqtSignal()
368
369    def __init__(self, data, name, text, parent):
370        QPushButton.__init__(self, text, parent)
371        self.ic = QPixmap(self.iconSize())
372        color = data[name]
373        self.data, self.name = data, name
374        if color is not None:
375            self.current_color = read_color(color).color()
376            self.ic.fill(self.current_color)
377        else:
378            self.ic.fill(Qt.GlobalColor.transparent)
379            self.current_color = color
380        self.update_tooltip()
381        self.setIcon(QIcon(self.ic))
382        self.clicked.connect(self.choose_color)
383
384    def clear(self):
385        self.current_color = None
386        self.update_tooltip()
387        self.ic.fill(Qt.GlobalColor.transparent)
388        self.setIcon(QIcon(self.ic))
389        self.data[self.name] = self.value
390        self.changed.emit()
391
392    def choose_color(self):
393        col = QColorDialog.getColor(self.current_color or Qt.GlobalColor.black, self, _('Choose color'))
394        if col.isValid():
395            self.current_color = col
396            self.update_tooltip()
397            self.ic.fill(col)
398            self.setIcon(QIcon(self.ic))
399            self.data[self.name] = self.value
400            self.changed.emit()
401
402    def update_tooltip(self):
403        self.setToolTip(_('Red: {0} Green: {1} Blue: {2}').format(*self.current_color.getRgb()[:3]) if self.current_color else _('No color'))
404
405    @property
406    def value(self):
407        if self.current_color is None:
408            return None
409        return col_to_string(self.current_color)
410
411
412class Bool(QCheckBox):
413
414    changed = pyqtSignal()
415
416    def __init__(self, data, key, text, parent):
417        QCheckBox.__init__(self, text, parent)
418        self.data, self.key = data, key
419        self.setChecked(data.get(key, False))
420        self.stateChanged.connect(self._changed)
421
422    def _changed(self, state):
423        self.data[self.key] = self.value
424        self.changed.emit()
425
426    @property
427    def value(self):
428        return self.checkState() == Qt.CheckState.Checked
429
430
431class Property(QWidget):
432
433    changed = pyqtSignal()
434
435    def __init__(self, name, data, parent=None):
436        QWidget.__init__(self, parent)
437        self.l = l = QHBoxLayout(self)
438        self.setLayout(l)
439        self.label = QLabel(name)
440        l.addWidget(self.label)
441        self.data = data
442
443        def create_color_button(key, text):
444            b = ColorButton(data, key, text, self)
445            b.changed.connect(self.changed), l.addWidget(b)
446            bc = QToolButton(self)
447            bc.setIcon(QIcon(I('clear_left.png')))
448            bc.setToolTip(_('Remove color'))
449            bc.clicked.connect(b.clear)
450            h = QHBoxLayout()
451            h.addWidget(b), h.addWidget(bc)
452            return h
453
454        for k, text in (('fg', _('&Foreground')), ('bg', _('&Background'))):
455            h = create_color_button(k, text)
456            l.addLayout(h)
457
458        for k, text in (('bold', _('B&old')), ('italic', _('&Italic'))):
459            w = Bool(data, k, text, self)
460            w.changed.connect(self.changed)
461            l.addWidget(w)
462
463        self.underline = us = QComboBox(self)
464        us.addItems(sorted(tuple(underline_styles) + ('',)))
465        idx = us.findText(data.get('underline', '') or '')
466        us.setCurrentIndex(max(idx, 0))
467        us.currentIndexChanged.connect(self.us_changed)
468        self.la = la = QLabel(_('&Underline:'))
469        la.setBuddy(us)
470        h = QHBoxLayout()
471        h.addWidget(la), h.addWidget(us), l.addLayout(h)
472
473        h = create_color_button('underline_color', _('Color'))
474        l.addLayout(h)
475        l.addStretch(1)
476
477    def us_changed(self):
478        self.data['underline'] = str(self.underline.currentText()) or None
479        self.changed.emit()
480
481# Help text {{{
482
483
484HELP_TEXT = _('''\
485<h2>Creating a custom theme</h2>
486
487<p id="attribute" lang="und">You can create a custom syntax highlighting theme, \
488with your own colors and font styles. The most important types of highlighting \
489rules are described below. Note that not every rule supports every kind of \
490customization, for example, changing font or underline styles for the \
491<code>Cursor</code> rule does not have any effect as that rule is used only for \
492the color of the blinking cursor.</p>
493
494<p>As you make changes to your theme on the left, the changes will be reflected live in this panel.</p>
495
496<p xml:lang="und">
497{}
498    The most important rule. Sets the foreground and background colors for the \
499    editor as well as the style of "normal" text, that is, text that does not match any special syntax.
500
501{}
502    Defines the colors for text selected by the mouse.
503
504{}
505    Defines the color for the line containing the cursor.
506
507{}
508    Defines the colors for the line numbers on the left.
509
510{}
511    Defines the colors for matching tags in HTML and matching
512    braces in CSS.
513
514{}
515    Used for highlighting tags in HTML
516
517{}
518    Used for highlighting attributes in HTML
519
520{}
521    Tag names in HTML
522
523{}
524    Namespace prefixes in XML and constants in CSS
525
526{}
527    Non-breaking spaces/hyphens in HTML
528
529{}
530    Syntax errors such as <this <>
531
532{}
533    Misspelled words such as <span lang="en">thisword</span>
534
535{}
536    Comments like <!-- this one -->
537
538</p>
539
540<style type="text/css">
541/* Some CSS so you can see how the highlighting rules affect it */
542
543p.someclass {{
544    font-family: serif;
545    font-size: 12px;
546    line-height: 1.2;
547}}
548</style>
549''')  # }}}
550
551
552class ThemeEditor(Dialog):
553
554    def __init__(self, parent=None):
555        Dialog.__init__(self, _('Create/edit custom theme'), 'custom-theme-editor', parent=parent)
556
557    def setup_ui(self):
558        self.block_show = False
559        self.properties = []
560        self.l = l  = QVBoxLayout(self)
561        self.setLayout(l)
562        h = QHBoxLayout()
563        l.addLayout(h)
564        self.la = la = QLabel(_('&Edit theme:'))
565        h.addWidget(la)
566        self.theme = t = QComboBox(self)
567        la.setBuddy(t)
568        t.addItems(sorted(custom_theme_names()))
569        t.setMinimumWidth(200)
570        if t.count() > 0:
571            t.setCurrentIndex(0)
572        t.currentIndexChanged[int].connect(self.show_theme)
573        h.addWidget(t)
574
575        self.add_button = b = QPushButton(QIcon(I('plus.png')), _('Add &new theme'), self)
576        b.clicked.connect(self.create_new_theme)
577        h.addWidget(b)
578
579        self.remove_button = b = QPushButton(QIcon(I('minus.png')), _('&Remove theme'), self)
580        b.clicked.connect(self.remove_theme)
581        h.addWidget(b)
582        h.addStretch(1)
583
584        self.scroll = s = QScrollArea(self)
585        self.w = w = QWidget(self)
586        s.setWidget(w), s.setWidgetResizable(True)
587        self.cl = cl = QVBoxLayout()
588        w.setLayout(cl)
589
590        from calibre.gui2.tweak_book.editor.text import TextEdit
591        self.preview = p = TextEdit(self, expected_geometry=(73, 50))
592        p.load_text(HELP_TEXT.format(
593                *('<b>%s</b>' % x for x in (
594                    'Normal', 'Visual', 'CursorLine', 'LineNr', 'MatchParen',
595                    'Function', 'Type', 'Statement', 'Constant', 'SpecialCharacter',
596                    'Error', 'SpellError', 'Comment'
597                ))
598            ))
599        p.setMaximumWidth(p.size_hint.width() + 5)
600        s.setMinimumWidth(600)
601        self.splitter = sp = QSplitter(self)
602        l.addWidget(sp)
603        sp.addWidget(s), sp.addWidget(p)
604
605        self.bb.clear()
606        self.bb.addButton(QDialogButtonBox.StandardButton.Close)
607        l.addWidget(self.bb)
608
609        if self.theme.count() > 0:
610            self.show_theme()
611
612    def update_theme(self, name):
613        data = tprefs['custom_themes'][name]
614        extra = set(data) - set(THEMES[default_theme()])
615        missing = set(THEMES[default_theme()]) - set(data)
616        for k in extra:
617            data.pop(k)
618        for k in missing:
619            data[k] = dict(THEMES[default_theme()][k]._asdict())
620            for nk, nv in iteritems(data[k]):
621                if isinstance(nv, QBrush):
622                    data[k][nk] = str(nv.color().name())
623        if extra or missing:
624            tprefs['custom_themes'][name] = data
625        return data
626
627    def show_theme(self):
628        if self.block_show:
629            return
630        for c in self.properties:
631            c.changed.disconnect()
632            self.cl.removeWidget(c)
633            c.setParent(None)
634            c.deleteLater()
635        self.properties = []
636        name = str(self.theme.currentText())
637        if not name:
638            return
639        data = self.update_theme(name)
640        maxw = 0
641        for k in sorted(data):
642            w = Property(k, data[k], parent=self)
643            w.changed.connect(self.changed)
644            self.properties.append(w)
645            maxw = max(maxw, w.label.sizeHint().width())
646            self.cl.addWidget(w)
647        for p in self.properties:
648            p.label.setMinimumWidth(maxw), p.label.setMaximumWidth(maxw)
649        self.preview.apply_theme(read_custom_theme(data))
650
651    @property
652    def theme_name(self):
653        return str(self.theme.currentText())
654
655    def changed(self):
656        name = self.theme_name
657        data = self.update_theme(name)
658        self.preview.apply_theme(read_custom_theme(data))
659
660    def create_new_theme(self):
661        d = CreateNewTheme(self)
662        if d.exec() == QDialog.DialogCode.Accepted:
663            name = '*' + d.theme_name
664            base = str(d.base.currentText())
665            theme = {}
666            for key, val in iteritems(THEMES[base]):
667                theme[key] = {k:col_to_string(v.color()) if isinstance(v, QBrush) else v for k, v in iteritems(val._asdict())}
668            tprefs['custom_themes'][name] = theme
669            tprefs['custom_themes'] = tprefs['custom_themes']
670            t = self.theme
671            self.block_show = True
672            t.clear(), t.addItems(sorted(custom_theme_names()))
673            t.setCurrentIndex(t.findText(name))
674            self.block_show = False
675            self.show_theme()
676
677    def remove_theme(self):
678        name = self.theme_name
679        if name:
680            tprefs['custom_themes'].pop(name, None)
681            tprefs['custom_themes'] = tprefs['custom_themes']
682            t = self.theme
683            self.block_show = True
684            t.clear(), t.addItems(sorted(custom_theme_names()))
685            if t.count() > 0:
686                t.setCurrentIndex(0)
687            self.block_show = False
688            self.show_theme()
689
690    def sizeHint(self):
691        g = self.screen().availableSize()
692        return QSize(min(1500, g.width() - 25), 650)
693# }}}
694
695
696if __name__ == '__main__':
697    from calibre.gui2 import Application
698    app = Application([])
699    d = ThemeEditor()
700    d.exec()
701    del app
702