2

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()
NirMH
  • 4,769
  • 3
  • 44
  • 69
  • if you change `end -1c` to `end` does it help? I believe the `-1c` takes away the last line from the end count. – Mike - SMT Aug 02 '17 at 20:30
  • @SierraMountainTech: no. the problem is that if the last line is not visible, dlineinfo returns None. – NirMH Aug 02 '17 at 20:31
  • Ah ok. I will see what I can figure out. It would be helpful if this was set up as a [Minimal, Complete, and Verifiable example](https://stackoverflow.com/help/mcve). – Mike - SMT Aug 02 '17 at 20:32
  • @SierraMountainTech: the above code is stripped from my application. I'll go ahead and create a stand alone app that shows the issue... will post it once done – NirMH Aug 02 '17 at 20:34
  • @SierraMountainTech: examples is posted, appreciate your help – NirMH Aug 03 '17 at 05:30

2 Answers2

2

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:

Screenshot

# 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 
        ...
Josselin
  • 2,593
  • 2
  • 22
  • 35
0

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.

GaryMBloom
  • 5,350
  • 1
  • 24
  • 32