Skip to content
Permalink
Browse files

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)
  • Loading branch information...
albertosottile committed May 14, 2019
1 parent 12fc043 commit bc242c25658620e3429068b7f12482cbaf4ac022
@@ -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,
}
}

@@ -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({
@@ -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\
\
@@ -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("<center><strong>Syncplay</strong></center>")
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)
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("&", "&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("\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 />")

@needsClient
@@ -1259,14 +1283,16 @@ 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)
window.outputbox.unsetCursor()
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)
@@ -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
@@ -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'
@@ -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.
You can’t perform that action at this time.