Tkinter.text - How to calculate the height of a dynamic string? - python

I have a Text widget that holds a custom string that contains \n chars (multiple lines).
The widget is placed within a vertical panedwindow which I want to adjust the panedwindow's sash to display the whole string in the Text widget.
The string is dynamic by nature (which means, it is being updated by other methods in my application).
As the Text widget is configured with wrap='word', how can I calculate the string height in pixels to adjust the sash accordingly?
I tried to use text.dlineInfo('end -1c')[1] + text.dlineinfo('end -1c')[3] (for line's y coordinate + height) after the string was loaded to the widget. The problem is that if the last line is not visible, then dlineinfo returns none.
I also tried to use Font.measure routine, but this doesn't include wrap aspects of the Text widget.
Here is a Minimal, Complete, and Verifiable example:
import tkinter
from tkinter import scrolledtext
class GUI():
def __init__(self, master):
self.master = master
self.body_frame = tkinter.PanedWindow(self.master, orient='vertical', sashwidth=4)
self.body_frame.pack(expand=1, fill='both')
self.canvas_frame = tkinter.Frame(self.body_frame)
self.description_frame = tkinter.Frame(self.body_frame)
self.body_frame.add(self.canvas_frame, sticky='nsew')
self.body_frame.add(self.description_frame, sticky='nsew')
tkinter.Button(self.canvas_frame, text='Update Text', command = lambda : self.update_text("""
A very long string with new lines
A very long string with new lines
A very long string with new lines
A very long string with new lines
A very long string with new lines
A very long string with new lines
""")).pack(fill='x')
self.field_description = scrolledtext.ScrolledText(self.description_frame, width=20, wrap='word')
self.field_description.pack(expand=1, fill='both')
self.master.update()
self.body_frame.sash_place(0,0,self.body_frame.winfo_height() - 50) # force sash to be lower
def update_text(self, description):
self.field_description.delete('1.0', 'end')
self.field_description.insert('1.0', description)
height = self.body_frame.winfo_height()
lastline_index = self.field_description.index('end - 1c')
text_height = self.field_description.dlineinfo(lastline_index)[1] + \
self.field_description.dlineinfo(lastline_index)[3]
self.body_frame.sash_place(0, 0, height - text_height)
root = tkinter.Tk()
my_gui = GUI(root)
root.mainloop()

I don't know of any built-in method that returns the total number of lines (including wrapped lines) in a tkinter Text widget.
However, you can manually calculate this number by comparing the lengths of the unbroken strings in the Text widget to the Text widget's exact width (minus padding). This is what the LineCounter class below does:
# python 2.x
# from tkFont import Font
# python 3.x
from tkinter.font import Font
class LineCounter():
def __init__(self):
"""" This class can count the total number of lines (including wrapped
lines) in a tkinter Text() widget """
def count_total_nb_lines(self, textWidget):
# Get Text widget content and split it by unbroken lines
textLines = textWidget.get("1.0", "end-1c").split("\n")
# Get Text widget wrapping style
wrap = text.cget("wrap")
if wrap == "none":
return len(textLines)
else:
# Get Text widget font
font = Font(root, font=textWidget.cget("font"))
totalLines_count = 0
maxLineWidth_px = textWidget.winfo_width() - 2*text.cget("padx") - 1
for line in textLines:
totalLines_count += self.count_nb_wrapped_lines_in_string(line,
maxLineWidth_px, font, wrap)
return totalLines_count
def count_nb_wrapped_lines_in_string(self, string, maxLineWidth_px, font, wrap):
wrappedLines_count = 1
thereAreCharsLeftForWrapping = font.measure(string) >= maxLineWidth_px
while thereAreCharsLeftForWrapping:
wrappedLines_count += 1
if wrap == "char":
string = self.remove_wrapped_chars_from_string(string,
maxLineWidth_px, font)
else:
string = self.remove_wrapped_words_from_string(string,
maxLineWidth_px, font)
thereAreCharsLeftForWrapping = font.measure(string) >= maxLineWidth_px
return wrappedLines_count
def remove_wrapped_chars_from_string(self, string, maxLineWidth_px, font):
avgCharWidth_px = font.measure(string)/float(len(string))
nCharsToWrap = int(0.9*maxLineWidth_px/float(avgCharWidth_px))
wrapLine_isFull = font.measure(string[:nCharsToWrap]) >= maxLineWidth_px
while not wrapLine_isFull:
nCharsToWrap += 1
wrapLine_isFull = font.measure(string[:nCharsToWrap]) >= maxLineWidth_px
return string[nCharsToWrap-1:]
def remove_wrapped_words_from_string(self, string, maxLineWidth_px, font):
words = string.split(" ")
nWordsToWrap = 0
wrapLine_isFull = font.measure(" ".join(words[:nWordsToWrap])) >= maxLineWidth_px
while not wrapLine_isFull:
nWordsToWrap += 1
wrapLine_isFull = font.measure(" ".join(words[:nWordsToWrap])) >= maxLineWidth_px
if nWordsToWrap == 1:
# If there is only 1 word to wrap, this word is longer than the Text
# widget width. Therefore, wrapping switches to character mode
return self.remove_wrapped_chars_from_string(string, maxLineWidth_px, font)
else:
return " ".join(words[nWordsToWrap-1:])
Example of use:
import tkinter as tk
root = tk.Tk()
text = tk.Text(root, wrap='word')
text.insert("1.0", "The total number of lines in this Text widget is " +
"determined accurately, even when the text is wrapped...")
lineCounter = LineCounter()
label = tk.Label(root, text="0 lines", foreground="red")
def show_nb_of_lines(evt):
nbLines = lineCounter.count_total_nb_lines(text)
if nbLines < 2:
label.config(text="{} line".format(nbLines))
else:
label.config(text="{} lines".format(nbLines))
label.pack(side="bottom")
text.pack(side="bottom", fill="both", expand=True)
text.bind("<Configure>", show_nb_of_lines)
text.bind("<KeyRelease>", show_nb_of_lines)
root.mainloop()
In your specific case, the height of the wrapped text in your ScrolledText can be determined in update_text() as follows:
from tkinter.font import Font
lineCounter = LineCounter()
...
class GUI():
...
def update_text(self, description):
...
nbLines = lineCounter.count_total_nb_lines(self.field_description)
font = Font(font=self.field_description.cget("font"))
lineHeight = font.metrics("linespace")
text_height = nbLines * lineHeight
...

You know the number of lines in your Text. And you can tell when a line is off the scrolled region when dlineinfo returns None. So go through each line and "see" it, to make sure it's visible before you run the dlineinfo() call on it. Then sum them all up, and that's the minimum new height you need for the lines to all appear at the current width. From the height of a line's bbox and the height of the biggest font in the line, you can determine if the line is wrapped, and if so, how many times, if you care about that. The trick is to then use paneconfig() to modify the height of the paned window. Even if the child window would resize automatically normally, the paned window will not. It must be told to resize through the paneconfig() call.
If you "see" each line before measuring, you'll get all the measurements. And "seeing" each line shouldn't be a big deal since you intend to show them all at the end anyway.

Related

How to dynamically wrap label text in tkinter with .bind and <Configure>

I'm trying to create a page that outputs a large amount of data, and wraps the text dynamically depending on window size. I started by setting wraplength = self.master.winfo_width(), which sets the text wrapping to the current window size, but it does not change when the window does. I found this answer, which seemed like it would solve the problem, but when trying to recreate it myself, something went wrong. I suspect that I'm misunderstanding something with .bind or <Configure>, but I can't be sure. My base code is as follows:
from tkinter import *
class Wrap_example(Frame):
def __init__(self):
Frame.__init__(self)
self.place(relx=0.5, anchor='n')
#Initalize list and variable that populates it
self.data_list = []
self.data = 0
#Button and function for creating a bunch of numbers to fill the space
self.button = Button(self, text = "Go", command = self.go)
self.button.grid()
def go(self):
for self.data in range(1, 20000, 100):
self.data_list.append(self.data)
#Label that holds the data, text = list, wraplength = current window width
self.data = Label(self, text = self.data_list, wraplength = self.master.winfo_width(), font = 'arial 30')
self.data.grid()
#Ostensibly sets the label to dynamically change wraplength to match new window size when window size changes
self.data.bind('<Configure>', self.rewrap())
def rewrap(self):
self.data.config(wraplength = self.master.winfo_width())
frame01 = Wrap_example()
frame01.mainloop()
A few things of note: I tried using the lambda directly as shown in the linked answer, but it didn't work. If I remove the rewrap function and use self.data.bind('<Configure>', lambda e: self.data.config(wraplength=self.winfo_width()), it throws a generic Syntax error, always targeting the first character after that line, (the d in def if the function is left in, the f in frame01 if it's commented out). Leaving rewrap as-is doesn't throw an error, but it doesn't perform any other apparent function, either. Clicking 'Go' will always spawn data that wraps at the current window size, and never changes.
There are few issues:
frame Wrap_example does not fill all the horizontal space when window is resized
label self.data does not fill all the horizontal space inside frame Wrap_example when the frame is resized
self.rewrap() will be executed immediately when executing the line self.data.bind('<Configure>', self.rewrap())
To fix the above issues:
set relwidth=1 in self.place(...)
call self.columnconfigure(0, weight=1)
use self.data.bind('<Configure>', self.rewrap) (without () after rewrap) and add event argument in rewrap()
from tkinter import *
class Wrap_example(Frame):
def __init__(self):
Frame.__init__(self)
self.place(relx=0.5, anchor='n', relwidth=1) ### add relwidth=1
self.columnconfigure(0, weight=1) ### make column 0 use all available horizontal space
#Initalize list and variable that populates it
self.data_list = []
self.data = 0
#Button and function for creating a bunch of numbers to fill the space
self.button = Button(self, text = "Go", command = self.go)
self.button.grid()
def go(self):
for self.data in range(1, 20000, 100):
self.data_list.append(self.data)
#Label that holds the data, text = list, wraplength = current window width
self.data = Label(self, text = self.data_list, wraplength = self.master.winfo_width(), font = 'arial 30')
self.data.grid()
#Ostensibly sets the label to dynamically change wraplength to match new window size when window size changes
self.data.bind('<Configure>', self.rewrap) ### remove () after rewrap
def rewrap(self, event): ### add event argument
self.data.config(wraplength = self.master.winfo_width())
frame01 = Wrap_example()
frame01.mainloop()

