ttk, How to position heading inside Labelframe instead of on the border?

Viewed 87

I am trying to create an own ttk Theme based on my company's CI. I took the Sun Valley theme as starting point and swapped out graphics, fonts and colors.

However I am stuck on the Label frame. I am trying to position the Label within the frame, kind of like a heading. I.e. there should be some margin between top edge and label, and appropriate top-padding for the content (child widgets).

Now:

+-- Label ------
| ...

Desired:

+---------------
| Label
| ...

I tried to set the padding option:

  • within the Layout
  • on TLabelframe itself
  • on TLabelframe.Label

but the label did not move a pixel. How to achieve this?

Generally I am very confused about what identifiers and options are legal within ttk:style layout, ttk:style element and ttk:style configure, because documentation is hazy and scattered all over the 'net, and there are no error messages whatsoever. Any helpful tips?

Edit: What I found out since posting:

  • The Labelframe label is a separate widget altogether, with the class TLabelframe.Label.
  • It is possible to override its layout and add a spacer on top, shifting the text down.
  • However, the label widget is v-centered on the frame line. If its height increases, it pushes "upward" as much as downward. I found no way to alter the alignment w.r.t. to the actual frame.
  • It might be possible to replace Labelframe altogether with a custom Frame subclass with the desired layout. But that means changing the "client" code in many places. :-/
3 Answers

This can be done by changing the layout definitions so that the text element is held by the Labelframe layout and the Layoutframe.Label no longer draws the text element. Adding a bit of padding ensures the contained widgets leave the label clear.

screenshot of example program running on Windows 10

Example code:

import sys
import tkinter as tk
import tkinter.ttk as ttk


class CustomLabelframe(ttk.Labelframe):
    def __init__(self, master, **kwargs):
        """Initialize the widget with the custom style."""
        kwargs["style"] = "Custom.Labelframe"
        super(CustomLabelframe, self).__init__(master, **kwargs)

    @staticmethod
    def register(master):
        style = ttk.Style(master)
        layout = CustomLabelframe.modify_layout(style.layout("TLabelframe"), "Custom")
        style.layout('Custom.Labelframe.Label', [
            ('Custom.Label.fill', {'sticky': 'nswe'})])
        style.layout('Custom.Labelframe', [
            ('Custom.Labelframe.border', {'sticky': 'nswe', 'children': [
                ('Custom.Labelframe.text', {'side': 'top'}),
                ('Custom.Labelframe.padding', {'side': 'top', 'expand': True})
            ]})
        ])
        if (style.configure('TLabelframe')):
            style.configure("Custom.Labelframe", **style.configure("TLabelframe"))
        # Add space to the top to prevent child widgets overwriting the label.
        style.configure("Custom.Labelframe", padding=(0,12,0,0))
        style.map("Custom.Labelframe", **style.map("TLabelframe"))
        master.bind("<<ThemeChanged>>", lambda ev: CustomLabelframe.register(ev.widget))
        
    @staticmethod
    def modify_layout(layout, prefix):
        """Copy a style layout and rename the elements with a prefix."""
        result = []
        for item in layout:
            element,desc = item
            if "children" in desc:
                desc["children"] = HistoryCombobox.modify_layout(desc["children"], prefix)
            result.append((f"{prefix}.{element}",desc))
        return result


class App(ttk.Frame):
    """Test application for the custom widget."""
    def __init__(self, master, **kwargs):
        super(App, self).__init__(master, **kwargs)
        self.master.wm_geometry("640x480")
        
        frame = self.create_themesframe()
        frame.pack(side=tk.TOP, fill=tk.BOTH)
        
        for count in range(3):
            frame = CustomLabelframe(self, text=f"Frame {count}", width=160, height=80)
            frame.pack(side=tk.TOP, expand=True, fill=tk.BOTH)
            button = ttk.Button(frame, text="Test")
            button.pack(side=tk.LEFT)
        self.pack(side=tk.TOP, expand=True, fill=tk.BOTH)

    def create_themesframe(self):
        frame = ttk.Frame(self)
        label = ttk.Label(frame, text="Theme: ")
        themes = ttk.Combobox(frame, values=style.theme_names(), state="readonly")
        themes.current(themes.cget("values").index(style.theme_use()))
        themes.bind("<<ComboboxSelected>>", lambda ev: style.theme_use(ev.widget.get()))
        label.pack(side=tk.LEFT)
        themes.pack(side=tk.LEFT)
        return frame


def main(args=None):
    global root, app, style
    root = tk.Tk()
    style = ttk.Style(root)
    CustomLabelframe.register(root)
    app = App(root)
    try:
        import idlelib.pyshell
        sys.argv = [sys.argv[0], "-n"]
        root.bind("<Control-i>", lambda ev: idlelib.pyshell.main())
    except Exception as e:
        print(e)
    root.mainloop()
    return 0

if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))

It is relatively easy to place ttk.Labelframe text below, on or above the relief graphic. This example uses the text attribute but labelwidget can also be used. In order for the relief to be visible the background color of Labelframe.Label must be set to "".

import tkinter as tk
from tkinter import font
from tkinter import ttk

message = "Hello World"

master = tk.Tk()
style = ttk.Style(master)
style.theme_use(themename = "default")

actualFont = font.Font(
    family = "Courier New", size = 20, weight = "bold")
style.configure(
    "TLabelframe.Label", background = "", font = actualFont)

frame = ttk.LabelFrame(
    master, labelanchor = "n", text = message)
frame.grid(sticky = tk.NSEW)
frame.rowconfigure(0, weight = 1)
frame.columnconfigure(0, weight = 1)

def change_heading():
    if frame["text"][0] == "\n":
        frame["text"] = f"{message}\n"
    else:
        frame["text"] = f"\n{message}"

button = tk.Button(
    frame, text = "Change", command = change_heading)
button.grid(sticky = "nsew")

master.mainloop()

Looking through the source of the Labelframe widget, I found that:

  • The label is either placed vertically-centered on the frame's border, or flush above it, depending on the -labeloutside config option. (for default NW anchor)
  • i.e. by adding whitespace on top of the text by any means, the label box will extend upwards the same amount as downwards, creating a "dead space" above the frame.
  • There might still be a way to get it "inside" by increasing the border width, but I couldn't get it to work.

I now used the labeloutside option to make a "tab-like" heading.

    # ... (define $images array much earlier) ...
    ttk::style element create Labelframe.border image $images(card2) \
        -border 6 -padding 6 -sticky nsew
    ttk::style configure TLabelframe -padding {8 8 8 8} -labeloutside 1 -labelmargins {2 2 2 0}
    ttk::style element create Label.fill image $images(header2) -height 31 -padding {8 0 16 0} -border 1

With suitable images, this is nearly what I was aiming for, only that the header does not stretch across the full frame width. Tkinter elements use a "9-patch"-like subdivision strategy for images, so you can make stretchable frames using the -border argument for element create.

Result is approximately this:

+-------------+
| Heading     |
+-------------+----------------+
| ...                          |
Related