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