Change Aspect ratio of tkinter canvas text object

I have placed some text onto a tkinter canvas.
How would I go about changing the aspect ratio of this text.
In my case I have a single letter on a canvas created with canvas.create_text.
I would like to change the height of this letter while retaining its width.
Visually it would look like the letter is falling forward or rotation about the horizon.
I also would like to change the width of the letter without changing its height.
Is this case it would visually look like it is rotating.
canvas.scale changes the coordinate system where the letter is drawn but not the scale of the letter it self.
Here is a short example. I want the height of the letter to follow the height of the lines on either side. But the width of the letter should remain the same.
import tkinter as tk
from tkinter import font
class FlipDigit():
def __init__(self,master):
self.master = master
self.digit = tk.Canvas(master,width= 150,height = 150,bg='black')
self.font = font.Font(family ='Calibri',size = 150)
self.letter = self.digit.create_text(75,75,text = 'A',fill = 'white', font = self.font)
self.digit.create_line(5,25,5,125,width=10,fill = '#808080')
self.digit.create_line(145,25,145,125,width = 10,fill = '#808080')
self.digit.pack()
self.count = 0
self.master.after(50,self.Bump)
def Bump(self):
yscale = .8
if self.count > 10:
yscale = 1.25
if self.count >20:
self.count = -1
self.count += 1
self.digit.scale('all',75,75,1,yscale)
self.master.after(150,self.Bump)
if __name__ == '__main__':
root = tk.Tk()
Digit = FlipDigit(root)
root.mainloop()
I don't think there is any way to do that in tkinter. You cannot change the height of a font independently from the width.

