wxPython: Creating a "dark mode"

One day at work last month, I was told that we had a feature request for one of my programs. They wanted a "dark mode" for when they used my application at night as the normal colors were kind of glaring. My program is used in laptops in police cars, so I could understand their frustration. I spent some time looking into the matter and got a mostly working script put together which I'm going to share with my readers. Of course, if you're a long time reader, you probably know I'm talking about a wxPython program. I write almost all my GUIs using wxPython. Anyway, let's get on with the story!

Into the Darkness

Getting the widgets to change color in wxPython is quite easy. The only two methods you need are SetBackgroundColour and SetForegroundColour. The only major problem I ran into when I was doing this was getting my ListCtrl / ObjectListView widget to change colors appropriately. You need to loop over each ListItem and change their colors individually. I alternate row colors, so that made things more interesting. The other problem I had was restoring the ListCtrl's background color. Normally you can set a widget's background color to wx.NullColour (or wx.NullColor) and it will go back to its default color. However, some widgets don't work that way and you have to actually specify a color. It should also be noted that some widgets don't seem to pay any attention to SetBackgroundColour at all. One such widget that I've found is the wx.ToggleButton.

Now you know what I know, so let's look at the code I came up with to solve my issue:

import wx
try:
    from ObjectListView import ObjectListView
except:
    ObjectListView = False

#----------------------------------------------------------------------
def getWidgets(parent):
    """
    Return a list of all the child widgets
    """
    items = [parent]
    for item in parent.GetChildren():
        items.append(item)
        if hasattr(item, "GetChildren"):
            for child in item.GetChildren():
                items.append(child)
    return items

#----------------------------------------------------------------------
def darkRowFormatter(listctrl, dark=False):
    """
    Toggles the rows in a ListCtrl or ObjectListView widget. 
    Based loosely on the following documentation:
    http://objectlistview.sourceforge.net/python/recipes.html#recipe-formatter
    and http://objectlistview.sourceforge.net/python/cellEditing.html
    """
    
    listItems = [listctrl.GetItem(i) for i in range(listctrl.GetItemCount())]
    for index, item in enumerate(listItems):
        if dark:
            if index % 2:
                item.SetBackgroundColour("Dark Grey")
            else:
                item.SetBackgroundColour("Light Grey")
        else:
            if index % 2:
                item.SetBackgroundColour("Light Blue")
            else:
                item.SetBackgroundColour("Yellow")
        listctrl.SetItem(item)

#----------------------------------------------------------------------
def darkMode(self, normalPanelColor):
    """
    Toggles dark mode
    """
    widgets = getWidgets(self)
    panel = widgets[0]
    if normalPanelColor == panel.GetBackgroundColour():
        dark_mode = True
    else:
        dark_mode = False
    for widget in widgets:
        if dark_mode:
            if isinstance(widget, ObjectListView) or isinstance(widget, wx.ListCtrl):
                darkRowFormatter(widget, dark=True)
            widget.SetBackgroundColour("Dark Grey")
            widget.SetForegroundColour("White")
        else:
            if isinstance(widget, ObjectListView) or isinstance(widget, wx.ListCtrl):
                darkRowFormatter(widget)
                widget.SetBackgroundColour("White")
                widget.SetForegroundColour("Black")
                continue
            widget.SetBackgroundColour(wx.NullColor)
            widget.SetForegroundColour("Black")
    self.Refresh()
    return dark_mode

This code is a little convoluted, but it gets the job done. Let's break it down a bit and see how it works. First off, we try to import ObjectListView, a cool 3rd party widget that wraps wx.ListCtrl and makes it a LOT easier to use. However, it's not part of wxPython right now, so you need to test for it's existence. I just set it to False if it doesn't exist.

The GetWidgets function takes a parent parameter, which would usually be a wx.Frame or wx.Panel and goes through all of its children to create a list of widgets, which it then returns to the calling function. The main function is darkMode. It takes two parameters too, the poorly named "self", which refers to a parent widget, and a default panel color. It calls GetWidgets and then uses a conditional statement to decide if dark mode should be enabled or not. Next it loops over the widgets and changes the colors accordingly. When it's done, it will refresh the passed in parent and return a bool to let you know if dark mode is on or off.

