Coverage for vibefpmgui/themes.py: 99%

92 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""*View → Theme*: the colors of the whole window (the editor's syntax colors 

2are *View → Syntax Colors*; with those on Automatic, a theme picks the 

3matching Pygments style). 

4 

5*System*, *Light* and *Dark* keep the platform's own look (on macOS: its 

6buttons, tabs and dialogs) and only choose light or dark, through Qt's color 

7scheme. The colorful themes need a style that draws with the palette's 

8colors (macOS's doesn't), so they use Qt's Fusion style with a palette of 

9their own; the color scheme is still set to light or dark to match, so the 

10window's title bar and native dialogs agree. 

11 

12*View → Window Style* chooses the style itself instead (any of Qt's on this 

13platform: Fusion everywhere, macOS on a Mac, windows11 / windowsvista on 

14Windows…). Fusion draws the panels' dividers with a grip of dots, macOS's a 

15plain line; only Fusion (and the plain Windows style) show a colorful 

16theme's colors throughout. 

17 

18Either style is wrapped in `StillStyle`, which turns off the moving of 

19panels: Qt's dock layout could otherwise keep "animating" forever on macOS. 

20""" 

21 

22from typing import NamedTuple 

23 

24from qtpy.QtCore import Qt 

25from qtpy.QtGui import QColor, QFont, QPalette 

26from qtpy.QtWidgets import QApplication, QProxyStyle, QStyle, QStyleFactory, QTabBar 

27 

28 

29class Theme(NamedTuple): 

30 title: str 

31 dark: bool 

32 window: str = "" # "" for the platform's own (System, Light, Dark) 

33 base: str = "" # text fields, lists, the editor's surroundings 

34 text: str = "" 

35 button: str = "" 

36 highlight: str = "" # selection and accents 

37 highlighted_text: str = "" 

38 link: str = "" 

39 syntax: str = "" # the Pygments style for Syntax Colors → Automatic ("": light/dark default) 

40 

41 @property 

42 def native(self) -> bool: 

43 """The platform's look (only light or dark chosen), not our palette.""" 

44 return not self.window 

45 

46 

47SYSTEM = "system" 

48THEMES: dict[str, Theme] = { 

49 SYSTEM: Theme("System (follow light/dark mode)", False), 

50 "light": Theme("Light", False), 

51 "dark": Theme("Dark", True), 

52 # Dark, colorful 

53 "nord": Theme("Nord", True, "#2e3440", "#272c36", "#d8dee9", "#3b4252", "#88c0d0", "#2e3440", "#81a1c1", 

54 "nord"), 

55 "dracula": Theme("Dracula", True, "#282a36", "#21222c", "#f8f8f2", "#44475a", "#bd93f9", "#282a36", 

56 "#8be9fd", "dracula"), 

57 "solarized-dark": Theme("Solarized Dark", True, "#002b36", "#073642", "#93a1a1", "#0a4352", "#268bd2", 

58 "#fdf6e3", "#2aa198", "solarized-dark"), 

59 "gruvbox-dark": Theme("Gruvbox Dark", True, "#282828", "#1d2021", "#ebdbb2", "#3c3836", "#d79921", "#282828", 

60 "#83a598", "gruvbox-dark"), 

61 "one-dark": Theme("One Dark", True, "#282c34", "#21252b", "#abb2bf", "#323842", "#61afef", "#282c34", 

62 "#56b6c2", "one-dark"), 

63 # Light, colorful 

64 "solarized-light": Theme("Solarized Light", False, "#eee8d5", "#fdf6e3", "#586e75", "#e4ddc8", "#268bd2", 

65 "#fdf6e3", "#2aa198", "solarized-light"), 

66 "gruvbox-light": Theme("Gruvbox Light", False, "#ebdbb2", "#fbf1c7", "#3c3836", "#d5c4a1", "#b57614", 

67 "#fbf1c7", "#076678", "gruvbox-light"), 

68 "ocean": Theme("Ocean", False, "#e3eef9", "#f7fbff", "#1b2a3a", "#d0e2f4", "#2f7fd8", "#ffffff", "#1f6fbf", 

69 "friendly"), 

70 "mint": Theme("Mint", False, "#e6f4ea", "#f8fffa", "#1f3326", "#d2eadb", "#2f9e5c", "#ffffff", "#1d7a47", 

71 "tango"), 

72} 

73 

74class StillStyle(QProxyStyle): 

75 """A style (the platform's, or Fusion) whose widgets don't animate their 

76 geometry. Even with the main window's AnimatedDocks off, Qt moves panels 

77 with a zero-length animation whenever the style has an animation 

78 duration; on macOS each one's end could re-lay out the panels' tab bars, 

79 which started another, forever (a frozen window at 100% CPU). With no 

80 duration, Qt moves them in one go.""" 

81 

82 def styleHint(self, hint, option=None, widget=None, data=None): 

83 if hint == QStyle.SH_Widget_Animation_Duration: 

84 return 0 

85 return super().styleHint(hint, option, widget, data) 

86 

87 

88def style_name(style: QStyle) -> str: 

89 """The name of a style, or of the one a `StillStyle` wraps.""" 

90 return (style.baseStyle() if isinstance(style, QProxyStyle) else style).name() 

91 

92 

93current = SYSTEM # the theme in use (see apply) 

94_native_style = "" # the platform's style ("macOS", "Windows", …), to go back to from Fusion 