Python textbox only updates after loop is complete

I have a very long automation that uses a very long WHILE loop. I would like to be able to have the text in a textbox update as the process is progressing. However, based on the research I have done, this does not seem to be possible and all of the results "dump" at once when the WHILE loop completes. This is useless for me.
I would love it if the textbox could update as the SHELL updates as that follows along synchronously with the actual process.
I have made a simple TEST file to see if I can get it to work. Here is the code for the TEST file:
from tkinter import *
import time
import tkinter as tk
import tkinter.font as tkFont
root=Tk()
myFont = tkFont.Font(family = 'Helvetica', size = 18, weight = 'bold')
text_cell_bg="cyan" #TEXT CELL BACKGROUND COLOR
text_cell_fg="black" #TEXT CELL TEXT COLOR
text_cell_height=2 #TEXT CELL HEIGHT
text_cell_width=30 #TEXT CELL WIDTH
button_bg="blue" #BUTTON CELL BACKGROUND COLOR
button_fg="white" #BUTTON CELL TEXT COLOR
button_height=2 #BUTTON CELL HEIGTH
button_width=10 #BUTTON CELL WIDTH
textbox=Text(root)
textbox.insert(END, 'Default Text\n\n')
def count_print ():
count = 0
letter = "A"
while count < 5:
print("Count = ",count,". Letter = ",letter,".")
textbox_value = "Count = {}. Letter = {}.\n".format(count,letter)
textbox.insert(1.0, textbox_value)
count += 1
time.sleep(1)
textbox.pack()
button1=tk.Button(root, text='output', command=count_print, font = myFont,
height=button_height,
width=button_width,
bg = button_bg,
fg = button_fg)
button1.pack()
root.mainloop()
You can call your function using Tkinter from inside the function itself.
Tkinter has a special function for this.
from tkinter import *
import time
import tkinter as tk
import tkinter.font as tk_font
root = Tk()
myFont = tk_font.Font(family='Helvetica', size=18, weight='bold')
text_cell_bg = "cyan" # TEXT CELL BACKGROUND COLOR
text_cell_fg = "black" # TEXT CELL TEXT COLOR
text_cell_height = 2 # TEXT CELL HEIGHT
text_cell_width = 30 # TEXT CELL WIDTH
button_bg = "blue" # BUTTON CELL BACKGROUND COLOR
button_fg = "white" # BUTTON CELL TEXT COLOR
button_height = 2 # BUTTON CELL HEIGHT
button_width = 10 # BUTTON CELL WIDTH
textbox = Text(root)
textbox.insert(END, 'Default Text\n\n')
def count_print(n_times=0): # tell your function how many times you have called it
(on the first call this will be 0)
n = n_times + 1
letter = "A"
if n <= 5:
print("Count = ", n, ". Letter = ", letter, ".")# you can change the n var on these lines to n_times if you want 0-4 rather than printing 1-5.
textbox_value = "Count = {}. Letter = {}.\n".format(n, letter)
textbox.insert(1.0, textbox_value)
root.after(1000, count_print, n) # this number replaces and time.sleep it's set to 1 second right now it also call the count_print function passing n as a variable
textbox.pack()
button1 = tk.Button(root, text='output', command=count_print, font=myFont,
height=button_height,
width=button_width,
bg=button_bg,
fg=button_fg)
button1.pack()
root.mainloop()
I hope this helps. also, I don't use Tkinter for my projects and am definitely not very good at using it either so credit for me knowing about root. after goes to this answer,
Make Tkinter force a "refresh" a text widget in mid-command?

