Python/Tk Experiments

Noah Petherbridge
kirsle
Posted by Noah Petherbridge on Wednesday, October 08 2014 @ 01:12:44 PM

I decided to take a look at the Tk GUI framework for Python and put together a simple mockup GUI to test various things.

I'd worked with Tk before in Perl (see my Perl CyanChat Client) and if you look at some of the Linux screenshots on that page, Tk looks ugly as hell in Perl.

Python's implementation of Tk (which they call Tkinter) is more modern than Perl's, and there's other neat helper modules like ttk which provides the Tk "Tile" theming engine which makes the standard Tk widgets look more modern, and takes a CSS-style approach to theming your widgets: instead of manually specifying things like background and foreground colors in each widget you program, you keep all that stuff in one central place and refer to it by name from the widgets.

For my Python mockup test app, I put together a rough copy of my Perl CyanChat Client GUI.

At first I was trying to use the ttk/Tile versions of the widgets (such as Button, Entry, etc.), but I ran into a rather annoying roadblock: in PCCC, my text Entry widgets have black background colors, and the insertion cursor (the little flashing I-beam in a text box) is also black by default. So when clicking in the text box, you wouldn't be able to see the insertion cursor.

In the standard Tkinter Entry widget, you can use the insertbackground option to change the color of the insertion cursor. But in ttk/Tile? There is no insertbackground option. Source - it's just not supported in ttk/Tile and their theming engine.

So I decided to not use ttk and just use the standard Tk widgets. I liked ttk's centralized styling system though, so I made a central class of dictionaries full of configuration attributes that I could easily reference when setting up my widgets. So, I eventually got my GUI put together and it looked nice enough I guess...

Tk widgets with ugly scrollbar

Except for those ugly scrollbars. The "1980s 3D" look to the scrollbar and those ugly triangle arrow widgets are from the Motif GUI which Tk was originally modeled to look like. It's ancient and it's ugly. This was also one of the main reasons why my Perl CyanChat Client looks so horrible under Linux, because this is Tk and Tk is ancient.

The Tile theming engine is supposed to fix this, but I wasn't using Tile in my code because of the aforementioned text insertion cursor problem. The best I could do with the standard Tk scrollbar is color it to make it look kind of "cool" at least, so I made it all black and grey to fit the theme of the rest of my GUI.

But then I figured out I can mix and match the widgets. I could import the Scrollbar from ttk while importing all the other widgets from Tkinter. The result?

Nice scrollbars!

That's better.

I probably won't create a full CyanChat client in Python because I really don't care about CyanChat much anymore, so this was mostly just me messing around with Tk and seeing how practical it is for certain use cases. But here's the source code anyway.

There's a few interesting things in the code, like I created my own "Scrolled" class for wrapping a widget in a scrollbar (works with Text and Listbox), so it's kinda like Python's ScrolledText module, but it's really more like Perl's Tk::Scrolled module in that it can wrap arbitrary widgets, not just Text.

Also, Tkinter's Text widget can't be made read-only. You can make a text box disabled, but that also prevents programmatic insertions/deletions as well. So I made a little function for inserting text that would first re-enable it, then insert text, then disable it again.

#!/usr/bin/env python

"""My test script for Python/Tk experimentation."""

import Tkinter as tk
from Tkinter import Tk, StringVar, Frame, Label, Text, Entry, Button, Listbox, END
from ttk import Scrollbar