95AUTOMATIC = "" # View → Window Style: the theme's (the platform's, or Fusion for a colorful theme) 

96chosen_style = AUTOMATIC # View → Window Style (see window_style) 

97PALETTE_STYLES = {"fusion", "windows"} # (styles that draw with a colorful theme's palette) 

98 

99 

100def styles() -> list[str]: 

101 """Qt's styles on this platform (always Fusion).""" 

102 return list(QStyleFactory.keys()) 

103 

104 

105def native_style() -> str: 

106 """The platform's own style (its name as Qt has it).""" 

107 return _native_style or style_name(QApplication.instance().style()) 

108 

109 

110def window_style(theme: Theme) -> str: 

111 """The style to use with a theme: the one chosen in View → Window Style 

112 if it's here (a setting from another platform may not be), else the 

113 platform's for System / Light / Dark and Fusion for a colorful theme.""" 

114 available = {name.lower(): name for name in styles()} 

115 if chosen_style and chosen_style.lower() in available: 

116 return available[chosen_style.lower()] 

117 return native_style() if theme.native else "Fusion" 

118 

119 

120def set_window_style(name: str): 

121 """View → Window Style (AUTOMATIC: the theme's), applied now.""" 

122 global chosen_style 

123 chosen_style = name 

124 apply(current) 

125 

126 

127def palette(theme: Theme) -> QPalette: 

128 """A full palette from a theme's few colors.""" 

129 window, base, text = QColor(theme.window), QColor(theme.base), QColor(theme.text) 

130 button, highlight = QColor(theme.button), QColor(theme.highlight) 

131 p = QPalette() 

132 for group in (QPalette.Active, QPalette.Inactive, QPalette.Disabled): 

133 p.setColor(group, QPalette.Window, window) 

134 p.setColor(group, QPalette.Base, base) 

135 p.setColor(group, QPalette.AlternateBase, base.lighter(112) if theme.dark else base.darker(104)) 

136 p.setColor(group, QPalette.Button, button) 

137 p.setColor(group, QPalette.ToolTipBase, button) 

138 p.setColor(group, QPalette.Highlight, highlight) 

139 p.setColor(group, QPalette.HighlightedText, QColor(theme.highlighted_text)) 

140 p.setColor(group, QPalette.Link, QColor(theme.link)) 

141 p.setColor(group, QPalette.LinkVisited, QColor(theme.link).darker(120)) 

142 p.setColor(group, QPalette.BrightText, QColor("#ff6b6b")) 

143 # Shades for frames and bevels, around the button color. 

144 p.setColor(group, QPalette.Light, button.lighter(130)) 

145 p.setColor(group, QPalette.Midlight, button.lighter(115)) 

146 p.setColor(group, QPalette.Mid, button.darker(130)) 

147 p.setColor(group, QPalette.Dark, button.darker(160)) 

148 p.setColor(group, QPalette.Shadow, QColor(0, 0, 0, 160)) 

149 muted = QColor(text) 

150 muted.setAlpha(140) 

151 p.setColor(group, QPalette.PlaceholderText, muted) 

152 faint = text if group != QPalette.Disabled else muted 

153 for role in (QPalette.WindowText, QPalette.Text, QPalette.ButtonText, QPalette.ToolTipText): 

154 p.setColor(group, role, faint) 

155 p.setColor(QPalette.Inactive, QPalette.Highlight, highlight.darker(115) if theme.dark else highlight.lighter(115)) 

156 return p 

157 

158 

159def apply(key: str): 

160 """Use a theme for the whole app (an unknown key: System). Qt then sends 

161 every window a palette change, which the app's panels follow.""" 

162 global current, _native_style 

163 app = QApplication.instance() 

164 theme = THEMES.get(key) or THEMES[SYSTEM] 

165 current = key if key in THEMES else SYSTEM 

166 if not _native_style: 

167 _native_style = style_name(app.style()) 

168 hints = app.styleHints() 

169 if hasattr(hints, "setColorScheme"): # (Qt 6.8+) 169 ↛ 173line 169 didn't jump to line 173 because the condition on line 169 was always true

170 scheme = Qt.ColorScheme.Unknown if current == SYSTEM else \ 

171 Qt.ColorScheme.Dark if theme.dark else Qt.ColorScheme.Light 

172 hints.setColorScheme(scheme) 

173 wanted = window_style(theme) 

174 if not isinstance(app.style(), StillStyle) or style_name(app.style()).lower() != wanted.lower(): 

175 app.setStyle(StillStyle(wanted)) 

176 _unpolish_tab_bars(app) 

177 app.setPalette(QPalette() if theme.native else palette(theme)) # (native: the platform's, for the scheme) 

178 

179 

180def _unpolish_tab_bars(app: QApplication): 

181 """The macOS style gives a document-mode tab bar (the editor's tabs, the 

182 panels') a near-black text color and a small font of its own when it 

183 polishes it. At startup the app's font and palette replace them; after a 

184 theme's change of style (Fusion → macOS) nothing did: dark text on a dark 

185 bar, and names cut off (the tabs measured in the small font). Back to the 

186 app's, as at startup.""" 

187 for widget in app.allWidgets(): 

188 if isinstance(widget, QTabBar) and widget.documentMode(): 

189 widget.setPalette(QPalette()) 

190 widget.setFont(QFont()) 

191 

192 

193def syntax_style() -> str: 

194 """The Pygments style the current theme goes with ("" for none).""" 

195 return THEMES.get(current, THEMES[SYSTEM]).syntax