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)
This commit is contained in:
Alberto Sottile 2019-05-14 12:20:32 +02:00
parent 12fc04326a
commit bc242c2565
7 changed files with 175 additions and 17 deletions

View File

@ -30,7 +30,8 @@ OPTIONS = {
'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,
}
}

View File

@ -212,6 +212,14 @@ STYLE_NOFILEITEM_COLOR = 'blue'
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({

View File

@ -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\
\

View File

@ -31,6 +31,11 @@ if isMacOS() and IsPySide:
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 @@ class ConsoleInGUI(ConsoleUI):
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 @@ class UserlistItemDelegate(QtWidgets.QStyledItemDelegate):
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 @@ class AboutDialog(QtWidgets.QDialog):
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 @@ class MainWindow(QtWidgets.QMainWindow):
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 @@ class MainWindow(QtWidgets.QMainWindow):
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 @@ class MainWindow(QtWidgets.QMainWindow):
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 @@ class MainWindow(QtWidgets.QMainWindow):
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,6 +1283,7 @@ class MainWindow(QtWidgets.QMainWindow):
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 @@ class MainWindow(QtWidgets.QMainWindow):
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 @@ class MainWindow(QtWidgets.QMainWindow):
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)

18
syncplay/vendor/darkdetect/__init__.py vendored Executable file
View File

@ -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
syncplay/vendor/darkdetect/_detect.py vendored Executable file
View File

@ -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
syncplay/vendor/darkdetect/_dummy.py vendored Executable file
View File

@ -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