Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add system theme support #806

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions angrmanagement/config/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
40 changes: 36 additions & 4 deletions angrmanagement/ui/dialogs/preferences.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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):
Expand All @@ -41,6 +42,9 @@ class Page(QWidget):
def save_config(self):
raise NotImplementedError

def revert_unsaved(self):
raise NotImplementedError

NAME = NotImplemented


Expand Down Expand Up @@ -104,6 +108,9 @@ def save_config(self):
# the current OS is not supported
pass

def revert_unsaved(self):
pass


class ThemeAndColors(Page):
"""
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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]
Expand All @@ -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):
zwimer marked this conversation as resolved.
Show resolved Hide resolved
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):
Expand All @@ -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")})
zwimer marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down Expand Up @@ -235,6 +259,9 @@ def save_config(self):
for i in self._font_options:
i.update()

def revert_unsaved(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Identical to base class implemantation

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The base class raises NotImpelementedError.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I guess I was looking at the wrong thing

Why not have the default implementation do nothing instead of raising an error? Or just drop the method because there's not an implementation that does anything?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was generally thinking of the page class as abstract and the functions that raise NotImpelemented as 'things derived classes should implement' (even if they are just no-op's). It's just a different paradigm but either way should be equivalent in this context. I don't have strong preferences either way so I can nuke the function if you prefer.

pass


class Preferences(QDialog):
"""
Expand Down Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions angrmanagement/ui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
130 changes: 130 additions & 0 deletions angrmanagement/utils/track_system_theme.py
Original file line number Diff line number Diff line change
@@ -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"

#
# 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)

#
# Private methods
#

def __init__(self, parent: Optional[QObject], *, _caller=None):
zwimer marked this conversation as resolved.
Show resolved Hide resolved
"""
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)
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ install_requires =
qtterm
requests[socks]
tomlkit
darkdetect-angr[macos-listener]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • It's unfortunate that we have to maintain a forked release of darkdetect now, but I appreicate the work on it
  • Did Windows/Linux get tested?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The majority of the fork has a PR into the main repo, so once that is merged we might not need to hopefully. TBD. I've tested the PR'd version on all three OS's, though less throughly on Windows as I lack said OS. The version we are using is mostly the same so it ought to work. Feel free to test them yourself but the relevant logic that distinguished between OSes wasn't really the code that changed between the PR'd version and the hardfork version.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tested the PR'd version on all three OS's

But not the forked version with extra changes?

Feel free to test them yourself

I don't plan to test this PR. Will you please test it

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extra changes are copyright, README, and __version__, and pyproject.toml to change the package name. Not really the code.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are curious, here is the diff stats:

 LICENSE                     |  4 ++--
 README.md                   |  8 ++++----
 darkdetect/__init__.py      |  4 ++--
 darkdetect/__main__.py      |  2 +-
 darkdetect/_dummy.py        |  2 +-
 darkdetect/_linux_detect.py |  2 +-
 docs/api.md                 |  2 +-
 docs/examples.md            |  2 +-
 pyproject.toml              | 10 +++++-----

Note the 2 line changes are copyright comments. I don't have access to a Windows device at the moment so I can't really test these changes easily, but they aren't really code changes just the changes necessary to hard-fork a project (changing the name on pypi, copyright, documentation, and version).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not know how to automate GUI test cases on windows changing system theme and detecting of the colors properly all changed over. Feel free. The actions to test this would be launch app, open preferences, select 'Track System Theme', then toggle the Windows System Theme and visually ensure it tracks the theme.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to "visually ensure" it, you just need to make sure after the theme is changed the correct behavior occurs in the library. I'm sure applescript and powershell each have ways to adjust the system theme.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want to write these test cases, I can tell you how to do it in applescript (via System Events), I don't know powershell. I imagine for Gnome specifically there is a gsettings option you could find? For detecting if it worked, you'll have to query the QApplication to check that all the colors and such have been updated and also ensure that any visual refresh functions have been called (i.e. redrawing the GUI app).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's on the angr management side, but not necessary for the library itself which is is the subject of this thread.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll happily take a PR for them here: https://github.com/zwimer/darkdetect but I don't have plans to write tests myself for them at the moment. If you do PR them, I'll also merge them into the branch that is PR-ing into the original repo https://github.com/albertosottile/darkdetect so as to avoid actively diverging the hard fork from the original more than necessary; my plan is to get rid of the fork once the PR albertosottile/darkdetect#32 in the original is merged.

Right now that PR has been tested on all three OSes and my fork's master branch doesn't meaningfully diverge from said code. I know the maintainer desired to do more thorough testing before merging the PR though, so I'm sure the test cases would be appreciated by all parties.

pyobjc-framework-Cocoa;platform_system == "Darwin"
thefuzz[speedup]
python_requires = >=3.8
Expand Down