diff --git a/angrmanagement/config/config_manager.py b/angrmanagement/config/config_manager.py index 071adbfce..6ff7ac66d 100644 --- a/angrmanagement/config/config_manager.py +++ b/angrmanagement/config/config_manager.py @@ -115,6 +115,7 @@ def bool_serializer(config_option, value: bool) -> str: CE("symexec_font", QFont, QFont("DejaVu Sans Mono", 10)), CE("code_font", QFont, QFont("Source Code Pro", 10)), CE("theme_name", str, "Light"), + CE("theme_track_system", bool, True), CE("disasm_view_minimap_viewport_color", QColor, QColor(0xFF, 0x00, 0x00)), CE("disasm_view_operand_color", QColor, QColor(0x00, 0x00, 0x80)), CE("disasm_view_operand_constant_color", QColor, QColor(0x00, 0x00, 0x80)), diff --git a/angrmanagement/ui/dialogs/preferences.py b/angrmanagement/ui/dialogs/preferences.py index a151ba465..dba6dd1be 100644 --- a/angrmanagement/ui/dialogs/preferences.py +++ b/angrmanagement/ui/dialogs/preferences.py @@ -1,7 +1,7 @@ from datetime import datetime from bidict import bidict -from PySide6.QtCore import QSize +from PySide6.QtCore import QSize, Qt from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QCheckBox, @@ -31,6 +31,7 @@ from angrmanagement.ui.css import refresh_theme from angrmanagement.ui.widgets.qcolor_option import QColorOption from angrmanagement.ui.widgets.qfont_option import QFontOption +from angrmanagement.utils.track_system_theme import TrackSystemTheme class Page(QWidget): @@ -41,6 +42,9 @@ class Page(QWidget): def save_config(self): raise NotImplementedError + def revert_unsaved(self): + raise NotImplementedError + NAME = NotImplemented @@ -104,6 +108,9 @@ def save_config(self): # the current OS is not supported pass + def revert_unsaved(self): + pass + class ThemeAndColors(Page): """ @@ -116,6 +123,7 @@ def __init__(self, parent=None): super().__init__(parent=parent) self._to_save = {} + self._auto = TrackSystemTheme.get() self._schemes_combo: QComboBox = None self._init_widgets() @@ -130,7 +138,7 @@ def _init_widgets(self): self._schemes_combo = QComboBox(self) current_theme_idx = 0 - for idx, name in enumerate(["Current"] + sorted(COLOR_SCHEMES)): + for idx, name in enumerate(sorted(COLOR_SCHEMES)): if name == Conf.theme_name: current_theme_idx = idx self._schemes_combo.addItem(name) @@ -161,8 +169,18 @@ def _init_widgets(self): page_layout.addLayout(scroll_layout) + self._track_system = QCheckBox("Override: Track System Theme", self) + self._track_system.setCheckState(Qt.CheckState.Checked if self._auto.enabled() else Qt.CheckState.Unchecked) + self._track_system.stateChanged.connect(self._toggle_system_tracking) + page_layout.addWidget(self._track_system) + self.setLayout(page_layout) + def _toggle_system_tracking(self, state: int): + self._auto.set_enabled(state == Qt.CheckState.Checked.value) + Conf.theme_track_system = self._auto.enabled() + (self._auto.refresh_theme if Conf.theme_track_system else self._on_load_scheme_clicked)() + def _load_color_scheme(self, name): for prop, value in COLOR_SCHEMES[name].items(): row = self._to_save[prop][1] @@ -172,11 +190,16 @@ def _on_load_scheme_clicked(self): self._load_color_scheme(self._schemes_combo.currentText()) self.save_config() + def revert_unsaved(self): + pass + def save_config(self): - # pylint: disable=assigning-non-slot + if Conf.theme_track_system: + return Conf.theme_name = self._schemes_combo.currentText() for ce, row in self._to_save.values(): setattr(Conf, ce.name, row.color.am_obj) + refresh_theme() class Style(Page): @@ -203,7 +226,8 @@ def _init_widgets(self): fmt: str = Conf.log_timestamp_format ts = datetime.now() # pylint: disable=use-sequence-for-iteration - self._fmt_map = bidict({ts.strftime(i): i for i in {fmt, "%X", "%c"}}) # set also dedups + self._fmt_map = bidict({ts.strftime(i): i for i in ("%X", "%c")}) + self._fmt_map.forceput(ts.strftime(fmt), fmt) # Ensure fmt is in the dict for i in self._fmt_map: self.log_format_entry.addItem(i) # pylint: disable=unsubscriptable-object @@ -235,6 +259,9 @@ def save_config(self): for i in self._font_options: i.update() + def revert_unsaved(self): + pass + class Preferences(QDialog): """ @@ -294,6 +321,11 @@ def item_changed(item: QListWidgetItem): self.setLayout(main_layout) + def close(self, *args, **kwargs): + for page in self._pages: + page.revert_unsaved() + super().close(*args, **kwargs) + def _on_ok_clicked(self): for page in self._pages: page.save_config() diff --git a/angrmanagement/ui/main_window.py b/angrmanagement/ui/main_window.py index 278167456..0fb28e3e9 100644 --- a/angrmanagement/ui/main_window.py +++ b/angrmanagement/ui/main_window.py @@ -39,6 +39,7 @@ from angrmanagement.ui.views import DisassemblyView from angrmanagement.utils.env import app_root, is_pyinstaller from angrmanagement.utils.io import download_url, isurl +from angrmanagement.utils.track_system_theme import TrackSystemTheme from .dialogs.about import LoadAboutDialog from .dialogs.command_palette import CommandPaletteDialog, GotoPaletteDialog @@ -196,6 +197,12 @@ def __init__(self, app: Optional["QApplication"] = None, parent=None, show=True, self._run_daemon(use_daemon=use_daemon) + # Allow system theme-ing + self._track_system_theme = TrackSystemTheme.create(self) + if Conf.theme_track_system: + self._track_system_theme.set_enabled(True) + self._track_system_theme.refresh_theme() + # I'm ready to show off! if show: self.showMaximized() diff --git a/angrmanagement/utils/track_system_theme.py b/angrmanagement/utils/track_system_theme.py new file mode 100644 index 000000000..8ea8d78c5 --- /dev/null +++ b/angrmanagement/utils/track_system_theme.py @@ -0,0 +1,130 @@ +import logging +from threading import Lock +from typing import Callable, Optional + +import darkdetect +from PySide6.QtCore import QObject, QThread + +from angrmanagement.config import Conf +from angrmanagement.config.color_schemes import COLOR_SCHEMES +from angrmanagement.logic.threads import gui_thread_schedule_async +from angrmanagement.ui.css import refresh_theme + +_l = logging.getLogger(__name__) + + +class _QListener(QObject): + """ + A QObject wrapper around a darkdetect Listener + """ + + def __init__(self, callback: Callable[[str], None]): + """ + :param callback: The callback to be invoked on theme change + """ + self.listener = darkdetect.Listener(callback) + super().__init__() + + def listen(self) -> None: + """ + Start listening + """ + self.listener.listen() + + +class TrackSystemTheme: + """ + A singleton global theme class + """ + + _object: Optional["TrackSystemTheme"] = None + _system: str = "System" + + # + # Private methods + # + + def __init__(self, parent: Optional[QObject], *, _caller=None): + """ + This method is not public + """ + if _caller != self.create: # pylint: disable=comparison-with-callable + raise RuntimeError("Use .create(parent) or .get(); this is a singleton") + # Init + self._lock = Lock() + self._parent = parent + self._underlying: str = darkdetect.theme() + self._enabled: bool = False + self._listener: Optional[_QListener] = None + self._thread: Optional[QThread] = None + + def _set_theme(self, theme: str, *, force: bool = False): + """ + Set the underlying theme according to the system theme if needed + """ + if force or theme != self._underlying: + self._underlying = theme + Conf.theme_name = self._underlying + for prop, value in COLOR_SCHEMES[theme].items(): + setattr(Conf, prop, value) + gui_thread_schedule_async(refresh_theme) + + # + # Public methods + # + + @classmethod + def create(cls, parent: Optional[QObject]): + """ + Create the singleton global theme object + This function is not thread safe until after its first run + """ + if cls._object is not None: + raise RuntimeError(f"Refusing to create a second {cls.__name__}") + cls._object = cls(parent, _caller=cls.create) + return cls._object + + @classmethod + def get(cls): + """ + Get the singleton global theme object + """ + if cls._object is None: + raise RuntimeError(f"No existing {cls.__name__}") + return cls._object + + def set_enabled(self, enabled: bool): + """ + Connect system tracking slots as needed + Note: This will not update the theme until the system state changes + """ + with self._lock: + if enabled == self.enabled(): + return + self._enabled = enabled + if enabled: + self._thread = QThread(self._parent) # Keep a reference to keep the thread alive + self._listener = _QListener(self._set_theme) + self._listener.moveToThread(self._thread) + self._thread.started.connect(self._listener.listen) + self._thread.start() + else: + self._listener.listener.stop(0.05) # .05 to give a moment to clean up + self._thread.terminate() + self._listener = None + self._thread = None # Remove reference counted reference + + def enabled(self) -> bool: + """ + Return True iff system theme tracking is enabled + """ + return self._enabled + + def refresh_theme(self): + """ + Force a refresh of the theme + """ + if self.enabled(): + self._set_theme(darkdetect.theme(), force=True) + else: + gui_thread_schedule_async(refresh_theme) diff --git a/setup.cfg b/setup.cfg index 043de74cb..13ac71282 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,6 +29,7 @@ install_requires = qtterm requests[socks] tomlkit + darkdetect-angr[macos-listener] pyobjc-framework-Cocoa;platform_system == "Darwin" thefuzz[speedup] python_requires = >=3.8