class ChatClient(object):
    def __init__(self):
        # Styles
        self.style = MainWindowStyles()

        self.setup()

    def setup(self):
        self.mw = Tk()
        self.mw.title("Python CyanChat Client")
        resize_and_center(self.mw, 640, 480)

        # Variables
        self.nickname = StringVar(self.mw, "Kirsle")
        self.message = StringVar(self.mw, "--disabled--")

        # Top Frame (name entry box, buttons, conn status)
        self.login_frame = Frame(self.mw, **self.style.Frame)
        self.lower_frame = Frame(self.mw, **self.style.Frame)
        self.login_frame.pack(side="top", fill="x")
        self.lower_frame.pack(side="top", fill="both", expand=1)

        # The lower left (message entry, chat history) and lower right
        # (who lists)
        self.left_frame = Frame(self.lower_frame, **self.style.Frame)
        self.right_frame = Frame(self.lower_frame, **self.style.Frame)
        self.right_frame.pack(side="right", fill="y")
        self.left_frame.pack(side="right", fill="both", expand=1)

        # The message entry & chat history frames
        self.message_frame = Frame(self.left_frame, **self.style.Frame)
        self.dialogue_frame = Frame(self.left_frame, **self.style.Frame)
        self.message_frame.pack(side="top", fill="x")
        self.dialogue_frame.pack(side="top", fill="both", expand=1)

        ###
        # Top Frame Widgets
        ###

        self.name_label = Label(self.login_frame,
            text="Name:",
            **self.style.Label
        )
        self.name_entry = Entry(self.login_frame,
            textvariable=self.nickname,
            width=20,
            **self.style.DarkEntry
        )
        self.enter_exit_button = Button(self.login_frame,
            text="Enter chat",
            **self.style.Button
        )
        self.status_label = Label(self.login_frame,
            text="Connected to CyanChat",
            **self.style.ConnectedLabel
        )
        self.name_label.pack(side="left", padx=5, pady=5)
        self.name_entry.pack(side="left", pady=5)
        self.enter_exit_button.pack(side="left", padx=5, pady=5)
        self.status_label.pack(side="left")

        ###
        # Message Frame Widgets
        ###

        self.message_entry = Entry(self.message_frame,
            textvariable=self.message,
            state="disabled",
            **self.style.Entry
        )
        self.message_entry.pack(
            side="top",
            fill="x",
            padx=10,
            pady=10,
            expand=1,
        )

        ###
        # Who Frame Widgets
        ###

        self.who_label = Label(self.right_frame,
            text="Who is online:",
            anchor="w",
            **self.style.Label
        )
        self.who_label.pack(side="top", fill="x")

        self.who_list = Scrolled(self.right_frame, Listbox,
            attributes=self.style.Listbox,
            scrollbar=self.style.Scrollbar,
        )
        self.who_list.pack(side="top", fill="both", expand=1)

        for i in range(200):
            self.who_list.widget.insert(END, "Anonymous{}".format(i))

        ###
        # Dialogue Frame Widgets
        ###

        self.dialogue_text = Scrolled(self.dialogue_frame, Text,
            attributes=self.style.Dialogue,
            scrollbar=self.style.Scrollbar,
        )
        self.chat_styles(self.dialogue_text.widget)
        self.dialogue_text.pack(side="top", fill="both", padx=10, pady=0, expand=1)

        # Dummy junk
        messages = [
            [["[Kirsle]", "user"], [" Hello room!"]],
            [["\\\\\\\\\\", "server"], ["[Kirsle]", "user"], [" <links in from comcast.net Age>"], ["/////", "server"]],
            [["[ChatServer] ", "server"], ["Welcome to the Cyan Chat room."]],
            [["[ChatServer] ", "server"], ["There are only a few rules:"]],
            [["[ChatServer] ", "server"], ["   Be respectful and sensitive to others"]],
            [["[ChatServer] ", "server"], ["   And HAVE FUN!"]],
            [["[ChatServer] ", "server"], [""]],
            [["[ChatServer] ", "server"], ["Termination of use can happen without warning!"]],
            [["[ChatServer] ", "server"], [""]],
            [["[ChatServer] ", "server"], ["Server commands now available, type !\\? at the beginning of a line."]],
            [["[ChatServer] ", "server"], ["CyanChat Server version 2.12d"]],
        ]
        for i in range(80):
            messages.append([["[ChatClient]", "client"], [" Connecting..."]])
        messages.reverse()
        for line in messages:
            self.insert_readonly(self.dialogue_text, 0.0, "\n")
            line.reverse()
            for part in line:
                self.insert_readonly(self.dialogue_text, 0.0, *part)
        #self.insert_readonly(self.dialogue_text, END, "[Admin]", "admin")

    def chat_styles(self, widget):
        """Configure chat text styles."""
        # User colors
        widget.tag_configure("user", foreground="#FFFFFF")
        widget.tag_configure("guest", foreground="#FF9900")
        widget.tag_configure("admin", foreground="#00FFFF")
        widget.tag_configure("server", foreground="#00FF00")
        widget.tag_configure("client", foreground="#FF0000")

    def insert_readonly(self, widget, *args):
        """Insert text into a readonly (disabled) widget."""
        widget.widget.configure(state="normal")
        widget.widget.insert(*args)
        widget.widget.configure(state="disabled")

    def start(self):
        self.mw.mainloop()


