Merge pull request #1385 from davvid/bindir
[git-cola.git] / cola / themes.py
blob4106e227c808ca9605ca6a5a01d63756664f1c25
1 """Themes generators"""
2 import os
5 try:
6 import AppKit
7 except ImportError:
8 AppKit = None
9 from qtpy import QtGui
11 from .i18n import N_
12 from .widgets import defs
13 from . import core
14 from . import icons
15 from . import qtutils
16 from . import resources
17 from . import utils
20 class EStylesheet:
21 DEFAULT = 1
22 FLAT = 2
23 CUSTOM = 3 # Files located in ~/.config/git-cola/themes/*.qss
26 class Theme:
27 def __init__(
28 self,
29 name,
30 title,
31 is_dark,
32 style_sheet=EStylesheet.DEFAULT,
33 main_color=None,
34 macos_appearance=None,
36 self.name = name
37 self.title = title
38 self.is_dark = is_dark
39 self.is_palette_dark = None
40 self.style_sheet = style_sheet
41 self.main_color = main_color
42 self.macos_appearance = macos_appearance
43 self.disabled_text_color = None
44 self.text_color = None
45 self.highlight_color = None
46 self.background_color = None
47 self.palette = None
49 def build_style_sheet(self, app_palette):
50 if self.style_sheet == EStylesheet.CUSTOM:
51 return self.style_sheet_custom(app_palette)
52 if self.style_sheet == EStylesheet.FLAT:
53 return self.style_sheet_flat()
54 window = app_palette.color(QtGui.QPalette.Window)
55 self.is_palette_dark = window.lightnessF() < 0.5
56 return style_sheet_default(app_palette)
58 def build_palette(self, app_palette):
59 QPalette = QtGui.QPalette
60 palette_dark = app_palette.color(QPalette.Base).lightnessF() < 0.5
61 if self.is_palette_dark is None:
62 self.is_palette_dark = palette_dark
64 if palette_dark and self.is_dark:
65 self.palette = app_palette
66 return app_palette
67 if not palette_dark and not self.is_dark:
68 self.palette = app_palette
69 return app_palette
70 if self.is_dark:
71 background = '#202025'
72 else:
73 background = '#edeef3'
75 bg_color = qtutils.css_color(background)
76 txt_color = qtutils.css_color('#777777')
77 palette = QPalette(bg_color)
78 palette.setColor(QPalette.Base, bg_color)
79 palette.setColor(QPalette.Disabled, QPalette.Text, txt_color)
80 self.background_color = background
81 self.palette = palette
82 return palette
84 def style_sheet_flat(self):
85 main_color = self.main_color
86 color = qtutils.css_color(main_color)
87 color_rgb = qtutils.rgb_css(color)
88 self.is_palette_dark = self.is_dark
90 if self.is_dark:
91 background = '#2e2f30'
92 field = '#383a3c'
93 grayed = '#06080a'
94 button_text = '#000000'
95 field_text = '#d0d0d0'
96 darker = qtutils.hsl_css(
97 color.hslHueF(), color.hslSaturationF() * 0.3, color.lightnessF() * 1.3
99 lighter = qtutils.hsl_css(
100 color.hslHueF(), color.hslSaturationF() * 0.7, color.lightnessF() * 0.6
102 focus = qtutils.hsl_css(
103 color.hslHueF(), color.hslSaturationF() * 0.7, color.lightnessF() * 0.7
105 else:
106 background = '#edeef3'
107 field = '#ffffff'
108 grayed = '#a2a2b0'
109 button_text = '#ffffff'
110 field_text = '#000000'
111 darker = qtutils.hsl_css(
112 color.hslHueF(), color.hslSaturationF(), color.lightnessF() * 0.4
114 lighter = qtutils.hsl_css(color.hslHueF(), color.hslSaturationF(), 0.92)
115 focus = color_rgb
117 self.disabled_text_color = grayed
118 self.text_color = field_text
119 self.highlight_color = lighter
120 self.background_color = background
122 return """
123 /* regular widgets */
124 * {{
125 background-color: {background};
126 color: {field_text};
127 selection-background-color: {lighter};
128 alternate-background-color: {field};
129 selection-color: {field_text};
130 show-decoration-selected: 1;
131 spacing: 2px;
134 /* Focused widths get a thin border */
135 QTreeView:focus, QListView:focus,
136 QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {{
137 border-width: 1px;
138 border-style: solid;
139 border-color: {focus};
142 QWidget:disabled {{
143 border-color: {grayed};
144 color: {grayed};
146 QDockWidget > QFrame {{
147 margin: 0px 0px 0px 0px;
149 QPlainTextEdit, QLineEdit, QTextEdit, QAbstractItemView,
150 QAbstractSpinBox {{
151 background-color: {field};
152 border-color: {grayed};
153 border-style: solid;
154 border-width: 1px;
156 QAbstractItemView::item:selected {{
157 background-color: {lighter};
159 QAbstractItemView::item:hover {{
160 background-color: {lighter};
162 QLabel {{
163 color: {darker};
164 background-color: transparent;
166 DockTitleBarWidget {{
167 padding-bottom: 4px;
170 /* buttons */
171 QPushButton[flat="false"] {{
172 background-color: {button};
173 color: {button_text};
174 border-radius: 2px;
175 border-width: 0;
176 margin-bottom: 1px;
177 min-width: 55px;
178 padding: 4px 5px;
180 QPushButton[flat="true"], QToolButton {{
181 background-color: transparent;
182 border-radius: 0px;
184 QPushButton[flat="true"] {{
185 margin-bottom: 10px;
187 QPushButton:hover, QToolButton:hover {{
188 background-color: {darker};
190 QPushButton[flat="false"]:pressed, QToolButton:pressed {{
191 background-color: {darker};
192 margin: 1px 1px 2px 1px;
194 QPushButton:disabled {{
195 background-color: {grayed};
196 color: {field};
197 padding-left: 5px;
198 padding-top: 5px;
200 QPushButton[flat="true"]:disabled {{
201 background-color: transparent;
204 /*menus*/
205 QMenuBar {{
206 background-color: {background};
207 color: {field_text};
208 border-width: 0;
209 padding: 1px;
211 QMenuBar::item {{
212 background: transparent;
214 QMenuBar::item:selected {{
215 background: {button};
217 QMenuBar::item:pressed {{
218 background: {button};
220 QMenu {{
221 background-color: {field};
223 QMenu::separator {{
224 background: {background};
225 height: 1px;
228 /* combo box */
229 QComboBox {{
230 background-color: {field};
231 border-color: {grayed};
232 border-style: solid;
233 color: {field_text};
234 border-radius: 0px;
235 border-width: 1px;
236 margin-bottom: 1px;
237 padding: 0 5px;
239 QComboBox::drop-down {{
240 border-color: {field_text} {field} {field} {field};
241 border-style: solid;
242 subcontrol-position: right;
243 border-width: 4px 3px 0 3px;
244 height: 0;
245 margin-right: 5px;
246 width: 0;
248 QComboBox::drop-down:hover {{
249 border-color: {button} {field} {field} {field};
251 QComboBox:item {{
252 background-color: {button};
253 color: {button_text};
254 border-width: 0;
255 height: 22px;
257 QComboBox:item:selected {{
258 background-color: {darker};
259 color: {button_text};
261 QComboBox:item:checked {{
262 background-color: {darker};
263 color: {button_text};
266 /* MainWindow separator */
267 QMainWindow::separator {{
268 width: {separator}px;
269 height: {separator}px;
271 QMainWindow::separator:hover {{
272 background: {focus};
275 /* scroll bar */
276 QScrollBar {{
277 background-color: {field};
278 border: 0;
280 QScrollBar::handle {{
281 background: {background}
283 QScrollBar::handle:hover {{
284 background: {button}
286 QScrollBar:horizontal {{
287 margin: 0 11px 0 11px;
288 height: 10px;
290 QScrollBar:vertical {{
291 margin: 11px 0 11px 0;
292 width: 10px;
294 QScrollBar::add-line, QScrollBar::sub-line {{
295 background: {background};
296 subcontrol-origin: margin;
298 QScrollBar::sub-line:horizontal {{ /*required by a buggy Qt version*/
299 subcontrol-position: left;
301 QScrollBar::add-line:hover, QScrollBar::sub-line:hover {{
302 background: {button};
304 QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
305 width: 10px;
307 QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
308 height: 10px;
310 QScrollBar:left-arrow, QScrollBar::right-arrow,
311 QScrollBar::up-arrow, QScrollBar::down-arrow {{
312 border-style: solid;
313 height: 0;
314 width: 0;
316 QScrollBar:right-arrow {{
317 border-color: {background} {background}
318 {background} {darker};
319 border-width: 3px 0 3px 4px;
321 QScrollBar:left-arrow {{
322 border-color: {background} {darker}
323 {background} {background};
324 border-width: 3px 4px 3px 0;
326 QScrollBar:up-arrow {{
327 border-color: {background} {background}
328 {darker} {background};
329 border-width: 0 3px 4px 3px;
331 QScrollBar:down-arrow {{
332 border-color: {darker} {background}
333 {background} {background};
334 border-width: 4px 3px 0 3px;
336 QScrollBar:right-arrow:hover {{
337 border-color: {button} {button}
338 {button} {darker};
340 QScrollBar:left-arrow:hover {{
341 border-color: {button} {darker}
342 {button} {button};
344 QScrollBar:up-arrow:hover {{
345 border-color: {button} {button}
346 {darker} {button};
348 QScrollBar:down-arrow:hover {{
349 border-color: {darker} {button}
350 {button} {button};
353 /* tab bar (stacked & docked widgets) */
354 QTabBar::tab {{
355 background: transparent;
356 border-color: {darker};
357 border-width: 1px;
358 margin: 1px;
359 padding: 3px 5px;
361 QTabBar::tab:selected {{
362 background: {grayed};
365 /* check box */
366 QCheckBox {{
367 spacing: 8px;
368 margin: 4px;
369 background-color: transparent;
371 QCheckBox::indicator {{
372 background-color: {field};
373 border-color: {darker};
374 border-style: solid;
375 subcontrol-position: left;
376 border-width: 1px;
377 height: 13px;
378 width: 13px;
380 QCheckBox::indicator:unchecked:hover {{
381 background-color: {button};
383 QCheckBox::indicator:unchecked:pressed {{
384 background-color: {darker};
386 QCheckBox::indicator:checked {{
387 background-color: {darker};
389 QCheckBox::indicator:checked:hover {{
390 background-color: {button};
392 QCheckBox::indicator:checked:pressed {{
393 background-color: {field};
396 /* radio checkbox */
397 QRadioButton {{
398 spacing: 8px;
399 margin: 4px;
401 QRadioButton::indicator {{
402 height: 0.75em;
403 width: 0.75em;
406 /* progress bar */
407 QProgressBar {{
408 background-color: {field};
409 border: 1px solid {darker};
411 QProgressBar::chunk {{
412 background-color: {button};
413 width: 1px;
416 /* spin box */
417 QAbstractSpinBox::up-button, QAbstractSpinBox::down-button {{
418 background-color: transparent;
420 QAbstractSpinBox::up-arrow, QAbstractSpinBox::down-arrow {{
421 border-style: solid;
422 height: 0;
423 width: 0;
425 QAbstractSpinBox::up-arrow {{
426 border-color: {field} {field} {darker} {field};
427 border-width: 0 3px 4px 3px;
429 QAbstractSpinBox::up-arrow:hover {{
430 border-color: {field} {field} {button} {field};
431 border-width: 0 3px 4px 3px;
433 QAbstractSpinBox::down-arrow {{
434 border-color: {darker} {field} {field} {field};
435 border-width: 4px 3px 0 3px;
437 QAbstractSpinBox::down-arrow:hover {{
438 border-color: {button} {field} {field} {field};
439 border-width: 4px 3px 0 3px;
442 /* dialogs */
443 QDialog > QFrame {{
444 margin: 2px 2px 2px 2px;
447 /* headers */
448 QHeaderView {{
449 color: {field_text};
450 border-style: solid;
451 border-width: 0 0 1px 0;
452 border-color: {grayed};
454 QHeaderView::section {{
455 border-style: solid;
456 border-right: 1px solid {grayed};
457 background-color: {background};
458 color: {field_text};
459 padding-left: 4px;
462 /* headers */
463 QHeaderView {{
464 color: {field_text};
465 border-style: solid;
466 border-width: 0 0 1px 0;
467 border-color: {grayed};
469 QHeaderView::section {{
470 border-style: solid;
471 border-right: 1px solid {grayed};
472 background-color: {background};
473 color: {field_text};
474 padding-left: 4px;
477 """.format(
478 background=background,
479 field=field,
480 button=color_rgb,
481 darker=darker,
482 lighter=lighter,
483 grayed=grayed,
484 button_text=button_text,
485 field_text=field_text,
486 separator=defs.separator,
487 focus=focus,
490 def style_sheet_custom(self, app_palette):
491 """Get custom style sheet.
492 File name is saved in variable self.name.
493 If user has deleted file, use default style"""
495 # check if path exists
496 filename = resources.config_home('themes', self.name + '.qss')
497 if not core.exists(filename):
498 return style_sheet_default(app_palette)
499 try:
500 return core.read(filename)
501 except OSError as err:
502 core.print_stderr(f'warning: unable to read custom theme {filename}: {err}')
503 return style_sheet_default(app_palette)
505 def get_palette(self):
506 """Get a QPalette for the current theme"""
507 if self.palette is None:
508 palette = qtutils.current_palette()
509 else:
510 palette = self.palette
511 return palette
513 def highlight_color_rgb(self):
514 """Return an rgb(r,g,b) CSS color value for the selection highlight"""
515 if self.highlight_color:
516 highlight_rgb = self.highlight_color
517 elif self.main_color:
518 highlight_rgb = qtutils.rgb_css(
519 qtutils.css_color(self.main_color).lighter()
521 else:
522 palette = self.get_palette()
523 color = palette.color(QtGui.QPalette.Highlight)
524 highlight_rgb = qtutils.rgb_css(color)
525 return highlight_rgb
527 def selection_color(self):
528 """Return a color suitable for selections"""
529 highlight = qtutils.css_color(self.highlight_color_rgb())
530 if highlight.lightnessF() > 0.7: # Avoid clamping light colors to white.
531 color = highlight
532 else:
533 color = highlight.lighter()
534 return color
536 def text_colors_rgb(self):
537 """Return a pair of rgb(r,g,b) CSS color values for text and selected text"""
538 if self.text_color:
539 text_rgb = self.text_color
540 highlight_text_rgb = self.text_color
541 else:
542 palette = self.get_palette()
543 color = palette.text().color()
544 text_rgb = qtutils.rgb_css(color)
546 color = palette.highlightedText().color()
547 highlight_text_rgb = qtutils.rgb_css(color)
548 return text_rgb, highlight_text_rgb
550 def disabled_text_color_rgb(self):
551 """Return an rgb(r,g,b) CSS color value for the disabled text"""
552 if self.disabled_text_color:
553 disabled_text_rgb = self.disabled_text_color
554 else:
555 palette = self.get_palette()
556 color = palette.color(QtGui.QPalette.Disabled, QtGui.QPalette.Text)
557 disabled_text_rgb = qtutils.rgb_css(color)
558 return disabled_text_rgb
560 def background_color_rgb(self):
561 """Return an rgb(r,g,b) CSS color value for the window background"""
562 if self.background_color:
563 background_color = self.background_color
564 else:
565 palette = self.get_palette()
566 window = palette.color(QtGui.QPalette.Base)
567 background_color = qtutils.rgb_css(window)
568 return background_color
571 def style_sheet_default(palette):
572 highlight = palette.color(QtGui.QPalette.Highlight)
573 shadow = palette.color(QtGui.QPalette.Shadow)
574 base = palette.color(QtGui.QPalette.Base)
576 highlight_rgb = qtutils.rgb_css(highlight)
577 shadow_rgb = qtutils.rgb_css(shadow)
578 base_rgb = qtutils.rgb_css(base)
580 return """
581 QCheckBox::indicator {{
582 width: {checkbox_size}px;
583 height: {checkbox_size}px;
585 QCheckBox::indicator::unchecked {{
586 border: {checkbox_border}px solid {shadow_rgb};
587 background: {base_rgb};
589 QCheckBox::indicator::checked {{
590 image: url({checkbox_icon});
591 border: {checkbox_border}px solid {shadow_rgb};
592 background: {base_rgb};
595 QRadioButton::indicator {{
596 width: {radio_size}px;
597 height: {radio_size}px;
599 QRadioButton::indicator::unchecked {{
600 border: {radio_border}px solid {shadow_rgb};
601 border-radius: {radio_radius}px;
602 background: {base_rgb};
604 QRadioButton::indicator::checked {{
605 image: url({radio_icon});
606 border: {radio_border}px solid {shadow_rgb};
607 border-radius: {radio_radius}px;
608 background: {base_rgb};
611 QSplitter::handle:hover {{
612 background: {highlight_rgb};
615 QMainWindow::separator {{
616 background: none;
617 width: {separator}px;
618 height: {separator}px;
620 QMainWindow::separator:hover {{
621 background: {highlight_rgb};
624 """.format(
625 separator=defs.separator,
626 highlight_rgb=highlight_rgb,
627 shadow_rgb=shadow_rgb,
628 base_rgb=base_rgb,
629 checkbox_border=defs.border,
630 checkbox_icon=icons.check_name(),
631 checkbox_size=defs.checkbox,
632 radio_border=defs.radio_border,
633 radio_icon=icons.dot_name(),
634 radio_radius=defs.radio // 2,
635 radio_size=defs.radio,
639 def get_all_themes():
640 themes = [
641 Theme(
642 'default',
643 N_('Default'),
644 False,
645 style_sheet=EStylesheet.DEFAULT,
646 main_color=None,
650 if utils.is_darwin():
651 themes.extend(get_macos_themes().values())
653 themes.extend([
654 Theme(
655 'flat-light-blue',
656 N_('Flat light blue'),
657 False,
658 style_sheet=EStylesheet.FLAT,
659 main_color='#5271cc',
661 Theme(
662 'flat-light-red',
663 N_('Flat light red'),
664 False,
665 style_sheet=EStylesheet.FLAT,
666 main_color='#cc5452',
668 Theme(
669 'flat-light-grey',
670 N_('Flat light grey'),
671 False,
672 style_sheet=EStylesheet.FLAT,
673 main_color='#707478',
675 Theme(
676 'flat-light-green',
677 N_('Flat light green'),
678 False,
679 style_sheet=EStylesheet.FLAT,
680 main_color='#42a65c',
682 Theme(
683 'flat-dark-blue',
684 N_('Flat dark blue'),
685 True,
686 style_sheet=EStylesheet.FLAT,
687 main_color='#5271cc',
689 Theme(
690 'flat-dark-red',
691 N_('Flat dark red'),
692 True,
693 style_sheet=EStylesheet.FLAT,
694 main_color='#cc5452',
696 Theme(
697 'flat-dark-grey',
698 N_('Flat dark grey'),
699 True,
700 style_sheet=EStylesheet.FLAT,
701 main_color='#aaaaaa',
703 Theme(
704 'flat-dark-green',
705 N_('Flat dark green'),
706 True,
707 style_sheet=EStylesheet.FLAT,
708 main_color='#42a65c',
712 # check if themes path exists in user folder
713 path = resources.config_home('themes')
714 if not os.path.isdir(path):
715 return themes
717 # Gather Qt .qss stylesheet themes
718 try:
719 filenames = core.listdir(path)
720 except OSError:
721 return themes
723 for filename in filenames:
724 name, ext = os.path.splitext(filename)
725 if ext == '.qss':
726 themes.append(Theme(name, N_(name), False, EStylesheet.CUSTOM, None))
728 return themes
731 def apply_platform_theme(theme):
732 """Apply platform-specific themes (e.g. dark mode on macOS)"""
733 # https://developer.apple.com/documentation/appkit/nsappearancecustomization/choosing_a_specific_appearance_for_your_macos_app
734 # https://github.com/git-cola/git-cola/issues/905#issuecomment-461118465
735 if utils.is_darwin():
736 if AppKit is None:
737 return
738 app = AppKit.NSApplication.sharedApplication()
739 macos_themes = get_macos_themes()
740 try:
741 macos_appearance = macos_themes[theme].macos_appearance
742 except KeyError:
743 return
744 if macos_appearance is None:
745 return
746 appearance = AppKit.NSAppearance.appearanceNamed_(macos_appearance)
747 app.setAppearance_(appearance)
750 def get_macos_themes():
751 """Get a mapping from theme names to macOS NSAppearanceName values"""
752 themes = {}
753 if AppKit is None:
754 return themes
756 def add_macos_theme(name, description, is_dark, attr):
757 """Add an AppKit theme if it exists"""
758 if hasattr(AppKit, attr):
759 themes[name] = Theme(
760 name, description, is_dark, macos_appearance=getattr(AppKit, attr)
763 add_macos_theme(
764 'macos-aqua-light', N_('MacOS Aqua light'), False, 'NSAppearanceNameAqua'
766 add_macos_theme(
767 'macos-aqua-dark',
768 N_('MacOS Aqua dark'),
769 True,
770 'NSAppearanceNameDarkAqua',
772 add_macos_theme(
773 'macos-vibrant-light',
774 N_('MacOS Vibrant light'),
775 False,
776 'NSAppearanceNameVibrantLight',
778 add_macos_theme(
779 'macos-vibrant-dark',
780 N_('MacOS Vibrant dark'),
781 True,
782 'NSAppearanceNameVibrantDark',
784 return themes
787 def options(themes=None):
788 """Return a dictionary mapping display names to theme names"""
789 if themes is None:
790 themes = get_all_themes()
791 return [(theme.title, theme.name) for theme in themes]
794 def find_theme(name):
795 themes = get_all_themes()
796 for item in themes:
797 if item.name == name:
798 return item
799 return themes[0]