Navigation Menu

Skip to content

Commit

Permalink
macOS: add support for Mojave Dark Mode
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
albertosottile committed May 14, 2019
1 parent 12fc043 commit bc242c2
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 17 deletions.
3 changes: 2 additions & 1 deletion buildPy2app.py
Expand Up @@ -30,7 +30,8 @@
'CFBundleShortVersionString': syncplay.version, 'CFBundleShortVersionString': syncplay.version,
'CFBundleIdentifier': 'pl.syncplay.Syncplay', 'CFBundleIdentifier': 'pl.syncplay.Syncplay',
'LSMinimumSystemVersion': '10.12.0', 'LSMinimumSystemVersion': '10.12.0',
'NSHumanReadableCopyright': 'Copyright © 2019 Syncplay All Rights Reserved' 'NSHumanReadableCopyright': 'Copyright © 2019 Syncplay All Rights Reserved',
'NSRequiresAquaSystemAppearance': False,
} }
} }


Expand Down
8 changes: 8 additions & 0 deletions syncplay/constants.py
Expand Up @@ -212,6 +212,14 @@ def getValueForOS(constantDict):
STYLE_NOTCONTROLLER_COLOR = 'grey' STYLE_NOTCONTROLLER_COLOR = 'grey'
STYLE_UNTRUSTEDITEM_COLOR = 'purple' 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 TLS_CERT_ROTATION_MAX_RETRIES = 10