There is one more function called darkRowFormatter that is only for setting the colors of the ListItems in a wx.ListCtrl or an ObjectListView widget. Here we use a list comprehension to create a list of wx.ListItems that we then iterate over, changing their colors. To actually apply the color change, we need to call SetItem and pass it a wx.ListItem object instance.

Trying Out Dark Mode

So now you're probably wondering how to actually use the script above. Well, this section will show you how it's done. Here's a simple program with a list control in it and a toggle button too!

import wx
import darkMode

########################################################################
class MyPanel(wx.Panel):
    """"""
 
    #----------------------------------------------------------------------
    def __init__(self, parent):
        """Constructor"""
        wx.Panel.__init__(self, parent)
        self.defaultColor = self.GetBackgroundColour()
 
        rows = [("Ford", "Taurus", "1996", "Blue"),
                ("Nissan", "370Z", "2010", "Green"),
                ("Porche", "911", "2009", "Red")
                ]
        self.list_ctrl = wx.ListCtrl(self, style=wx.LC_REPORT)
 
        self.list_ctrl.InsertColumn(0, "Make")
        self.list_ctrl.InsertColumn(1, "Model")
        self.list_ctrl.InsertColumn(2, "Year")
        self.list_ctrl.InsertColumn(3, "Color")
 
        index = 0
        for row in rows:
            self.list_ctrl.InsertStringItem(index, row[0])
            self.list_ctrl.SetStringItem(index, 1, row[1])
            self.list_ctrl.SetStringItem(index, 2, row[2])
            self.list_ctrl.SetStringItem(index, 3, row[3])
            if index % 2:
                self.list_ctrl.SetItemBackgroundColour(index, "white")
            else:
                self.list_ctrl.SetItemBackgroundColour(index, "yellow")
            index += 1
            
        btn = wx.ToggleButton(self, label="Toggle Dark")
        btn.Bind(wx.EVT_TOGGLEBUTTON, self.onToggleDark)
        normalBtn = wx.Button(self, label="Test")
 
        sizer = wx.BoxSizer(wx.VERTICAL)
        sizer.Add(self.list_ctrl, 0, wx.ALL|wx.EXPAND, 5)
        sizer.Add(btn, 0, wx.ALL, 5)
        sizer.Add(normalBtn, 0, wx.ALL, 5)
        self.SetSizer(sizer)
        
    #----------------------------------------------------------------------
    def onToggleDark(self, event):
        """"""
        darkMode.darkMode(self, self.defaultColor)
 
########################################################################
class MyFrame(wx.Frame):
    """"""
 
    #----------------------------------------------------------------------
    def __init__(self):
        """Constructor"""
        wx.Frame.__init__(self, None, wx.ID_ANY,
                          "MvP ListCtrl Dark Mode Demo")
        panel = MyPanel(self)
        self.Show()
 
#----------------------------------------------------------------------
if __name__ == "__main__":
    app = wx.App(False)
    frame = MyFrame()
    app.MainLoop()

If you run the program above, you should see something like this:

If you click the ToggleButton, you should see something like this:

Notice how the toggle button was unaffected by the SetBackgroundColour method. Also notice that the list control's column headers don't change colors either. Unfortunately, wxPython doesn't expose access to the column headers, so there's no way to manipulate their color.

Anyway, let's take a moment to see how the dark mode code is used. First we need to import it. In this case, the module is called darkMode. To actually call it, we need to look at the ToggleButton's event handler:

darkMode.darkMode(self, self.defaultColor)

As you can see, all we did was call darkMode.darkMode with the panel object (the "self) and a defaultColor that we set at the beginning of the wx.Panel's init method. That's all we had to do too. We should probably set it up with a variable to catch the returned value, but for this example we don't really care.

Wrapping Up

Now we're done and you too can create a "dark mode" for your applications. At some point, I'd like to generalize this some more to make into a color changer script where I can pass whatever colors I want to it. What would be really cool is to make it into a mixin. But that's something for the future. For now, enjoy!

Further Reading

Source Code

Copyright © 2024 Mouse Vs Python | Powered by Pythonlibrary