f169ee0933
* Create widget gallery dialog * Add WidgetGallery to debug dialog * Use enum for its intended purpose * Rename "reduced-motion" to "reduce-motion" * Add another border-radius value and make former large radius a bit smaller. * Revamp preferences, add minimalist mode Also: - create additional and missing widget styles and tweak existing ones - use single profile entry to set widget styles and reduce choices to Anki and Native * Indent QTabBar style definitions * Add missing styles for QPushButton states * Fix QTableView background * Remove unused layout from Preferences * Fix QTabView focused tab style * Highlight QCheckBox and QRadioButton when focused * Fix toolbar styles * Reorder preferences * Add setting to hide bottom toolbar * Move toolbar settings above minimalist modes * Remove unused lines * Implement proper full-screen mode * Sort imports * Tweak deck overview appearance in minimalist mode * Undo TitledContainer changes since nobody asked for that * Remove dynamic toolbar background from minimalist mode * Tweak buttons in minimalist mode * Fix some issues * Reduce theme check interval to 5s on Linux * Increase hide timer interval to 2s * Collapse toolbars with slight delay when moving to review state This should ensure the bottom toolbar collapses too. * Allow users to make hiding exclusive to full screen * Rename full screen option * Fix hide mode dropdown ignoring checkbox state on startup * Fix typing issue * Refine background image handling Giving the toolbar body the main webview height ensures background-size: cover behaves exactly the same. To prevent an override of other background properties, users are advised to only set background-images via the background-image property, not the background shorthand. * Fix top toolbar getting huge when switching modes The issue was caused by the min-height hack to align the background images. A call to web.adjustHeightToFit would set the toolbar to the same height as the main webview, as the function makes use of document.offsetHeight. * Prevent scrollbar from appearing on bottom toolbar resize * Cleanup * Put review tab before editing; fix some tab orders * Rename 'network' to 'syncing' * Fix bottom toolbar disappearing on UI > 100 * Improve Preferences layout by adding vertical spacers to the bottom also make the hiding of video_driver and its label more obvious in preferences.py. * Fix bottom toolbar animating on startup Also fix bottom toolbar not appearing when unchecking hide mode in reviewer. * Hide/Show menubar in fullscreen mode along with toolbar * Attempt to fix broken native theme on macOS * Format * Improve native theme on other systems by not forcing palette with the caveat that theme switching can get weird. * Fix theme switching in native style * Remove redundant condition * Add back check for Qt5 to prevent theme issues * Add check for macOS before setting fusion theme * Do not force scrollbar styles on macOS * Remove all of that crazy theme logic * Use canvas instead of button-bg for ColorRole.Button * Make sure Anki style is always based on Fusion otherwise we can't guarantee the same look on all systems. * Explicitly apply default style when Anki style is not selected This should fix the style not switching back after it was selected. * Remove reduncant default_palette * Revert 8af4c1cc2 On Mac with native theme, both Qt5 and Qt6 look correct already. On the Anki theme, without this change, we get the fusion-style scrollbars instead of the rounded ones. * Rename AnkiStyles enum to WidgetStyle * Fix theme switching shades on same theme * Format * Remove unused placeholderText that caused an error when opening the widget gallery on Qt5. * Check for full screen windowState using bitwise operator to prevent error in Qt5. Credit: https://stackoverflow.com/a/65425151 * Hide style option on Windows also exclude native option from dropdown just in case. * Format * Minor naming tweak
427 lines
13 KiB
Python
427 lines
13 KiB
Python
# Copyright: Ankitects Pty Ltd and contributors
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
|
|
from __future__ import annotations
|
|
|
|
import enum
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from typing import Callable, List, Tuple
|
|
|
|
import anki.lang
|
|
import aqt
|
|
from anki.lang import is_rtl
|
|
from anki.utils import is_lin, is_mac, is_win
|
|
from aqt import QApplication, colors, gui_hooks
|
|
from aqt.qt import (
|
|
QColor,
|
|
QIcon,
|
|
QPainter,
|
|
QPalette,
|
|
QPixmap,
|
|
QStyleFactory,
|
|
Qt,
|
|
qtmajor,
|
|
qtminor,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class ColoredIcon:
|
|
path: str
|
|
color: dict[str, str]
|
|
|
|
def current_color(self, night_mode: bool) -> str:
|
|
if night_mode:
|
|
return self.color.get("dark", "")
|
|
else:
|
|
return self.color.get("light", "")
|
|
|
|
def with_color(self, color: dict[str, str]) -> ColoredIcon:
|
|
return ColoredIcon(path=self.path, color=color)
|
|
|
|
|
|
class WidgetStyle(enum.IntEnum):
|
|
ANKI = 0
|
|
NATIVE = 1
|
|
|
|
|
|
class Theme(enum.IntEnum):
|
|
FOLLOW_SYSTEM = 0
|
|
LIGHT = 1
|
|
DARK = 2
|
|
|
|
|
|
class ThemeManager:
|
|
_night_mode_preference = False
|
|
_icon_cache_light: dict[str, QIcon] = {}
|
|
_icon_cache_dark: dict[str, QIcon] = {}
|
|
_icon_size = 128
|
|
_dark_mode_available: bool | None = None
|
|
_default_style: str | None = None
|
|
_current_widget_style: WidgetStyle | None = None
|
|
|
|
def rtl(self) -> bool:
|
|
return is_rtl(anki.lang.current_lang)
|
|
|
|
def left(self) -> str:
|
|
return "right" if self.rtl() else "left"
|
|
|
|
def right(self) -> str:
|
|
return "left" if self.rtl() else "right"
|
|
|
|
# Qt applies a gradient to the buttons in dark mode
|
|
# from about #505050 to #606060.
|
|
DARK_MODE_BUTTON_BG_MIDPOINT = "#555555"
|
|
|
|
def macos_dark_mode(self) -> bool:
|
|
"True if the user has night mode on."
|
|
if not is_mac:
|
|
return False
|
|
|
|
if not self._night_mode_preference:
|
|
return False
|
|
|
|
if self._dark_mode_available is None:
|
|
self._dark_mode_available = set_macos_dark_mode(True)
|
|
|
|
return self._dark_mode_available
|
|
|
|
def get_night_mode(self) -> bool:
|
|
return self._night_mode_preference
|
|
|
|
def set_night_mode(self, val: bool) -> None:
|
|
self._night_mode_preference = val
|
|
self._update_stat_colors()
|
|
|
|
night_mode = property(get_night_mode, set_night_mode)
|
|
|
|
def themed_icon(self, path: str) -> str:
|
|
"Fetch themed version of svg."
|
|
from aqt.utils import aqt_data_folder
|
|
|
|
if m := re.match(r"(?:mdi:)(.+)$", path):
|
|
name = m.group(1)
|
|
else:
|
|
return path
|
|
|
|
filename = f"{name}-{'dark' if self.night_mode else 'light'}.svg"
|
|
|
|
return (
|
|
os.path.join(aqt_data_folder(), "qt", "icons", filename)
|
|
.replace("\\\\?\\", "")
|
|
.replace("\\", "/")
|
|
)
|
|
|
|
def icon_from_resources(self, path: str | ColoredIcon) -> QIcon:
|
|
"Fetch icon from Qt resources."
|
|
if self.night_mode:
|
|
cache = self._icon_cache_light
|
|
else:
|
|
cache = self._icon_cache_dark
|
|
|
|
if isinstance(path, str):
|
|
key = path
|
|
else:
|
|
key = f"{path.path}-{path.color}"
|
|
|
|
icon = cache.get(key)
|
|
if icon:
|
|
return icon
|
|
|
|
if isinstance(path, str):
|
|
# default black/white
|
|
if "mdi:" in path:
|
|
icon = QIcon(self.themed_icon(path))
|
|
else:
|
|
icon = QIcon(path)
|
|
if self.night_mode:
|
|
img = icon.pixmap(self._icon_size, self._icon_size).toImage()
|
|
img.invertPixels()
|
|
icon = QIcon(QPixmap(img))
|
|
else:
|
|
# specified colours
|
|
icon = QIcon(path.path)
|
|
pixmap = icon.pixmap(16)
|
|
painter = QPainter(pixmap)
|
|
painter.setCompositionMode(
|
|
QPainter.CompositionMode.CompositionMode_SourceIn
|
|
)
|
|
painter.fillRect(pixmap.rect(), QColor(path.current_color(self.night_mode)))
|
|
painter.end()
|
|
icon = QIcon(pixmap)
|
|
return icon
|
|
|
|
return cache.setdefault(path, icon)
|
|
|
|
def body_class(self, night_mode: bool | None = None) -> str:
|
|
"Returns space-separated class list for platform/theme/global settings."
|
|
classes = []
|
|
if is_win:
|
|
classes.append("isWin")
|
|
elif is_mac:
|
|
classes.append("isMac")
|
|
else:
|
|
classes.append("isLin")
|
|
|
|
if night_mode is None:
|
|
night_mode = self.night_mode
|
|
if night_mode:
|
|
classes.extend(["nightMode", "night_mode"])
|
|
if self.macos_dark_mode():
|
|
classes.append("macos-dark-mode")
|
|
if aqt.mw.pm.reduce_motion():
|
|
classes.append("reduce-motion")
|
|
if not aqt.mw.pm.minimalist_mode():
|
|
classes.append("fancy")
|
|
if qtmajor == 5 and qtminor < 15:
|
|
classes.append("no-blur")
|
|
return " ".join(classes)
|
|
|
|
def body_classes_for_card_ord(
|
|
self, card_ord: int, night_mode: bool | None = None
|
|
) -> str:
|
|
"Returns body classes used when showing a card."
|
|
return f"card card{card_ord+1} {self.body_class(night_mode)}"
|
|
|
|
def var(self, vars: dict[str, str]) -> str:
|
|
"""Given day/night colors/props, return the correct one for the current theme."""
|
|
return vars["dark" if self.night_mode else "light"]
|
|
|
|
def qcolor(self, colors: dict[str, str]) -> QColor:
|
|
"""Create QColor instance from CSS string for the current theme."""
|
|
|
|
if m := re.match(
|
|
r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*(\d+\.*\d+?)\)", self.var(colors)
|
|
):
|
|
return QColor(
|
|
int(m.group(1)),
|
|
int(m.group(2)),
|
|
int(m.group(3)),
|
|
int(255 * float(m.group(4))),
|
|
)
|
|
return QColor(self.var(colors))
|
|
|
|
def _determine_night_mode(self) -> bool:
|
|
theme = aqt.mw.pm.theme()
|
|
if theme == Theme.LIGHT:
|
|
return False
|
|
elif theme == Theme.DARK:
|
|
return True
|
|
else:
|
|
if is_win:
|
|
return get_windows_dark_mode()
|
|
elif is_mac:
|
|
return get_macos_dark_mode()
|
|
else:
|
|
return get_linux_dark_mode()
|
|
|
|
def apply_style(self) -> None:
|
|
"Apply currently configured style."
|
|
new_theme = self._determine_night_mode()
|
|
theme_changed = self.night_mode != new_theme
|
|
new_widget_style = aqt.mw.pm.get_widget_style()
|
|
style_changed = self._current_widget_style != new_widget_style
|
|
if not theme_changed and not style_changed:
|
|
return
|
|
self.night_mode = new_theme
|
|
self._current_widget_style = new_widget_style
|
|
app = aqt.mw.app
|
|
if not self._default_style:
|
|
self._default_style = app.style().objectName()
|
|
self._apply_palette(app)
|
|
self._apply_style(app)
|
|
gui_hooks.theme_did_change()
|
|
|
|
def _apply_style(self, app: QApplication) -> None:
|
|
buf = ""
|
|
|
|
if aqt.mw.pm.get_widget_style() == WidgetStyle.ANKI:
|
|
from aqt.stylesheets import custom_styles
|
|
|
|
app.setStyle(QStyleFactory.create("fusion")) # type: ignore
|
|
|
|
buf += "".join(
|
|
[
|
|
custom_styles.general(self),
|
|
custom_styles.button(self),
|
|
custom_styles.checkbox(self),
|
|
custom_styles.menu(self),
|
|
custom_styles.combobox(self),
|
|
custom_styles.tabwidget(self),
|
|
custom_styles.table(self),
|
|
custom_styles.spinbox(self),
|
|
custom_styles.scrollbar(self),
|
|
custom_styles.slider(self),
|
|
custom_styles.splitter(self),
|
|
]
|
|
)
|
|
|
|
else:
|
|
app.setStyle(QStyleFactory.create(self._default_style)) # type: ignore
|
|
|
|
# allow addons to modify the styling
|
|
buf = gui_hooks.style_did_init(buf)
|
|
|
|
app.setStyleSheet(buf)
|
|
|
|
def _apply_palette(self, app: QApplication) -> None:
|
|
set_macos_dark_mode(self.night_mode)
|
|
|
|
palette = QPalette()
|
|
text = self.qcolor(colors.FG)
|
|
palette.setColor(QPalette.ColorRole.WindowText, text)
|
|
palette.setColor(QPalette.ColorRole.ToolTipText, text)
|
|
palette.setColor(QPalette.ColorRole.Text, text)
|
|
palette.setColor(QPalette.ColorRole.ButtonText, text)
|
|
|
|
hlbg = self.qcolor(colors.HIGHLIGHT_BG)
|
|
palette.setColor(
|
|
QPalette.ColorRole.HighlightedText, self.qcolor(colors.HIGHLIGHT_FG)
|
|
)
|
|
palette.setColor(QPalette.ColorRole.Highlight, hlbg)
|
|
|
|
canvas = self.qcolor(colors.CANVAS)
|
|
palette.setColor(QPalette.ColorRole.Window, canvas)
|
|
palette.setColor(QPalette.ColorRole.AlternateBase, canvas)
|
|
|
|
palette.setColor(QPalette.ColorRole.Button, canvas)
|
|
|
|
input_base = self.qcolor(colors.CANVAS_CODE)
|
|
palette.setColor(QPalette.ColorRole.Base, input_base)
|
|
palette.setColor(QPalette.ColorRole.ToolTipBase, input_base)
|
|
|
|
palette.setColor(
|
|
QPalette.ColorRole.PlaceholderText, self.qcolor(colors.FG_SUBTLE)
|
|
)
|
|
|
|
disabled_color = self.qcolor(colors.FG_DISABLED)
|
|
palette.setColor(
|
|
QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_color
|
|
)
|
|
palette.setColor(
|
|
QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_color
|
|
)
|
|
palette.setColor(
|
|
QPalette.ColorGroup.Disabled,
|
|
QPalette.ColorRole.HighlightedText,
|
|
disabled_color,
|
|
)
|
|
|
|
palette.setColor(QPalette.ColorRole.Link, self.qcolor(colors.FG_LINK))
|
|
|
|
palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red)
|
|
|
|
app.setPalette(palette)
|
|
|
|
def _update_stat_colors(self) -> None:
|
|
import anki.stats as s
|
|
|
|
s.colLearn = self.var(colors.STATE_NEW)
|
|
s.colRelearn = self.var(colors.STATE_LEARN)
|
|
s.colCram = self.var(colors.STATE_SUSPENDED)
|
|
s.colSusp = self.var(colors.STATE_SUSPENDED)
|
|
s.colMature = self.var(colors.STATE_REVIEW)
|
|
s._legacy_nightmode = self._night_mode_preference
|
|
|
|
|
|
def get_windows_dark_mode() -> bool:
|
|
"True if Windows system is currently in dark mode."
|
|
if not is_win:
|
|
return False
|
|
|
|
from winreg import ( # type: ignore[attr-defined] # pylint: disable=import-error
|
|
HKEY_CURRENT_USER,
|
|
OpenKey,
|
|
QueryValueEx,
|
|
)
|
|
|
|
try:
|
|
key = OpenKey(
|
|
HKEY_CURRENT_USER,
|
|
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
|
|
)
|
|
return not QueryValueEx(key, "AppsUseLightTheme")[0]
|
|
except Exception as err:
|
|
# key reportedly missing or set to wrong type on some systems
|
|
return False
|
|
|
|
|
|
def set_macos_dark_mode(enabled: bool) -> bool:
|
|
"True if setting successful."
|
|
from aqt._macos_helper import macos_helper
|
|
|
|
if not macos_helper:
|
|
return False
|
|
return macos_helper.set_darkmode_enabled(enabled)
|
|
|
|
|
|
def get_macos_dark_mode() -> bool:
|
|
"True if macOS system is currently in dark mode."
|
|
from aqt._macos_helper import macos_helper
|
|
|
|
if not macos_helper:
|
|
return False
|
|
return macos_helper.system_is_dark()
|
|
|
|
|
|
def get_linux_dark_mode() -> bool:
|
|
"""True if Linux system is in dark mode.
|
|
Only works if D-Bus is installed and system uses org.freedesktop.appearance
|
|
color-scheme to indicate dark mode preference OR if GNOME theme has
|
|
'-dark' in the name."""
|
|
if not is_lin:
|
|
return False
|
|
|
|
def parse_stdout_dbus_send(stdout: str) -> bool:
|
|
dbus_response = stdout.split()
|
|
if len(dbus_response) != 4:
|
|
return False
|
|
|
|
# https://github.com/flatpak/xdg-desktop-portal/blob/main/data/org.freedesktop.impl.portal.Settings.xml#L40
|
|
PREFER_DARK = "1"
|
|
|
|
return dbus_response[-1] == PREFER_DARK
|
|
|
|
dark_mode_detection_strategies: List[Tuple[str, Callable[[str], bool]]] = [
|
|
(
|
|
"dbus-send --session --print-reply=literal --reply-timeout=1000 "
|
|
"--dest=org.freedesktop.portal.Desktop /org/freedesktop/portal/desktop "
|
|
"org.freedesktop.portal.Settings.Read string:'org.freedesktop.appearance' "
|
|
"string:'color-scheme'",
|
|
parse_stdout_dbus_send,
|
|
),
|
|
(
|
|
"gsettings get org.gnome.desktop.interface gtk-theme",
|
|
lambda stdout: "-dark" in stdout.lower(),
|
|
),
|
|
]
|
|
|
|
for cmd, parse_stdout in dark_mode_detection_strategies:
|
|
try:
|
|
process = subprocess.run(
|
|
cmd,
|
|
shell=True,
|
|
check=True,
|
|
capture_output=True,
|
|
encoding="utf8",
|
|
)
|
|
except FileNotFoundError as e:
|
|
# detection strategy failed, missing program
|
|
print(e)
|
|
continue
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
# detection strategy failed, command returned error
|
|
print(e)
|
|
continue
|
|
|
|
return parse_stdout(process.stdout)
|
|
|
|
return False # all dark mode detection strategies failed
|
|
|
|
|
|
theme_manager = ThemeManager()
|