USERLIST_GUI_USERNAME_OFFSET = getValueForOS({ USERLIST_GUI_USERNAME_OFFSET = getValueForOS({
Expand Down
29 changes: 29 additions & 0 deletions syncplay/resources/third-party-notices.rtf
Expand Up @@ -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.\ 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\ \b Icons\
\ \
Expand Down
56 changes: 40 additions & 16 deletions syncplay/ui/gui.py
Expand Up @@ -31,6 +31,11 @@
from Foundation import NSURL from Foundation import NSURL
from Cocoa import NSString, NSUTF8StringEncoding from Cocoa import NSString, NSUTF8StringEncoding
lastCheckedForUpdates = None lastCheckedForUpdates = None
from syncplay.vendor import darkdetect
if isMacOS():
isDarkMode = darkdetect.isDark()
else:
isDarkMode = None




class ConsoleInGUI(ConsoleUI): class ConsoleInGUI(ConsoleUI):
Expand All @@ -51,7 +56,8 @@ def getUserlist(self):




class UserlistItemDelegate(QtWidgets.QStyledItemDelegate): class UserlistItemDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self): def __init__(self, view=None):
self.view = view
QtWidgets.QStyledItemDelegate.__init__(self) QtWidgets.QStyledItemDelegate.__init__(self)


def sizeHint(self, option, index): def sizeHint(self, option, index):
Expand All @@ -72,9 +78,10 @@ def paint(self, itemQPainter, optionQStyleOptionViewItem, indexQModelIndex):
roomController = currentQAbstractItemModel.data(itemQModelIndex, Qt.UserRole + constants.USERITEM_CONTROLLER_ROLE) roomController = currentQAbstractItemModel.data(itemQModelIndex, Qt.UserRole + constants.USERITEM_CONTROLLER_ROLE)
userReady = currentQAbstractItemModel.data(itemQModelIndex, Qt.UserRole + constants.USERITEM_READY_ROLE) userReady = currentQAbstractItemModel.data(itemQModelIndex, Qt.UserRole + constants.USERITEM_READY_ROLE)
isUserRow = indexQModelIndex.parent() != indexQModelIndex.parent().parent() isUserRow = indexQModelIndex.parent() != indexQModelIndex.parent().parent()
bkgColor = self.view.palette().color(QtGui.QPalette.Base)
if isUserRow and isMacOS(): if isUserRow and isMacOS():
whiteRect = QtCore.QRect(0, optionQStyleOptionViewItem.rect.y(), optionQStyleOptionViewItem.rect.width(), optionQStyleOptionViewItem.rect.height()) blankRect = QtCore.QRect(0, optionQStyleOptionViewItem.rect.y(), optionQStyleOptionViewItem.rect.width(), optionQStyleOptionViewItem.rect.height())
itemQPainter.fillRect(whiteRect, QtGui.QColor(Qt.white)) itemQPainter.fillRect(blankRect, bkgColor)


if roomController and not controlIconQPixmap.isNull(): if roomController and not controlIconQPixmap.isNull():
itemQPainter.drawPixmap( itemQPainter.drawPixmap(
Expand Down Expand Up @@ -130,7 +137,11 @@ def __init__(self, parent=None):
self.setWindowIcon(QtGui.QPixmap(resourcespath + 'syncplay.png')) self.setWindowIcon(QtGui.QPixmap(resourcespath + 'syncplay.png'))
nameLabel = QtWidgets.QLabel("<center><strong>Syncplay</strong></center>") nameLabel = QtWidgets.QLabel("<center><strong>Syncplay</strong></center>")
nameLabel.setFont(QtGui.QFont("Helvetica", 18)) nameLabel.setFont(QtGui.QFont("Helvetica", 18))
linkLabel = QtWidgets.QLabel("<center><a href=\"https://syncplay.pl\">syncplay.pl</a></center>") linkLabel = QtWidgets.QLabel()
if isDarkMode:
linkLabel.setText(("<center><a href=\"https://syncplay.pl\" style=\"{}\">syncplay.pl</a></center>").format(constants.STYLE_DARK_ABOUT_LINK_COLOR))
else:
linkLabel.setText("<center><a href=\"https://syncplay.pl\">syncplay.pl</a></center>")
linkLabel.setOpenExternalLinks(True) linkLabel.setOpenExternalLinks(True)
versionExtString = version + revision versionExtString = version + revision
versionLabel = QtWidgets.QLabel( versionLabel = QtWidgets.QLabel(
Expand Down Expand Up @@ -324,11 +335,17 @@ def updatePlaylistIndexIcon(self):
fileIsAvailable = self.selfWindow.isFileAvailable(itemFilename) fileIsAvailable = self.selfWindow.isFileAvailable(itemFilename)
fileIsUntrusted = self.selfWindow.isItemUntrusted(itemFilename) fileIsUntrusted = self.selfWindow.isItemUntrusted(itemFilename)
if fileIsUntrusted: 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: 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: 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.selfWindow._syncplayClient.fileSwitch.setFilenameWatchlist(self.selfWindow.newWatchlist)
self.forceUpdate() self.forceUpdate()


Expand Down Expand Up @@ -605,24 +622,28 @@ def showUserList(self, currentUser, rooms):
sameDuration = sameFileduration(user.file['duration'], currentUser.file['duration']) sameDuration = sameFileduration(user.file['duration'], currentUser.file['duration'])
underlinefont = QtGui.QFont() underlinefont = QtGui.QFont()
underlinefont.setUnderline(True) underlinefont.setUnderline(True)
differentItemColor = constants.STYLE_DARK_DIFFERENTITEM_COLOR if isDarkMode else constants.STYLE_DIFFERENTITEM_COLOR
if sameRoom: if sameRoom:
if not sameName: if not sameName:
filenameitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DIFFERENTITEM_COLOR))) filenameitem.setForeground(QtGui.QBrush(QtGui.QColor(differentItemColor)))
filenameitem.setFont(underlinefont) filenameitem.setFont(underlinefont)
if not sameSize: if not sameSize:
if formatSize(user.file['size']) == formatSize(currentUser.file['size']): if formatSize(user.file['size']) == formatSize(currentUser.file['size']):
filesizeitem = QtGui.QStandardItem(formatSize(user.file['size'], precise=True)) filesizeitem = QtGui.QStandardItem(formatSize(user.file['size'], precise=True))
filesizeitem.setFont(underlinefont) filesizeitem.setFont(underlinefont)
filesizeitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DIFFERENTITEM_COLOR))) filesizeitem.setForeground(QtGui.QBrush(QtGui.QColor(differentItemColor)))
if not sameDuration: if not sameDuration:
filedurationitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DIFFERENTITEM_COLOR))) filedurationitem.setForeground(QtGui.QBrush(QtGui.QColor(differentItemColor)))
filedurationitem.setFont(underlinefont) filedurationitem.setFont(underlinefont)
else: else:
filenameitem = QtGui.QStandardItem(getMessage("nofile-note")) filenameitem = QtGui.QStandardItem(getMessage("nofile-note"))
filedurationitem = QtGui.QStandardItem("") filedurationitem = QtGui.QStandardItem("")
filesizeitem = QtGui.QStandardItem("") filesizeitem = QtGui.QStandardItem("")
if room == currentUser.room: 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() font = QtGui.QFont()
if currentUser.username == user.username: if currentUser.username == user.username:
font.setWeight(QtGui.QFont.Bold) font.setWeight(QtGui.QFont.Bold)
Expand All @@ -637,7 +658,7 @@ def showUserList(self, currentUser, rooms):
roomitem.appendRow((useritem, filesizeitem, filedurationitem, filenameitem)) roomitem.appendRow((useritem, filesizeitem, filedurationitem, filenameitem))
self.listTreeModel = self._usertreebuffer self.listTreeModel = self._usertreebuffer
self.listTreeView.setModel(self.listTreeModel) self.listTreeView.setModel(self.listTreeModel)
self.listTreeView.setItemDelegate(UserlistItemDelegate()) self.listTreeView.setItemDelegate(UserlistItemDelegate(view=self.listTreeView))
self.listTreeView.setItemsExpandable(False) self.listTreeView.setItemsExpandable(False)
self.listTreeView.setRootIsDecorated(False) self.listTreeView.setRootIsDecorated(False)
self.listTreeView.expandAll() self.listTreeView.expandAll()
Expand Down Expand Up @@ -849,7 +870,10 @@ def showErrorMessage(self, message, criticalerror=False):
message = message.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;") message = message.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
message = message.replace("&lt;a href=&quot;https://syncplay.pl/trouble&quot;&gt;", '<a href="https://syncplay.pl/trouble">').replace("&lt;/a&gt;", "</a>") message = message.replace("&lt;a href=&quot;https://syncplay.pl/trouble&quot;&gt;", '<a href="https://syncplay.pl/trouble">').replace("&lt;/a&gt;", "</a>")
message = message.replace("\n", "<br />") message = message.replace("\n", "<br />")
message = "<span style=\"{}\">".format(constants.STYLE_ERRORNOTIFICATION) + message + "</span>" if isDarkMode:
message = "<span style=\"{}\">".format(constants.STYLE_DARK_ERRORNOTIFICATION) + message + "</span>"
else:
message = "<span style=\"{}\">".format(constants.STYLE_ERRORNOTIFICATION) + message + "</span>"
self.newMessage(time.strftime(constants.UI_TIME_FORMAT, time.localtime()) + message + "<br />") self.newMessage(time.strftime(constants.UI_TIME_FORMAT, time.localtime()) + message + "<br />")


