Skip to content

Commit bc242c2

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)
1 parent 12fc043 commit bc242c2

File tree

7 files changed

+175
-17
lines changed

7 files changed

+175
-17
lines changed

buildPy2app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
'CFBundleShortVersionString': syncplay.version,
3131
'CFBundleIdentifier': 'pl.syncplay.Syncplay',
3232
'LSMinimumSystemVersion': '10.12.0',
33-
'NSHumanReadableCopyright': 'Copyright © 2019 Syncplay All Rights Reserved'
33+
'NSHumanReadableCopyright': 'Copyright © 2019 Syncplay All Rights Reserved',
34+
'NSRequiresAquaSystemAppearance': False,
3435
}
3536
}
3637

syncplay/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,14 @@ def getValueForOS(constantDict):
212212
STYLE_NOTCONTROLLER_COLOR = 'grey'
213213
STYLE_UNTRUSTEDITEM_COLOR = 'purple'
214214

215+
STYLE_DARK_LINKS_COLOR = "a {color: #1A78D5; }"
216+
STYLE_DARK_ABOUT_LINK_COLOR = "color: #1A78D5;"
217+
STYLE_DARK_ERRORNOTIFICATION = "color: #E94F64;"
218+
STYLE_DARK_DIFFERENTITEM_COLOR = '#E94F64'
219+
STYLE_DARK_NOFILEITEM_COLOR = '#1A78D5'
220+
STYLE_DARK_NOTCONTROLLER_COLOR = 'grey'
221+
STYLE_DARK_UNTRUSTEDITEM_COLOR = '#882fbc'
222+
215223
TLS_CERT_ROTATION_MAX_RETRIES = 10
216224

217225
USERLIST_GUI_USERNAME_OFFSET = getValueForOS({

syncplay/resources/third-party-notices.rtf

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,35 @@ TIONS OF ANY KIND, either express or implied. See the License for the specific l
400400
uage governing permissions and limitations under the License.\
401401
\
402402

403+
\b Darkdetect
404+
\b0 \
405+
\
406+
Copyright (c) 2019, Alberto Sottile\
407+
All rights reserved.\
408+
\
409+
Redistribution and use in source and binary forms, with or without\
410+
modification, are permitted provided that the following conditions are met:\
411+
* Redistributions of source code must retain the above copyright\
412+
notice, this list of conditions and the following disclaimer.\
413+
* Redistributions in binary form must reproduce the above copyright\
414+
notice, this list of conditions and the following disclaimer in the\
415+
documentation and/or other materials provided with the distribution.\
416+
* Neither the name of "darkdetect" nor the\
417+
names of its contributors may be used to endorse or promote products\
418+
derived from this software without specific prior written permission.\
419+
\
420+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND\
421+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\
422+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\
423+
DISCLAIMED. IN NO EVENT SHALL "Alberto Sottile" BE LIABLE FOR ANY\
424+
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\
425+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\
426+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND\
427+
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\
428+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\
429+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\
430+
\
431+
403432
\b Icons\
404433
\
405434

syncplay/ui/gui.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
from Foundation import NSURL
3232
from Cocoa import NSString, NSUTF8StringEncoding
3333
lastCheckedForUpdates = None
34+
from syncplay.vendor import darkdetect
35+
if isMacOS():
36+
isDarkMode = darkdetect.isDark()
37+
else:
38+
isDarkMode = None
3439

3540

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

5257

5358
class UserlistItemDelegate(QtWidgets.QStyledItemDelegate):
54-
def __init__(self):
59+
def __init__(self, view=None):
60+
self.view = view
5561
QtWidgets.QStyledItemDelegate.__init__(self)
5662

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

7986
if roomController and not controlIconQPixmap.isNull():
8087
itemQPainter.drawPixmap(
@@ -130,7 +137,11 @@ def __init__(self, parent=None):
130137
self.setWindowIcon(QtGui.QPixmap(resourcespath + 'syncplay.png'))
131138
nameLabel = QtWidgets.QLabel("<center><strong>Syncplay</strong></center>")
132139
nameLabel.setFont(QtGui.QFont("Helvetica", 18))
133-
linkLabel = QtWidgets.QLabel("<center><a href=\"https://syncplay.pl\">syncplay.pl</a></center>")
140+
linkLabel = QtWidgets.QLabel()
141+
if isDarkMode:
142+
linkLabel.setText(("<center><a href=\"https://syncplay.pl\" style=\"{}\">syncplay.pl</a></center>").format(constants.STYLE_DARK_ABOUT_LINK_COLOR))
143+
else:
144+
linkLabel.setText("<center><a href=\"https://syncplay.pl\">syncplay.pl</a></center>")
134145
linkLabel.setOpenExternalLinks(True)
135146
versionExtString = version + revision
136147
versionLabel = QtWidgets.QLabel(
@@ -324,11 +335,17 @@ def updatePlaylistIndexIcon(self):
324335
fileIsAvailable = self.selfWindow.isFileAvailable(itemFilename)
325336
fileIsUntrusted = self.selfWindow.isItemUntrusted(itemFilename)
326337
if fileIsUntrusted:
327-
self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_UNTRUSTEDITEM_COLOR)))
338+
if isDarkMode:
339+
self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DARK_UNTRUSTEDITEM_COLOR)))
340+
else:
341+
self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_UNTRUSTEDITEM_COLOR)))
328342
elif fileIsAvailable:
329-
self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(QtGui.QPalette.ColorRole(QtGui.QPalette.Text))))
343+
self.item(item).setForeground(QtGui.QBrush(self.selfWindow.palette().color(QtGui.QPalette.Text)))
330344
else:
331-
self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DIFFERENTITEM_COLOR)))
345+
if isDarkMode:
346+
self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DARK_DIFFERENTITEM_COLOR)))
347+
else:
348+
self.item(item).setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DIFFERENTITEM_COLOR)))
332349
self.selfWindow._syncplayClient.fileSwitch.setFilenameWatchlist(self.selfWindow.newWatchlist)
333350
self.forceUpdate()
334351