How to force tkinter text widget to stay on one line

I want to create an object akin to Label, however I want it to also be selectable for copy-pasting. I have done so using the Text widget:
class CopyLabel (Text):
def __init__ (self, master, text = '', font = None):
if font is None:
super().__init__(master = master, height = 1,
borderwidth = 0, width = len(text),
bg = master['background'])
else:
super().__init__(master = master, height = 1,
borderwidth = 0, font = font,
width = len(text), bg = master['background'])
self.insert(1.0, text)
self.configure(state = 'disabled')
I end up displaying this widget with a grid. However, I randomly find the last 1 or 2 characters not showing. When investigating this, it seems the Text widget is splitting those characters off to a new line (when selecting the text, it is possible to drag down to see this second line). The biggest problem is the unpredictable nature of this splitting (I tried doing width = len(text) + 2, but I still occasionally get this line splitting behaviour). Is there any way to remedy this behaviour?
EDIT: setting wrap = 'none' fixed the line splitting behaviour, but the text is still getting cutoff. Now I have to scroll horizontally instead of vertically to see the text, but I guess that is a different question from what I posed.
What you're experiencing is called wrapping and can be disabled modifying Text widget's wrap option, as in :
self['wrap'] = 'none'
or
self.config(wrap='none')

Fitting text into a rectangle (width x by height y) with tkinter