@needsClient @needsClient
Expand Down Expand Up @@ -1259,14 +1283,16 @@ def addTopLayout(self, window):


window.outputLayout = QtWidgets.QVBoxLayout() window.outputLayout = QtWidgets.QVBoxLayout()
window.outputbox = QtWidgets.QTextBrowser() window.outputbox = QtWidgets.QTextBrowser()
if isDarkMode: window.outputbox.document().setDefaultStyleSheet(constants.STYLE_DARK_LINKS_COLOR);
window.outputbox.setReadOnly(True) window.outputbox.setReadOnly(True)
window.outputbox.setTextInteractionFlags(window.outputbox.textInteractionFlags() | Qt.TextSelectableByKeyboard) window.outputbox.setTextInteractionFlags(window.outputbox.textInteractionFlags() | Qt.TextSelectableByKeyboard)
window.outputbox.setOpenExternalLinks(True) window.outputbox.setOpenExternalLinks(True)
window.outputbox.unsetCursor() window.outputbox.unsetCursor()
window.outputbox.moveCursor(QtGui.QTextCursor.End) window.outputbox.moveCursor(QtGui.QTextCursor.End)
window.outputbox.insertHtml(constants.STYLE_CONTACT_INFO.format(getMessage("contact-label"))) window.outputbox.insertHtml(constants.STYLE_CONTACT_INFO.format(getMessage("contact-label")))
window.outputbox.moveCursor(QtGui.QTextCursor.End) 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 = QtWidgets.QLabel(getMessage("notifications-heading-label"))
window.outputlabel.setMinimumHeight(27) window.outputlabel.setMinimumHeight(27)
Expand Down Expand Up @@ -1414,8 +1440,6 @@ def addBottomLayout(self, window):
playlistItem = QtWidgets.QListWidgetItem(getMessage("playlist-instruction-item-message")) playlistItem = QtWidgets.QListWidgetItem(getMessage("playlist-instruction-item-message"))
playlistItem.setFont(noteFont) playlistItem.setFont(noteFont)
window.playlist.addItem(playlistItem) window.playlist.addItem(playlistItem)
playlistItem.setFont(noteFont)
window.playlist.addItem(playlistItem)
window.playlistLayout.addWidget(window.playlist) window.playlistLayout.addWidget(window.playlist)
window.playlistLayout.setAlignment(Qt.AlignTop) window.playlistLayout.setAlignment(Qt.AlignTop)
window.playlistGroup.setLayout(window.playlistLayout) window.playlistGroup.setLayout(window.playlistLayout)
Expand Down
18 changes: 18 additions & 0 deletions 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
64 changes: 64 additions & 0 deletions 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'
14 changes: 14 additions & 0 deletions 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

0 comments on commit bc242c2

Please sign in to comment.