class MainWindowStyles(object):
    """Simple Python class to hold style-related configurations for widgets."""
    Frame = dict(
        bg="#000000",
    )

    BaseLabel = dict(
        font="Verdana 8",
    )
    Label = dict(
        bg="#000000",
        fg="#CCCCCC",
        **BaseLabel
    )
    ConnectedLabel = dict(
        bg="#000000",
        fg="#00FF00",
        **BaseLabel
    )

    BaseFormCtrl=dict(
        highlightthickness=0, # Removes stupid border around the widget
    )

    BaseEntry = dict(
        insertwidth=1,
        selectborderwidth=0,
        selectbackground="#0099FF",
        font="Verdana 8",
        **BaseFormCtrl
    )
    Entry = dict(
        bg="#FFFFFF",
        fg="#000000",
        disabledbackground="#000000",
        disabledforeground="#666666",
        insertbackground="#000000",
        **BaseEntry
    )
    DarkEntry = dict(
        bg="#000000",
        fg="#CCCCCC",
        insertbackground="#FFFFFF", # Text insertion blinking cursor
        **BaseEntry
    )

    Listbox = dict(
        bg="#000000",
        fg="#CCCCCC",
        **BaseFormCtrl
    )

    Dialogue = dict(
        bg="#000000",
        fg="#CCCCCC",
        #disabledbackground="#000000",
        #disabledforeground="#CCCCCC",
        wrap=tk.WORD,
        state="disabled",
        **BaseEntry
    )

    Button = dict(
        bg="#000000",
        fg="#CCCCCC",
        activebackground="#000000",
        activeforeground="#0099FF",
        **BaseFormCtrl
    )

    # If using the Tkinter scrollbar, uncommon these. If using the ttk
    # scrollbar, use ttk's theming system instead.
    Scrollbar = dict(
        #relief="flat",
        #troughcolor="#000000",
        #bg="#606060",
        #activebackground="#999999",
        #borderwidth=1,
        #width=12,
        #highlightthickness=0,
    )


class Scrolled(object):
    """My own implementation for adding a scrollbar to a widget. Similar in
    principal to Python's ScrolledText module, but it works on other widgets too
    (this script uses it on Listbox too). So it's more like the Perl/Tk module
    Tk::Scrolled in that it can wrap any widget, in theory."""

    def __init__(self, master, widget_class, attributes=None, scrollbar=None):
        """
        master is the parent widget
        widget_class is the class, like Text or Listbox
        attributes are attributes for the widget
        scrollbar are attributes for the scrollbar
        """
        if attributes is None:
            attributes = []
        if scrollbar is None:
            scrollbar = []

        self.master = master

        # Parent frame to hold the widget + scrollbar
        self.frame  = Frame(master)

        # The scrollbar
        self.scrollbar = Scrollbar(self.frame, **scrollbar)

        # The widget itself
        self.widget = widget_class(self.frame,
            yscrollcommand=self.scrollbar.set,
            **attributes
        )
        self.scrollbar.configure(command=self.widget.yview)

        self.scrollbar.pack(side="right", fill="y")
        self.widget.pack(side="right", fill="both", expand=1)

    def widget(self):
        """Get at the inner widget."""
        return self.widget

    def scrollbar(self):
        """Get at the scrollbar widget."""
        return self.scrollbar

    def pack(self, **kwargs):
        """Wrapper so that pack() works as you'd expect."""
        self.frame.pack(**kwargs)


def resize_and_center(win, width, height):
    """Resize a window and center it on the screen."""
    screen_w = win.winfo_screenwidth()
    screen_h = win.winfo_screenheight()
    geometry = "{}x{}+{}+{}".format(
        width,
        height,
        screen_w / 2 - width / 2,
        screen_h / 2 - height / 2,
    )
    win.geometry(geometry)


if __name__ == "__main__":
    app = ChatClient()
    app.start()

Categories:

[ Blog ]

Comments

There are 2 comments on this page.

Avatar
guest
Posted on Monday, March 21 2016 @ 12:20:53 PM by Fabrizio Ojeda.

Really cool bro.. i'll take a look at your code! ty very much for sharing :D

guest
guest
Posted on Tuesday, April 26 2016 @ 01:57:58 PM by Pythonys.

Thanks for the code :)

Add a Comment

Your name:
Your Email:
Message:
Comments can be formatted with Markdown, and you can use
emoticons in your comment.

If you can see this, don't touch the following fields.