@@ -605,24 +622,28 @@ def showUserList(self, currentUser, rooms):
605622
sameDuration = sameFileduration(user.file['duration'], currentUser.file['duration'])
606623
underlinefont = QtGui.QFont()
607624
underlinefont.setUnderline(True)
625+
differentItemColor = constants.STYLE_DARK_DIFFERENTITEM_COLOR if isDarkMode else constants.STYLE_DIFFERENTITEM_COLOR
608626
if sameRoom:
609627
if not sameName:
610-
filenameitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DIFFERENTITEM_COLOR)))
628+
filenameitem.setForeground(QtGui.QBrush(QtGui.QColor(differentItemColor)))
611629
filenameitem.setFont(underlinefont)
612630
if not sameSize:
613631
if formatSize(user.file['size']) == formatSize(currentUser.file['size']):
614632
filesizeitem = QtGui.QStandardItem(formatSize(user.file['size'], precise=True))
615633
filesizeitem.setFont(underlinefont)
616-
filesizeitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DIFFERENTITEM_COLOR)))
634+
filesizeitem.setForeground(QtGui.QBrush(QtGui.QColor(differentItemColor)))
617635
if not sameDuration:
618-
filedurationitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DIFFERENTITEM_COLOR)))
636+
filedurationitem.setForeground(QtGui.QBrush(QtGui.QColor(differentItemColor)))
619637
filedurationitem.setFont(underlinefont)
620638
else:
621639
filenameitem = QtGui.QStandardItem(getMessage("nofile-note"))
622640
filedurationitem = QtGui.QStandardItem("")
623641
filesizeitem = QtGui.QStandardItem("")
624642
if room == currentUser.room:
625-
filenameitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_NOFILEITEM_COLOR)))
643+
if isDarkMode:
644+
filenameitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_DARK_NOFILEITEM_COLOR)))
645+
else:
646+
filenameitem.setForeground(QtGui.QBrush(QtGui.QColor(constants.STYLE_NOFILEITEM_COLOR)))
626647
font = QtGui.QFont()
627648
if currentUser.username == user.username:
628649
font.setWeight(QtGui.QFont.Bold)
@@ -637,7 +658,7 @@ def showUserList(self, currentUser, rooms):
637658
roomitem.appendRow((useritem, filesizeitem, filedurationitem, filenameitem))
638659
self.listTreeModel = self._usertreebuffer
639660
self.listTreeView.setModel(self.listTreeModel)
640-
self.listTreeView.setItemDelegate(UserlistItemDelegate())
661+
self.listTreeView.setItemDelegate(UserlistItemDelegate(view=self.listTreeView))
641662
self.listTreeView.setItemsExpandable(False)
642663
self.listTreeView.setRootIsDecorated(False)
643664
self.listTreeView.expandAll()
@@ -849,7 +870,10 @@ def showErrorMessage(self, message, criticalerror=False):
849870
message = message.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
850871
message = message.replace("&lt;a href=&quot;https://syncplay.pl/trouble&quot;&gt;", '<a href="https://syncplay.pl/trouble">').replace("&lt;/a&gt;", "</a>")
851872
message = message.replace("\n", "<br />")
852-
message = "<span style=\"{}\">".format(constants.STYLE_ERRORNOTIFICATION) + message + "</span>"
873+
if isDarkMode:
874+
message = "<span style=\"{}\">".format(constants.STYLE_DARK_ERRORNOTIFICATION) + message + "</span>"
875+
else:
876+
message = "<span style=\"{}\">".format(constants.STYLE_ERRORNOTIFICATION) + message + "</span>"
853877
self.newMessage(time.strftime(constants.UI_TIME_FORMAT, time.localtime()) + message + "<br />")
854878

855879
@needsClient
@@ -1259,14 +1283,16 @@ def addTopLayout(self, window):
12591283

