From bc242c25658620e3429068b7f12482cbaf4ac022 Mon Sep 17 00:00:00 2001 From: Alberto Sottile Date: Tue, 14 May 2019 12:20:32 +0200 Subject: [PATCH] macOS: add support for Mojave Dark Mode This commit adds to our UI the support for macOS 10.14+ Dark Mode. Qt already adapts a large fraction of the color scheme, but some label colors had to be adjusted and were put in separate STYLE_DARK constants. To determine if the OS is set in Dark or Light Mode, we use a new dependency, included in the vendor folder: Darkdetect - license: BSD-3-Clause To allow the app bundle to use the Dark Mode APIs, a constant is added in the info.plist ('NSRequiresAquaSystemAppearance': False) --- buildPy2app.py | 3 +- syncplay/constants.py | 8 +++ syncplay/resources/third-party-notices.rtf | 29 ++++++++++ syncplay/ui/gui.py | 56 +++++++++++++------ syncplay/vendor/darkdetect/__init__.py | 18 ++++++ syncplay/vendor/darkdetect/_detect.py | 64 ++++++++++++++++++++++ syncplay/vendor/darkdetect/_dummy.py | 14 +++++ 7 files changed, 175 insertions(+), 17 deletions(-) create mode 100755 syncplay/vendor/darkdetect/__init__.py create mode 100755 syncplay/vendor/darkdetect/_detect.py create mode 100755 syncplay/vendor/darkdetect/_dummy.py diff --git a/buildPy2app.py b/buildPy2app.py index 68e646609..659e57540 100755 --- a/buildPy2app.py +++ b/buildPy2app.py @@ -30,7 +30,8 @@ 'CFBundleShortVersionString': syncplay.version, 'CFBundleIdentifier': 'pl.syncplay.Syncplay', 'LSMinimumSystemVersion': '10.12.0', - 'NSHumanReadableCopyright': 'Copyright © 2019 Syncplay All Rights Reserved' + 'NSHumanReadableCopyright': 'Copyright © 2019 Syncplay All Rights Reserved', + 'NSRequiresAquaSystemAppearance': False, } } diff --git a/syncplay/constants.py b/syncplay/constants.py index 2ed523ccd..f200d9e01 100755 --- a/syncplay/constants.py +++ b/syncplay/constants.py @@ -212,6 +212,14 @@ def getValueForOS(constantDict): STYLE_NOTCONTROLLER_COLOR = 'grey' STYLE_UNTRUSTEDITEM_COLOR = 'purple' +STYLE_DARK_LINKS_COLOR = "a {color: #1A78D5; }" +STYLE_DARK_ABOUT_LINK_COLOR = "color: #1A78D5;" +STYLE_DARK_ERRORNOTIFICATION = "color: #E94F64;" +STYLE_DARK_DIFFERENTITEM_COLOR = '#E94F64' +STYLE_DARK_NOFILEITEM_COLOR = '#1A78D5' +STYLE_DARK_NOTCONTROLLER_COLOR = 'grey' +STYLE_DARK_UNTRUSTEDITEM_COLOR = '#882fbc' + TLS_CERT_ROTATION_MAX_RETRIES = 10 USERLIST_GUI_USERNAME_OFFSET = getValueForOS({ diff --git a/syncplay/resources/third-party-notices.rtf b/syncplay/resources/third-party-notices.rtf index 13d3355db..207ed833a 100644 --- a/syncplay/resources/third-party-notices.rtf +++ b/syncplay/resources/third-party-notices.rtf @@ -400,6 +400,35 @@ TIONS OF ANY KIND, either express or implied. See the License for the specific l uage governing permissions and limitations under the License.\ \ +\b Darkdetect +\b0 \ +\ +Copyright (c) 2019, Alberto Sottile\ +All rights reserved.\ +\ +Redistribution and use in source and binary forms, with or without\ +modification, are permitted provided that the following conditions are met:\ + * Redistributions of source code must retain the above copyright\ + notice, this list of conditions and the following disclaimer.\ + * Redistributions in binary form must reproduce the above copyright\ + notice, this list of conditions and the following disclaimer in the\ + documentation and/or other materials provided with the distribution.\ + * Neither the name of "darkdetect" nor the\ + names of its contributors may be used to endorse or promote products\ + derived from this software without specific prior written permission.\ +\ +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND\ +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\ +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\ +DISCLAIMED. IN NO EVENT SHALL "Alberto Sottile" BE LIABLE FOR ANY\ +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\ +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\ +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND\ +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\ +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\ +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\ +\ + \b Icons\ \ diff --git a/syncplay/ui/gui.py b/syncplay/ui/gui.py index 5f8b17c31..de2f6bb86 100755 --- a/syncplay/ui/gui.py +++ b/syncplay/ui/gui.py @@ -31,6 +31,11 @@ from Foundation import NSURL from Cocoa import NSString, NSUTF8StringEncoding lastCheckedForUpdates = None +from syncplay.vendor import darkdetect +if isMacOS(): + isDarkMode = darkdetect.isDark() +else: + isDarkMode = None class ConsoleInGUI(ConsoleUI): @@ -51,7 +56,8 @@ def getUserlist(self): class UserlistItemDelegate(QtWidgets.QStyledItemDelegate): - def __init__(self): + def __init__(self, view=None): + self.view = view QtWidgets.QStyledItemDelegate.__init__(self) def sizeHint(self, option, index): @@ -72,9 +78,10 @@ def paint(self, itemQPainter, optionQStyleOptionViewItem, indexQModelIndex): roomController = currentQAbstractItemModel.data(itemQModelIndex, Qt.UserRole + constants.USERITEM_CONTROLLER_ROLE) userReady = currentQAbstractItemModel.data(itemQModelIndex, Qt.UserRole + constants.USERITEM_READY_ROLE) isUserRow = indexQModelIndex.parent() != indexQModelIndex.parent().parent() + bkgColor = self.view.palette().color(QtGui.QPalette.Base) if isUserRow and isMacOS(): - whiteRect = QtCore.QRect(0, optionQStyleOptionViewItem.rect.y(), optionQStyleOptionViewItem.rect.width(), optionQStyleOptionViewItem.rect.height()) - itemQPainter.fillRect(whiteRect, QtGui.QColor(Qt.white)) + blankRect = QtCore.QRect(0, optionQStyleOptionViewItem.rect.y(), optionQStyleOptionViewItem.rect.width(), optionQStyleOptionViewItem.rect.height()) + itemQPainter.fillRect(blankRect, bkgColor) if roomController and not controlIconQPixmap.isNull(): itemQPainter.drawPixmap( @@ -130,7 +137,11 @@ def __init__(self, parent=None): self.setWindowIcon(QtGui.QPixmap(resourcespath + 'syncplay.png')) nameLabel = QtWidgets.QLabel("
Syncplay
") nameLabel.setFont(QtGui.QFont("Helvetica", 18)) - linkLabel = QtWidgets.QLabel("
syncplay.pl
") + linkLabel = QtWidgets.QLabel() + if isDarkMode: + linkLabel.setText(("
syncplay.pl
").format(constants.STYLE_DARK_ABOUT_LINK_COLOR)) + else: + linkLabel.setText("
syncplay.pl
") linkLabel.setOpenExternalLinks(True) versionExtString = version + revision versionLabel = QtWidgets.QLabel( @@ -324,11 +335,17 @@ def updatePlaylistIndexIcon(self): fileIsAvailable = self.selfWindow.isFileAvailable(itemFilename) fileIsUntrusted = self.selfWindow.isItemUntrusted(itemFilename) if fileIsUntrusted: - self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_UNTRUSTEDITEM_COLOR))) + if isDarkMode: + self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DARK_UNTRUSTEDITEM_COLOR))) + else: + self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_UNTRUSTEDITEM_COLOR))) elif fileIsAvailable: - self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(QtGui.QPalette.ColorRole(QtGui.QPalette.Text)))) + self.item(item).setForeground(QtGui.QBrush(self.selfWindow.palette().color(QtGui.QPalette.Text))) else: - self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DIFFERENTITEM_COLOR))) + if isDarkMode: + self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DARK_DIFFERENTITEM_COLOR))) + else: + self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DIFFERENTITEM_COLOR))) self.selfWindow._syncplayClient.fileSwitch.setFilenameWatchlist(self.selfWindow.newWatchlist) self.forceUpdate() @@ -605,24 +622,28 @@ def showUserList(self, currentUser, rooms): sameDuration = sameFileduration(user.file['duration'], currentUser.file['duration']) underlinefont = QtGui.QFont() underlinefont.setUnderline(True) + differentItemColor = constants.STYLE_DARK_DIFFERENTITEM_COLOR if isDarkMode else constants.STYLE_DIFFERENTITEM_COLOR if sameRoom: if not sameName: - filenameitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DIFFERENTITEM_COLOR))) + filenameitem.setForeground(QtGui.QBrush(QtGui.QColor(differentItemColor))) filenameitem.setFont(underlinefont) if not sameSize: if formatSize(user.file['size']) == formatSize(currentUser.file['size']): filesizeitem = QtGui.QStandardItem(formatSize(user.file['size'], precise=True)) filesizeitem.setFont(underlinefont) - filesizeitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DIFFERENTITEM_COLOR))) + filesizeitem.setForeground(QtGui.QBrush(QtGui.QColor(differentItemColor))) if not sameDuration: - filedurationitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DIFFERENTITEM_COLOR))) + filedurationitem.setForeground(QtGui.QBrush(QtGui.QColor(differentItemColor))) filedurationitem.setFont(underlinefont) else: filenameitem = QtGui.QStandardItem(getMessage("nofile-note")) filedurationitem = QtGui.QStandardItem("") filesizeitem = QtGui.QStandardItem("") if room == currentUser.room: - filenameitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_NOFILEITEM_COLOR))) + if isDarkMode: + filenameitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DARK_NOFILEITEM_COLOR))) + else: + filenameitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_NOFILEITEM_COLOR))) font = QtGui.QFont() if currentUser.username == user.username: font.setWeight(QtGui.QFont.Bold) @@ -637,7 +658,7 @@ def showUserList(self, currentUser, rooms): roomitem.appendRow((useritem, filesizeitem, filedurationitem, filenameitem)) self.listTreeModel = self._usertreebuffer self.listTreeView.setModel(self.listTreeModel) - self.listTreeView.setItemDelegate(UserlistItemDelegate()) + self.listTreeView.setItemDelegate(UserlistItemDelegate(view=self.listTreeView)) self.listTreeView.setItemsExpandable(False) self.listTreeView.setRootIsDecorated(False) self.listTreeView.expandAll() @@ -849,7 +870,10 @@ def showErrorMessage(self, message, criticalerror=False): message = message.replace("&", "&").replace('"', """).replace("<", "<").replace(">", ">") message = message.replace("<a href="https://syncplay.pl/trouble">", '').replace("</a>", "") message = message.replace("\n", "
") - message = "".format(constants.STYLE_ERRORNOTIFICATION) + message + "" + if isDarkMode: + message = "".format(constants.STYLE_DARK_ERRORNOTIFICATION) + message + "" + else: + message = "".format(constants.STYLE_ERRORNOTIFICATION) + message + "" self.newMessage(time.strftime(constants.UI_TIME_FORMAT, time.localtime()) + message + "
") @needsClient @@ -1259,6 +1283,7 @@ def addTopLayout(self, window): window.outputLayout = QtWidgets.QVBoxLayout() window.outputbox = QtWidgets.QTextBrowser() + if isDarkMode: window.outputbox.document().setDefaultStyleSheet(constants.STYLE_DARK_LINKS_COLOR); window.outputbox.setReadOnly(True) window.outputbox.setTextInteractionFlags(window.outputbox.textInteractionFlags() | Qt.TextSelectableByKeyboard) window.outputbox.setOpenExternalLinks(True) @@ -1266,7 +1291,8 @@ def addTopLayout(self, window): window.outputbox.moveCursor(QtGui.QTextCursor.End) window.outputbox.insertHtml(constants.STYLE_CONTACT_INFO.format(getMessage("contact-label"))) window.outputbox.moveCursor(QtGui.QTextCursor.End) - window.outputbox.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) + window.outputbox.setCursorWidth(0) + if not isMacOS(): window.outputbox.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) window.outputlabel = QtWidgets.QLabel(getMessage("notifications-heading-label")) window.outputlabel.setMinimumHeight(27) @@ -1414,8 +1440,6 @@ def addBottomLayout(self, window): playlistItem = QtWidgets.QListWidgetItem(getMessage("playlist-instruction-item-message")) playlistItem.setFont(noteFont) window.playlist.addItem(playlistItem) - playlistItem.setFont(noteFont) - window.playlist.addItem(playlistItem) window.playlistLayout.addWidget(window.playlist) window.playlistLayout.setAlignment(Qt.AlignTop) window.playlistGroup.setLayout(window.playlistLayout) diff --git a/syncplay/vendor/darkdetect/__init__.py b/syncplay/vendor/darkdetect/__init__.py new file mode 100755 index 000000000..0537e588f --- /dev/null +++ b/syncplay/vendor/darkdetect/__init__.py @@ -0,0 +1,18 @@ +#----------------------------------------------------------------------------- +# Copyright (C) 2019 Alberto Sottile +# +# Distributed under the terms of the 3-clause BSD License. +#----------------------------------------------------------------------------- + +__version__ = '0.1.0' + +import sys +import platform +from distutils.version import LooseVersion as V + +if sys.platform != "darwin" or V(platform.mac_ver()[0]) < V("10.14"): + from ._dummy import * +else: + from ._detect import * + +del sys, platform, V \ No newline at end of file diff --git a/syncplay/vendor/darkdetect/_detect.py b/syncplay/vendor/darkdetect/_detect.py new file mode 100755 index 000000000..9a79f7c26 --- /dev/null +++ b/syncplay/vendor/darkdetect/_detect.py @@ -0,0 +1,64 @@ +#----------------------------------------------------------------------------- +# Copyright (C) 2019 Alberto Sottile +# +# Distributed under the terms of the 3-clause BSD License. +#----------------------------------------------------------------------------- + +import ctypes +import ctypes.util + +appkit = ctypes.cdll.LoadLibrary(ctypes.util.find_library('AppKit')) +objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library('objc')) + +void_p = ctypes.c_void_p +ull = ctypes.c_uint64 + +objc.objc_getClass.restype = void_p +objc.sel_registerName.restype = void_p +objc.objc_msgSend.restype = void_p +objc.objc_msgSend.argtypes = [void_p, void_p] + +msg = objc.objc_msgSend + +def _utf8(s): + if not isinstance(s, bytes): + s = s.encode('utf8') + return s + +def n(name): + return objc.sel_registerName(_utf8(name)) + +def C(classname): + return objc.objc_getClass(_utf8(classname)) + +def theme(): + NSAutoreleasePool = objc.objc_getClass('NSAutoreleasePool') + pool = msg(NSAutoreleasePool, n('alloc')) + pool = msg(pool, n('init')) + + NSUserDefaults = C('NSUserDefaults') + stdUserDef = msg(NSUserDefaults, n('standardUserDefaults')) + + NSString = C('NSString') + + key = msg(NSString, n("stringWithUTF8String:"), _utf8('AppleInterfaceStyle')) + appearanceNS = msg(stdUserDef, n('stringForKey:'), void_p(key)) + appearanceC = msg(appearanceNS, n('UTF8String')) + + if appearanceC is not None: + out = ctypes.string_at(appearanceC) + else: + out = None + + msg(pool, n('release')) + + if out is not None: + return out.decode('utf-8') + else: + return 'Light' + +def isDark(): + return theme() == 'Dark' + +def isLight(): + return theme() == 'Light' diff --git a/syncplay/vendor/darkdetect/_dummy.py b/syncplay/vendor/darkdetect/_dummy.py new file mode 100755 index 000000000..1e99668ed --- /dev/null +++ b/syncplay/vendor/darkdetect/_dummy.py @@ -0,0 +1,14 @@ +#----------------------------------------------------------------------------- +# Copyright (C) 2019 Alberto Sottile +# +# Distributed under the terms of the 3-clause BSD License. +#----------------------------------------------------------------------------- + +def theme(): + return None + +def isDark(): + return None + +def isLight(): + return None