Tuesday, October 25, 2016

python tkinter label 2

Tired of not having the scrollbar that I wanted on my event log window, I finally revisited my earlier searches and figured out why I hadn't been able to get it working the first time and got it working.

The main thing that had confused me was that placing objects in a "Canvas" widget is not the same thing as packing Frames together. It's almost like there is an entirely separate language within Tkinter for Canvasses. Additionally, it takes a while to pick up on the fact that the scrollbar is a separate widget that is not actually a part of the Canvas or any Frame or other object, you simply place it to the side of the Canvas such that it looks related and then you link its position property to the "view" property of the Canvas (there is a vertical view property and a horizontal view property; in my case I am using one scrollbar to control only the vertical view).

The trick to getting the geometry right was to define a Frame of fixed size, put the Canvas and the scrollbar side by side in the Frame such that their edges fill it, then put a Frame in a window on the Canvas and then inside that Frame put a text Label which can expand, which will cause the Frame containing it to expand as needed. (In my case I set the width and wraplength properties of the Label to ensure that it would not expand in the horizontal direction, because I didn't want to have a horizontal scrollbar for my event window, just a vertical scroll bar.) After doing this, the Label is allowed to get as big in the vertical direction as it wants, as in my first implementation. but the amount of text that shows is framed by the size of the window on the Canvas that is holding the interior Frame, and the location of the view of that Frame through the window that is showing on the Canvas is controlled by scrollbar. I did have to define an initial size for the interior Frame which matched the expected size of the viewable canvas area produced by the size of the fixed outside Frame minus the size of the scrollbar, but the size of the inside Frame was not fixed.

The part that I couldn't get right for the longest time is the part where you put the interior Frame in the Canvas. That is because it is not the same mechanism as for the rest of Tkinter, where you are "packing" Frames together inside other Frames. A Canvas is intended to have anything just anywhere on it and allows for overlapping and layering. The interior Frame is inside a "window" of the canvas, and you don't "pack" windows, you "create" them for the canvas. It took forever to get the syntax right for creating the window with the frame inside it and anchoring it where I wanted it. To do it, create a Canvas with an exterior Frame as its root and pack it into the Frame, then create another Frame for the interior frame and use the Canvas.create_window() function which has an argument that links to the frame object that you want to put in the window, a starting coordinate for the location of the window on the canvas, and an anchor geometry argument which I think overrides the coordinate argument. The Label widget can be created and packed into the interior Frame at any time.

Now that I had a window with a Frame inside it that had an expanding Label widget inside it, on a canvas that had a fixed size due to being packed into a nonexpanding Frame next to a scrollbar, I had to link the scrollbar to the viewing area of the window. This requires two things; one that the scrollbar position output is linked to the canvas's yview parameter (why it's the canvas's yview parameter and not the window's, I'm not quite sure), and two that every time the frame expands that the scrollbar's scroll region "size" is adjusted.

To link the scrollbar to the canvas is easy; it's almost as if the creators of Tkinter somehow anticipated that programmers would want to do this and intentionally made the output type of the scrollbar the same type as the inputs for the canvas view function. So it's just a matter of setting the scrollbar to use the canvas's yview as the position that the scrollbar shows its position as, and the canvas's yscrollcommand to be called every time the scrollbar set() function is triggered by the user adjusting the scrollbar. The output of the scrollbar set() function is fed directly into the yscrollcommand function using the following super compact syntax:

Canvas.configure(yscrollcommand=Scrollbar.set)

To make the viewable area that the scrollbar can access change every time that new lines are added to the StringVar for the Label object, the technique is to define something for the callback for the Frame's "Configuration". I.e. if the frame size changes (due to stretching), it is considered a change in configuration and whatever is defined in the Configuration callback gets executed. Normally that stub is empty, but all I have to do is put in some code that sets the "scrollregion" parameter of the canvas to the frame's new size; the size of the scrollbar's scroll region is already linked to that parameter earlier in the setup code so it passes through. Again, the command syntax is super compact (i.e. opaque), but it looks something like this:

Canvas.configure(scrollregion=Canvas.bbox("all"))

The other tricky part was in that I wanted my text to scroll up from the bottom, with old text disappearing from view at the top, with the scrollbar adjusting in size to match the new number of lines but the view remaining anchored at the bottom unless the user began using the scrollbar to see text that had scrolled up. To accomplish this, I added something to the Frame Conguration callback so that each time the frame stretched, the last thing the callback did was manually set the scrollbar's position to the bottom. It actually does this by setting the Canvas's yvivew to the bottom; the scrollbar setting just updates accordingly. The yview position is defined as a fractional value, ranging from 0.0 to 1.0, so this command sets it to 1.0

Canvas.yview_moveto(1.0)

However, this also results in a problem, because for some reason, every time the user grabs the scrollbar and tries to scroll up, it triggers a Configure callback for the frame for some reason, and the user setting the scrollbar position and the callback trying to set it to 1.0 would conflict quite badly. The answer was to look at the frame height every time the Configure callback was called and compare it to a stored previous value; if the frame height hadn't changed then the reason for the callback must not have been due to adding a new line of text, so there is no reason to set the viewing position back to the bottom.

Below is my code where I define the geometry of everything. As before, f is the root frame, and f5 is a frame where my status window will go below four other frames containing other stuff:

f5 = Tkinter.Frame(f, bd=1, relief="groove", width=720,height=100)
# The next line makes sure that the frame does not stretch based on its contents
f5.pack_propagate(0)
f5.pack(anchor="w",padx=10)

# Have to set the canvas width or it defaults to something stupid
self.statuscanvas=Tkinter.Canvas(f5,width=700,height=100)

# Add a scrollbar to f5 which controls the yview of the canvas
self.statusscrollbar=Tkinter.Scrollbar(f5,orient="vertical",command=self.statuscanvas.yview)
self.statuscanvas.configure(yscrollcommand=self.statusscrollbar.set)

# Pack the scrollbar on the right and the canvas on the left inside f5
self.statusscrollbar.pack(side="right",fill="y")
self.statuscanvas.pack(side="left")

# Now make a frame with the status message label in it
f5i = Tkinter.Frame(self.statuscanvas,width=2800,height=100)
self.stattext = Tkinter.StringVar()
self.stattext.set("")
self.status = Tkinter.Label(f5i,textvar=self.stattext, justify="left",anchor="sw",width=900, wraplength=640)
self.status.pack(side="bottom")

# Add the status frame as a window in the canvas
self.statuscanvas.create_window((0,0),window=f5i,anchor="nw")

# Add a callback to the frame so that every time it changes size the scrollbar can be resized
self.statusheight = 0
f5i.bind("",self.scrollbarConfigure)

Here is the code for the callback of the frame inside the canvas that is triggered every time the frame configuration changes (i.e. as a result of a resize due to the frame stretching) so that we can set the scrollbar's size to match the new size of the frame and the scrollbar can scroll the canvas's view over the entire new size of the frame:

def scrollbarConfigure(self,event):
self.statuscanvas.configure(scrollregion=self.statuscanvas.bbox("all"))
if not(event.height == self.statusheight):
self.statuscanvas.yview_moveto(1.0)
self.statusheight = event.height

So, here is a quick dump of the useful links that enabled me to figure this out:

These are the original two links that I saved from my first search. I studied them until beads of blood popped out on my forehead:
http://stackoverflow.com/questions/7113937/how-do-you-create-a-labelframe-with-a-scrollbar-in-tkinter
http://stackoverflow.com/questions/16188420/python-tkinter-scrollbar-for-frame

Here are some new very good links from my second Google search:
http://stackoverflow.com/questions/26979190/tkinter-simple-scrollable-text
http://knowpapa.com/scroll-text/
http://stackoverflow.com/questions/111155/how-do-i-handle-the-window-close-event-in-tkinter