I'm trying to make a program which will fit text into a rectangle (x by y) depending on the text, the font and the font size
Here is the code
def fit_text(screen, width, height, text, font):
measure_frame = Frame(screen) # frame
measure_frame.pack()
measure_frame.pack_forget()
measure = Label(measure_frame, font = font) # make a blank label
measure.grid(row = 0, column = 0) # put it in the frame
##########################################################
# make a certain number of lines
##########################################################
words = text.split(" ")
lines = []
num = 0
previous = 0
while num <= len(words):
measure.config(text = " ".join(words[previous:num])) # change text
line_width = measure.winfo_width() # get the width
print(line_width)
if line_width >= width: # if the line is now too long
lines.append(" ".join(words[previous:num - 1])) # add the last vsion which wasn't too long
previous = num - 1 # previous is now different
num = num + 1 # next word
lines.append(" ".join(words[previous:])) # add the rest of it
return "\n".join(lines)
from tkinter import *
window = Tk()
screen = Canvas(window)
screen.pack()
text = fit_text(screen, 200, 80, "i want to fit this text into a rectangle which is 200 pixels by 80 pixels", ("Purisa", 12))
screen.create_rectangle(100, 100, 300, 180)
screen.create_text(105, 105, text = text, font = ("Purisa", 12), anchor = "nw")
The problem with this is no matter what text is in the label the result from measure.winfo_width() is always 1. Here is where I found this from but it doesn't seem to work for me
The problem with your code is that you're using the width of a widget, but the width will be 1 until the widget is actually laid out on the screen and made visible, since the actual width depends on a number of factors that aren't present until that happens.
You don't need to put the text in a widget in order to measure it. You can pass a string to font.measure() and it will return the amount of space required to render that string in the given font.
For python 3.x you can import the Font class like this:
from tkinter.font import Font
For python 2.x you import it from the tkFont module:
from tkFont import Font
You can then create an instance of Font so that you can get information about that font:
font = Font(family="Purisa", size=18)
length = font.measure("Hello, world")
print "result:", length
You can also get the height of a line in a given font with the font.metrics() method, giving it the argument "linespace":
height = font.metrics("linespace")
The widget will not have a width until it is packed. You need to put the label into the frame, then pack it, then forget it.
I've actually stumbled across a way of doing this through trial and error
By using measure.update_idletasks() it calculates the width properly and it works! Bryan Oakley definitely has a more efficient way of doing it though but I think this method will be useful in other situations
P.S. I wouldn't mind some votes to get a nice, shiny, bronze, self-learner badge ;)

Categories

Resources