The reason why this is so difficult is that, in contrast to the majority of other widgets where the width is specified in characters (or at least, the size of an average character which is usually close enough,) the Treeview widget takes widths in pixels which is useless for the purpose of measuring text.
Furthermore, while we can use the Tkinter Font
object to measure the width of some text, it doesn't account for the images or padding that can exist in a Treeview column, which eat up some of that space. So to truly fix this problem we must go through each row, getting all the fonts in use, images, and padding settings to determine the true width of the text area.
First and foremost, the script below uses a class I wrote called Once
that is basically like a set except it only requires a single lookup to both insert items and check if they were in there previously. So here are the contents of once.py:
class Once:
__slots__ = ('_obj', '_count')
def __init__(self, type_=dict):
# this class is similar to a set
# but with the distinction we know if an added key already exists
# with only a single lookup
self._obj = type_() # the underlying object (anything implementing setdefault)
self._count = 0 # infinitely increasing count
def __contains__(self, item):
return item in self._obj
def __len__(self):
return len(self._obj)
def __iter__(self):
# for a standard dictionary it would not be necessary to clarify
# that I want the keys, but for some custom object maybe
return iter(self.keys())
def clear(self):
self._obj.clear()
def add(self, key):
# add the key
# returns True if key was added, False if existed before
obj = self._obj
# reset count if obj is empty
# this must be done here instead of the other methods
# in case obj changes underneath us
# (for example, it's a WeakKeyDictionary and a key gets dropped)
# it's not strictly necessary to reset count
# particularly in Python, where we can't overflow it anyway
# but the hope is that it keeps the count in a somewhat reasonable range
count = self._count + 1 if obj else 1
self._count = count
return self._obj.setdefault(key, count) == count
def discard(self, key):
# discard the key
# returns True if it existed, False if it did not
return self._obj.pop(key, 0) != 0
def keys(self):
return self._obj.keys()
Alright, now for the script itself. It's quite long and complicated, so I'll post the full thing first and then walk through it step by step.
import tkinter as tk
from tkinter import ttk
from tkinter.font import Font
from contextlib import suppress
from math import ceil
import once
DEFAULT_MINWIDTH = -1
# these default numbers come from the Tk Treeview documentation
DEFAULT_TREEVIEW_INDENT = 20
DEFAULT_TREEVIEW_CELL_PADDING = (4, 0)
def padding4_widget(widget, padding):
with suppress(TypeError):
padding = widget.tk.splitlist(padding)
if not padding:
return [0.0, 0.0, 0.0, 0.0]
def fpixels(*lengths):
return [widget.winfo_fpixels(l) for l in lengths]
# should raise TypeError is padding is just an integer
with suppress(TypeError):
# should raise ValueError if too many values to unpack
try:
left, top, right, bottom = padding
except ValueError:
pass
else:
return fpixels(left, top, right, bottom)
try:
left, vertical, right = padding
except ValueError:
pass
else:
return fpixels(left, vertical, right, vertical)
try:
horizontal, vertical = padding
except ValueError:
pass
else:
return fpixels(horizontal, vertical, horizontal, vertical)
padding, = padding
return fpixels(padding, padding, padding, padding)
def lookup_style_widget(widget, option, element='', state=None, **kwargs):
style = str(widget['style'])
if not style:
style = widget.winfo_class()
if element:
style = '.'.join((style, element))
if state is None:
with suppress(tk.TclError):
state = widget.state()
return ttk.Style(widget).lookup(style, option, state=state, **kwargs)
def measure_text_width_widget(widget, width, font):
# cast font descriptors to font objects
if not isinstance(font, Font):
font = Font(font=font)
# find average width using '0' character like Tk does
# see: https://www.tcl-lang.org/man/tcl8.6/TkCmd/text.htm#M21
return width * font.measure('0', displayof=widget)
def _minwidth_treeview():
default = DEFAULT_MINWIDTH
def get(minwidth=DEFAULT_MINWIDTH):
nonlocal default
if minwidth != DEFAULT_MINWIDTH:
return minwidth
if default != DEFAULT_MINWIDTH:
return default
return (default := ttk.Treeview().column('#0', 'minwidth'))
return get
get_minwidth_treeview = _minwidth_treeview()
def indents_treeview(treeview, item=None):
if item is None: return 0
def parent():
nonlocal item
# must check for empty string specifically (zero should fall through)
return '' != (item := str(treeview.parent(item)))
indents = 0
while parent():
indents += 1
return indents
def measure_widths_treeview(treeview, widths, item=None):
# get the per-treeview indent, padding and font
indent = lookup_style_widget(treeview, 'indent')
try:
indent = treeview.winfo_fpixels(indent)
except tk.TclError:
indent = DEFAULT_TREEVIEW_INDENT
def width_padding(padding):
left, top, right, bottom = padding4_widget(treeview, padding)
return left + right
padding_width = width_padding(DEFAULT_TREEVIEW_CELL_PADDING)
font = lookup_style_widget(treeview, 'font')
if not font:
font = 'TkDefaultFont'
fonts = {font}
# get the per-heading padding and font, but only if the heading is shown
show_headings = 'headings' in [str(s) for s in treeview.tk.splitlist(treeview['show'])]
if show_headings:
padding_width = max(padding_width, width_padding(
lookup_style_widget(treeview, 'padding', element='Heading')))
font = lookup_style_widget(treeview, 'font', element='Heading')
if font:
fonts.add(font)
def width_image(image):
return int(treeview.tk.call('image', 'width', image)) if image else 0
item_image_width = 0
# get the per-tag padding, fonts, and images
tags = once.Once()
for child in treeview.get_children(item=item):
for child_tag in treeview.tk.splitlist(treeview.item(child, 'tags')):
# first check if we've already done this tag before
# although it doesn't take very long to query a tag's configuration, it is still
# worth checking if we've done it yet, as it is likely there are many many columns
# but only a few tags they are collectively using
if not tags.add(child_tag):
continue
# after confirming we have not done the tag yet, query the tag's configuration
# ideally, this would only get the "active" tag
# but there isn't any way to tell what is the top tag in the stacking order
# even in the worst case scenario of a conflict though, the column will always be wide enough
try:
padding = treeview.tag_configure(child_tag, 'padding')
except tk.TclError:
pass # not supported in this version
else:
padding_width = max(padding_width, width_padding(padding))
try:
font = treeview.tag_configure(child_tag, 'font')
except tk.TclError:
pass # not supported in this version
else:
if font: fonts.add(font)
try:
image = treeview.tag_configure(child_tag, 'image')
except tk.TclError:
pass # not supported in this version
else:
item_image_width = max(item_image_width, width_image(image))
# get the per-item image
item_image_width = max(item_image_width,
width_image(treeview.item(child, 'image')))
# get the per-element (item/cell) padding
item_padding_width = width_padding(
lookup_style_widget(treeview, 'padding', element='Item'))
cell_padding_width = width_padding(
lookup_style_widget(treeview, 'padding', element='Cell'))
# measure the widths
measured_widths = {}
for cid, width in widths.items():
minwidth = DEFAULT_MINWIDTH
# the width can be a sequence like (width, minwidth) which we unpack here
# if the sequence is too short, just get the width and use default minwidth
# otherwise the width is specified as an integer, not a sequence
try:
width, minwidth = width
except ValueError:
width, = width
except TypeError:
pass
# a width of None means don't do this column
if width is None: continue
# we can't just get the minwidth of the current column to use here
# otherwise, if the minwidth was set to the result of this function
# then it would stack if this function were called multiple times
# so here we get the real default
# this is done after the try block above, because minwidth can be
# manually specified as DEFAULT_MINWIDTH, explicitly meaning to use the default
minwidth = get_minwidth_treeview(minwidth)
# get the per-heading image, but only if the heading is shown
heading_image_width = width_image(treeview.heading(cid, 'image')) if show_headings else 0
# the element (item/cell) padding is added on top of the treeview/tag padding by Tk
# so here we do the same
# for column #0, we need to worry about indents
# on top of that, we include the minwidth in the space width
# this is because the indicator has a dynamic width which we can't directly get
# but it is probably okay to assume it is safely contained in the minwidth
# (otherwise, it'd get cut off when the column is at its minwidth)
# so the space width (including the minwidth) is added on top of the text width
# for all other columns (not #0,) minimum text width is the minwidth, but excluding
# the part of it filled by space width
# this ensures the column won't be smaller than the minwidth (but may be equal to it)
# if the space width fills the entire minwidth, this is undesirable for the measured result
# so in that case, the text width is, in effect, initially zero
space_width = padding_width
text_width = 0
if cid == '#0':
space_width += (
item_padding_width
+ max(item_image_width, heading_image_width)
+ minwidth
+ (indent * indents_treeview(treeview, item=item))
)
else:
space_width += cell_padding_width + heading_image_width
text_width = max(text_width, minwidth - space_width)
# get the text width for the font that would take up the most space in the column
for font in fonts:
text_width = max(text_width, measure_text_width_widget(treeview, width, font))
# must use ceil here because these widths may be floats; Tk doesn't want a float for the width
measured_widths[cid] = ceil(space_width + text_width)
return measured_widths
def configure_widths_treeview(treeview, widths, item=None):
measured_widths = measure_widths_treeview(treeview, widths, item=item)
for cid, width in measured_widths.items():
treeview.column(cid, width=width, minwidth=width, stretch=False)
def main():
window = tk.Tk()
treeview = ttk.Treeview(window, columns=(0, 1), show='headings')
treeview.heading(0, text='Column 1')
treeview.heading(1, text='Column 2')
treeview.insert('', tk.END, 0, values=('Item A', 'Item B'))
treeview.insert('', tk.END, 1, values=('Item C', 'Item D'))
treeview.insert('', tk.END, 2, values=('Item E', 'Item F'))
configure_widths_treeview(treeview, {0: 10})
treeview.grid()
window.mainloop()
if __name__ == '__main__': main()
As you can see if you just run this, the leftmost column gets set to a width of ten characters.
Okay, so what on earth is this all doing? Well, like I said, it's carving out the width of everything around the text so we can determine how much space is just the text area. The majority of the heavy lifting here is done by measure_widths_treeview
, so let's go through it bit by bit.
indent = lookup_style_widget(treeview, 'indent')
try:
indent = treeview.winfo_fpixels(indent)
except tk.TclError:
indent = DEFAULT_TREEVIEW_INDENT
For the leftmost "tree" column - that is, column #0 - the indentation of child items occupies space in the column that we need to subtract off the width. Here we get that. You can also see here the first use of a function I'm going to be using a lot: winfo_fpixels
. This is because in Tkinter, widths can be specified not only as pixels, but also as measurements. For example, adding a 'c' after a width will cause it to be in centimetres. So, any time we deal with a screen distance, we need to ensure to convert it into a pixel width so we can cleanly add them all together.
If no indent is specified, a default value is used, stated in the documentation to be 20 pixels. As far as I know, there is no way to get this value out of Tkinter, so it just has to be hardcoded as a constant.
def width_padding(padding):
left, top, right, bottom = padding4_widget(treeview, padding)
return left + right
padding_width = width_padding(DEFAULT_TREEVIEW_CELL_PADDING)
The width_padding
inner function is intended to get the combined width of the padding for a Treeview column. It works by calling another function, padding4_widget
, which takes any of the various formats you're allowed to specify padding in for Tk (integer, tuple, space separated string...) and converts it into a tuple of pixels for each side. Then it just adds together the left and right padding to get the full padding width.
The padding_width
variable is eventually going to contain the maximum padding width applied across any given row in the Treeview. To start with, we initialize it to the default padding for Treeview cells, stated in the documentation to be (4, 0). Note that the documentation for versions of Tk prior to Tk 9.0 do not state the default padding, so technically this is an implementation detail in Tk 8.6. In practice, it appears to be the same (I've measured it.) Again, this needs to just be a hardcoded constant because there is no way to get this information out of Tkinter.
font = lookup_style_widget(treeview, 'font')
if not font:
font = 'TkDefaultFont'
fonts = {font}
To be able to know the size of the text, we're going to need to know the largest font that is being shown in the Treeview. To accomplish this, we use another function in the script, lookup_style_widget
, to get the default font for the Treeview, and add that to a set. If this fails, we default to TkDefaultFont
which is probably a safe bet. This is a great start, but it's also possible to set the font differently for individual rows, so later we'll loop through those and add them to the set as well.
# get the per-heading padding and font, but only if the heading is shown
show_headings = 'headings' in [str(s) for s in treeview.tk.splitlist(treeview['show'])]
if show_headings:
padding_width = max(padding_width, width_padding(
lookup_style_widget(treeview, 'padding', element='Heading')))
font = lookup_style_widget(treeview, 'font', element='Heading')
if font:
fonts.add(font)
Building on what we just did: now that we have the default padding/font for the entire Treeview, now we get the default padding/font for the Heading, which can be set individually. However, we only do this if the heading is actually being shown, as otherwise we don't need to reserve any additional space for it anyway. splitlist
is used here to convert the show
option from any of the various forms it is allowed to be in (tuple, space separated string...) into a list. Often that list will then contain "Tcl Objects" which we explicitly convert to Python strings via the use of a list comprehension.
def width_image(image):
return int(treeview.tk.call('image', 'width', image)) if image else 0
item_image_width = 0
A treeview can contain an image in its "tree" column, so like the indent, this counts towards its width and we need to subtract it off.
Usually in Tkinter to create images, we use the BitmapImage
or PhotoImage
objects. However here we are going to be getting an image that already previously existed off of our treeview, which will only tell us its string name. In order to get the width of an image having only its name, it is necessary to call down to the interpreter directly, so that is what we have a function to do here. item_image_width
is initialized to zero, but if we encounter any image we'll set it to the size of the largest we find.
# get the per-tag padding, fonts, and images
tags = once.Once()
for child in treeview.get_children(item=item):
for child_tag in treeview.tk.splitlist(treeview.item(child, 'tags')):
# first check if we've already done this tag before
# although it doesn't take very long to query a tag's configuration, it is still
# worth checking if we've done it yet, as it is likely there are many many columns
# but only a few tags they are collectively using
if not tags.add(child_tag):
continue
# after confirming we have not done the tag yet, query the tag's configuration
# ideally, this would only get the "active" tag
# but there isn't any way to tell what is the top tag in the stacking order
# even in the worst case scenario of a conflict though, the column will always be wide enough
try:
padding = treeview.tag_configure(child_tag, 'padding')
except tk.TclError:
pass # not supported in this version
else:
padding_width = max(padding_width, width_padding(padding))
try:
font = treeview.tag_configure(child_tag, 'font')
except tk.TclError:
pass # not supported in this version
else:
if font: fonts.add(font)
try:
image = treeview.tag_configure(child_tag, 'image')
except tk.TclError:
pass # not supported in this version
else:
item_image_width = max(item_image_width, width_image(image))
# get the per-item image
item_image_width = max(item_image_width,
width_image(treeview.item(child, 'image')))
This loops through all of the tags in use in the Treeview in order to get the max padding width, max image width, and add every encountered font to our set. We surround these in try-catch statements because some of these attributes aren't available in old versions. In particular, padding
was only added in Tk 9.0, and otherwise we can expect it'll always be the default. (In practice, I have not actually tested padding
here because as far as I know, Tkinter is stuck on Tk 8.6 - so basically, I'm just crossing my fingers it works with width_padding
like everything else.)
This is also where we use that Once
class, as it is highly likely that the same handful of tags will be shared across the whole Treeview so it would be pointless to do the same tag 10, or 100, or 1000 times extra because there are that many items.
There is unfortunately one limitation here, and that is because of tag conflicts. It is possible for the same item to have multiple tags on it, and for both of those tags to specify the same option as a different value. For example, the same item could have two tags: one stating to use Arial as the font, and another stating to use Courier. In cases like these, Tk determines which tag to use based on which one was created most recently. This information is not exposed by Tk in any way, so it isn't possible to tell which tag option is actually currently in use in the event of a conflict.
As such, if there is a tag conflict it is possible we will add a font that is not actually in use to the set and our column will end up slightly too wide. However, it is still guaranteed to be large enough to fully contain our text - there just might be a bit of extra space, which is not the end of the world.
# get the per-element (item/cell) padding
item_padding_width = width_padding(
lookup_style_widget(treeview, 'padding', element='Item'))
cell_padding_width = width_padding(
lookup_style_widget(treeview, 'padding', element='Cell'))
Here, we get the per-item and per-cell padding, because yes: in addition to the Treeview's default padding, and Tags which allow specifying padding on a per-row basis, we can also specify the default padding for the actual Items and Cells. The term "Item" throughout most of the Treeview documentation refers to the entire row, but in this instance it specifically refers to the "tree" column, or column #0 - its padding is added on top of the indent for that column.
minwidth = get_minwidth_treeview(minwidth)
Unlike the other constants we've needed to hardcode, it is possible to get the default minwidth for a Treeview column by just constructing a fresh one and getting the value off of it, so get_minwidth_treeview
is a function to do that and then cache the result so we don't need to create a new Treeview every time we want this value.
After all this boilerplate setup, we can get to the actual core of the function.
if cid == '#0':
space_width += (
item_padding_width
+ max(item_image_width, heading_image_width)
+ minwidth
+ (indent * indents_treeview(treeview, item=item))
)
else:
space_width += cell_padding_width + heading_image_width
text_width = max(text_width, minwidth - space_width)
# get the text width for the font that would take up the most space in the column
for font in fonts:
text_width = max(text_width, measure_text_width_widget(treeview, width, font))
# must use ceil here because these widths may be floats; Tk doesn't want a float for the width
measured_widths[cid] = ceil(space_width + text_width)
As mentioned, the rules surrounding the leftmost, #0 column is different than for every other column. Here, space_width
is the final result of getting all the indentation and padding: it's the width of the space around the text. text_width
we calculate by measuring all the fonts we collected, using the measure_text_width_widget
function, which performs the same measurement that Tk's width
usually does when it's measuring in characters. We use whatever the largest value is, produced by the largest font in the Treeview.
Finally, we add back together the space_width
and text_width
, call ceil
to turn our float back into a pixel width integer as Tkinter expects for Treeview widths, and return that out. And that's it, we now can set the width of a Treeview column in terms of character width:
configure_widths_treeview(treeview, {0: 10})
And you can also set a minwidth in pixels with a tuple:
configure_widths_treeview(treeview, {0: (10, 100)})
If you now wanted to set a width to that of the longest cell, you could just loop through your values with len
to get the length of the longest string. That's trivial so I'll leave it as an exercise to the reader.
It's a good idea to leave one column without a width, otherwise none of the columns will have any "slack," so it will cause some strange resizing behaviour.