Coverage for vibefpmgui/themes.py: 99%
92 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« 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).
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.
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.
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"""
22from typing import NamedTuple
24from qtpy.QtCore import Qt
25from qtpy.QtGui import QColor, QFont, QPalette
26from qtpy.QtWidgets import QApplication, QProxyStyle, QStyle, QStyleFactory, QTabBar
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)
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
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}
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."""
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)
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()
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)
100def styles() -> list[str]:
101 """Qt's styles on this platform (always Fusion)."""
102 return list(QStyleFactory.keys())
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())
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"
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)
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
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)
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())
193def syntax_style() -> str:
194 """The Pygments style the current theme goes with ("" for none)."""
195 return THEMES.get(current, THEMES[SYSTEM]).syntax