12601284
window.outputLayout = QtWidgets.QVBoxLayout()
12611285
window.outputbox = QtWidgets.QTextBrowser()
1286+
if isDarkMode: window.outputbox.document().setDefaultStyleSheet(constants.STYLE_DARK_LINKS_COLOR);
12621287
window.outputbox.setReadOnly(True)
12631288
window.outputbox.setTextInteractionFlags(window.outputbox.textInteractionFlags() | Qt.TextSelectableByKeyboard)
12641289
window.outputbox.setOpenExternalLinks(True)
12651290
window.outputbox.unsetCursor()
12661291
window.outputbox.moveCursor(QtGui.QTextCursor.End)
12671292
window.outputbox.insertHtml(constants.STYLE_CONTACT_INFO.format(getMessage("contact-label")))
12681293
window.outputbox.moveCursor(QtGui.QTextCursor.End)
1269-
window.outputbox.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
1294+
window.outputbox.setCursorWidth(0)
1295+
if not isMacOS(): window.outputbox.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
12701296

12711297
window.outputlabel = QtWidgets.QLabel(getMessage("notifications-heading-label"))
12721298
window.outputlabel.setMinimumHeight(27)
@@ -1414,8 +1440,6 @@ def addBottomLayout(self, window):
14141440
playlistItem = QtWidgets.QListWidgetItem(getMessage("playlist-instruction-item-message"))
14151441
playlistItem.setFont(noteFont)
14161442
window.playlist.addItem(playlistItem)
1417-
playlistItem.setFont(noteFont)
1418-
window.playlist.addItem(playlistItem)
14191443
window.playlistLayout.addWidget(window.playlist)
14201444
window.playlistLayout.setAlignment(Qt.AlignTop)
14211445
window.playlistGroup.setLayout(window.playlistLayout)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#-----------------------------------------------------------------------------
2+
# Copyright (C) 2019 Alberto Sottile
3+
#
4+
# Distributed under the terms of the 3-clause BSD License.
5+
#-----------------------------------------------------------------------------
6+
7+
__version__ = '0.1.0'
8+
9+
import sys
10+
import platform
11+
from distutils.version import LooseVersion as V
12+
13+
if sys.platform != "darwin" or V(platform.mac_ver()[0]) < V("10.14"):
14+
from ._dummy import *
15+
else:
16+
from ._detect import *
17+
18+
del sys, platform, V
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#-----------------------------------------------------------------------------
2+
# Copyright (C) 2019 Alberto Sottile
3+
#
4+
# Distributed under the terms of the 3-clause BSD License.
5+
#-----------------------------------------------------------------------------
6+
7+
import ctypes
8+
import ctypes.util
9+
10+
appkit = ctypes.cdll.LoadLibrary(ctypes.util.find_library('AppKit'))
11+
objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library('objc'))
12+
13+
void_p = ctypes.c_void_p
14+
ull = ctypes.c_uint64
15+
16+
objc.objc_getClass.restype = void_p
17+
objc.sel_registerName.restype = void_p
18+
objc.objc_msgSend.restype = void_p
19+
objc.objc_msgSend.argtypes = [void_p, void_p]
20+
21+
msg = objc.objc_msgSend
22+
23+
def _utf8(s):
24+
if not isinstance(s, bytes):
25+
s = s.encode('utf8')
26+
return s
27+
28+
def n(name):
29+
return objc.sel_registerName(_utf8(name))
30+
31+
def C(classname):
32+
return objc.objc_getClass(_utf8(classname))
33+
34+
def theme():
35+
NSAutoreleasePool = objc.objc_getClass('NSAutoreleasePool')
36+
pool = msg(NSAutoreleasePool, n('alloc'))
37+
pool = msg(pool, n('init'))
38+
39+
NSUserDefaults = C('NSUserDefaults')
40+
stdUserDef = msg(NSUserDefaults, n('standardUserDefaults'))
41+
42+
NSString = C('NSString')
43+
44+
key = msg(NSString, n("stringWithUTF8String:"), _utf8('AppleInterfaceStyle'))
45+
appearanceNS = msg(stdUserDef, n('stringForKey:'), void_p(key))
46+
appearanceC = msg(appearanceNS, n('UTF8String'))
47+
48+
if appearanceC is not None:
49+
out = ctypes.string_at(appearanceC)
50+
else:
51+
out = None
52+
53+
msg(pool, n('release'))
54+
55+
if out is not None:
56+
return out.decode('utf-8')
57+
else:
58+
return 'Light'
59+
60+
def isDark():
61+
return theme() == 'Dark'
62+
63+
def isLight():
64+
return theme() == 'Light'
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#-----------------------------------------------------------------------------
2+
# Copyright (C) 2019 Alberto Sottile
3+
#
4+
# Distributed under the terms of the 3-clause BSD License.
5+
#-----------------------------------------------------------------------------
6+
7+
def theme():
8+
return None
9+
10+
def isDark():
11+
return None
12+
13+
def isLight():
14+
return None

0 commit comments

Comments
 (0)