Quantcast
Channel: Active questions tagged python - Stack Overflow
Viewing all articles
Browse latest Browse all 23131

Tkinter winfo values are incorrect after content changes

$
0
0

The tkinter methods winfo_rooty and winfo_height are used to calculate the fractional position of a widget within a scrollable frame. The fractional position is passed to canvas.yview_moveto to scroll the window to the widget. This works correctly until one or more of the widgets within the scrollable frame is modified and/or deleted.

Below is a minimal working example. It doesn't look it, but it is the minimum code that will reproduce the bug given the workflow.

The code

A scrollable container is a packed with similar objects called actions (here simplified to a Label widget and a Text widget). The actions can be combined, which is a matter of combining their label content and combining their text content into one object, and deleting the other(s).

The workflow

The actions are selected by scrolling through them, accomplished here by using Control-Tab to scroll down, and Control-Shift-Tab to scroll up. The selected action is highlighted in yellow. When another action is to be combined (usually the next adjacent action, but that isn't required), the user presses Control and left-mouse-clicks the action to be added. The added action is highlighted in salmon. More than one action can be added at a time. The are combined when the user clicks the Group button. This is considered grouping the actions.

The bug

Scrolling (Control-Tab and Control-Shift-Tab) works as expected before any actions are grouped. After each grouping, the scroll location becomes increasingly inaccurate.

Additional diagnostic

If the argument to yview_moveto is calculated by adding the winfo_height of each child down to the widget desired, this number is the same as winfo_rooty on the widget, until one or more action had been grouped. See the commented-out code at the end of the ActionFrameEditor.select() method. After grouping, the sum (called height_to_here in the comments) starts to differ from the widget's winfo_rooty. The comment shows a snippet of code that can make the new scroll position more accurate under some circumstances, but isn't really a workaround.

Minimal Working Example

import tkinter as tkimport randomWINDOW_WIDTH = 400WINDOW_HEIGHT = 800from abc import ABCMeta, abstractmethodclass ScrollableContainer(tk.Frame):""" Class for a scrollable container        The intent is to allow loading of content        after the container has been instantiated. """    # frame and canvas are publicly accessible to enable scrolling    #   under program control    frame = None    canvas = None    # these are application-specific    actions = None # the actions to be displayed and edited    add_selections = [] # actions to be added into the currently selected action    def __init__(self, master, **kwargs):"""        Instantiate the scrollable container        :param master: The parent widget        :param kwargs: Any keyword arguments ot be passed to the superclass"""        tk.Frame.__init__(self, master, **kwargs)  # holds canvas & scrollbars        # Canvas is packed to the left and fills and expands        self.canvas = tk.Canvas(self, bd=0, highlightthickness=0)        # Vertical scrollbar is packed to the right and fills        #   vertically but does not expand        self.vScroll = tk.Scrollbar(self, orient='vertical',                                 command=self.canvas.yview)        self.vScroll.pack(side=tk.RIGHT, fill=tk.Y, expand=False)        self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)        self.canvas.configure(yscrollcommand=self.vScroll.set)        # Frame holds the content and fills the canvas        self.frame = tk.Frame(self.canvas, bd=2)        self.frame.parent = self        # The window is the only part of the canvas/frame that is visible        self.canvas.create_window(0, 0, window=self.frame, anchor='nw', tags='inner')        # Handle resize events        self.canvas.bind('<Configure>', self.on_configure)    def load_scrollable_content(self, parent, reload=False):"""        Load the widgets into the scrollable area.        :param parent: The parent widget        :param reload: If True, the action frames will re-instantiate widgets.                       If False, the action frames will use their previously                       instantiated widgets.        :return:"""        for next_action in self.actions:            # get the widget for this action and pack to scrollable frame            action_frame = next_action.as_frame(parent.frame, reload)            action_frame.pack(fill=tk.BOTH, expand=True)            # get the text widget so we can capture edits            next_action.text_widget = action_frame.winfo_children()[1]            next_action.text_widget.parent = next_action            next_action.text_widget.bind("<KeyRelease>", self.update_text_size)            action_frame.update_idletasks() # this may or may not be necessary        # give the parent widget a chance to adapt, scroll to top        parent.update_layout('0.0')    def update_text_size(self, event):        self.update_text_widget(event.widget)    @staticmethod    def update_text_widget(widget, update_parent=True):        narration_text = widget.get("1.0", tk.END)        if update_parent:            widget.parent.text = narration_text        widget_width = 0        widget_height = max(2.0, float(widget.index(tk.END)) - 1.0)        for line in narration_text.split("\n"):            if len(line) > widget_width:                widget_width = len(line) + 1        widget.config(width=widget_width, height=widget_height)    def clear_scrollable_content(self, start_with = 0):"""        Clear out all the content of the scrollable frame. Optionally,        only clear out the widgets after the start_with child.        :param start_with: (optional) Only clear out widgets from                           child #starts_with to the end"""        if self.frame is not None:            while start_with < len(self.frame.winfo_children()):                child = self.frame.winfo_children()[start_with]                child.destroy()    def update_layout(self, move_to='1.0'):"""        Update the layout after the scrollable content is loaded.        :param move_to: (optional) The fraction of the content to which                        to scroll after updating. Default is bottom of                        content frame.        :return:"""""" """        self.frame.update_idletasks()        # make sure scroll region knows the current size of the canvas        self.canvas.configure(scrollregion=self.canvas.bbox('all'))        # scroll to the specified fraction of the content.        self.canvas.yview('moveto', move_to)    def on_configure(self, event):"""        Update the layout if the containing window is changed.        :param event: The layout change event."""        w, h = event.width, event.height # the new dimensions        current = self.frame.winfo_reqwidth() # whit it thinks it is        # only adjust if the new width is greater than the old        self.canvas.itemconfigure('inner', width=w if w > current else current)        # make sure scroll region knows the current size of the canvas        self.canvas.configure(scrollregion=self.canvas.bbox('all'))    def set_add_selection(self, selection):"""        Add the selection to the list of actions to be added,        updating the formatting.        :param selection: The selection to be added"""        self.add_selections.append(selection)    def clear_add_selection(self, selection):"""        Remove the selection to the list of actions to be added,        updating the formatting.        :param selection: The selection to be removed"""        self.add_selections.remove(selection)    def clear_all_add_selections(self):"""        Clear out the list of actions to be added, clearing their        formatting, etc."""        while 0 < len(self.add_selections):            self.add_selections[0].clear_add_selection()class ActionFrame(tk.Frame):"""    Frame to display the action data."""    KEY_FONT = ("Helvetica", 12, "bold")    ACTION_FONT = ("Helvetica", 12)    VALUE_FONT = ("Helvetica", 12, "italic")    NOTES_FONT = ("Helvetica", 10)    ADD_ACTION = "Add"    REMOVE_ACTION = "Rem"    SHARE_ACTION = "Shr"    UNSHARE_ACTION = "Uns"    action_list = (        ADD_ACTION,        REMOVE_ACTION,        SHARE_ACTION,        UNSHARE_ACTION,    )    parent = None    action_tuples = None # a list of actions for this frame    text = None    selected = False    this_frame = None    action_label = None    notes_text = None    background_color = "light gray"    foreground_color = "white"    selected_color = "yellow"    selected_highlight = "yellow"    select_add_color = "salmon"    selected_border = 5    select_add = False    def __init__(self):"""        Instantiate an empty object"""        super().__init__()        self.action_tuples = []        self.text = ""    def set_selected(self, selected):"""        Sets selection state of this action frame.        :param selected: True if selected, False if unselected        :return:"""        self.selected = selected # this could be done before the GUI is instantiated        if self.this_frame is not None:            bg_color = self.selected_color if self.selected else self.background_color            highlight_color = self.selected_highlight if self.selected else self.background_color            border_width = self.selected_border if self.selected else 1            # Enable text editing only on selected frame            edit_state = tk.NORMAL if self.selected else tk.DISABLED            self.this_frame.configure(bg=bg_color)            self.action_label.configure(bg=bg_color)            self.notes_text.configure(state=tk.NORMAL)            self.notes_text.configure(state=edit_state,                                      highlightbackground=highlight_color,                                      highlightcolor=highlight_color,                                      highlightthickness=border_width)            self.this_frame.update()            if self.selected:                self.notes_text.focus_set()    def add_selection(self, event):"""        Toggles the add state of this action frame. If state is        set when the Group button is pressed, this action will        be folded into the selected action.        :param event: Mouse event to select this action frame"""        if self.select_add:            self.clear_add_selection() # clear state and color        else:            self.select_add = True            self.set_select_add_color()            self.parent.parent.set_add_selection(self)    def clear_add_selection(self):"""        Clear the add state and reset the background colors.        Let the parent object know that this one is no longer        on the list of actions to add to the selected action."""        self.select_add = False        self.clear_select_add_color()        self.parent.parent.clear_add_selection(self)    def set_select_add_color(self):"""        Set all the widgets to the add background color"""        self.this_frame.configure(bg=self.select_add_color)        self.action_label.configure(bg=self.select_add_color)        self.notes_text.configure(highlightbackground=self.select_add_color, highlightthickness=self.selected_border)    def clear_select_add_color(self):"""        Set all the widgets to the normal background color"""        self.this_frame.configure(bg=self.background_color)        self.action_label.configure(bg=self.background_color)        self.notes_text.configure(highlightbackground=self.background_color)        # move focus to the container instead of the text widget        self.this_frame.focus_set()    def as_frame(self, parent, reload=False):"""        After this object has been populated, all the child widgets        can be instantiated.        :param parent: The parent widget for this widget (probably                       a scrollable frame).        :param reload: If True, the container and enclosed objects                       will be re-instantiated. If False, the already                       instantiated frame will be returned        :return: The enclosing frame."""        if reload or self.this_frame is None:            # instantiate the widgets if they haven't already been            # or if reload = True            self.parent = parent            self.this_frame = tk.Frame(parent, bg=self.background_color)            self.this_frame.rowconfigure(1, weight=1)            self.this_frame.columnconfigure(0, weight=1)            self.action_label = tk.Label(self.this_frame, anchor=tk.CENTER,                                         font=self.ACTION_FONT, fg="blue",                                         bg=self.background_color)            self.action_label.grid(row=0, column=0, sticky="ew")            self.notes_text = tk.Text(self.this_frame, font=self.NOTES_FONT,                                      fg="black", padx=5, height=2, width=40,                                      state="disabled") # only enabled if selected            self.notes_text.grid(row=1, column=0, sticky="ew")            # load the saved tuples into the widgets            self.refresh_frame()            # Control key plus left mouse button marks this selection to be added            self.action_label.bind("<Control-Button-1>", self.add_selection)            self.notes_text.bind("<Control-Button-1>", self.add_selection)        return self.this_frame    def add_tuple(self, new_tuple):"""        Add a new tuple to the list of tuples and adjust the        widgets for the new content.        :param new_tuple: The new tuple to be added"""        old_text = self.action_label.cget("text")        new_text = ""        if new_tuple is not None:            new_text = self.action_list[new_tuple]        self.action_tuples.append(new_tuple)        sep = "," if 0 < len(old_text) else ""        self.action_label.config(text=old_text + sep + new_text)    def refresh_frame(self):"""        Populates the widgets with the object values. This is done when first        instantiated, but may be done again if something changes."""        if self.this_frame is not None:            # Strings to hold the values            action_str = ""            # Separators between values            action_sep = ""            for next_action_idx in self.action_tuples:                if next_action_idx is not None:                    next_action = self.action_list[next_action_idx]                    action_str += action_sep + next_action                    action_sep = ","            # Load the widgets with these strings            self.action_label.configure(text=action_str)            # Make the text widget editable for the moment            self.notes_text.configure(state="normal")            self.notes_text.delete('1.0', tk.END)            self.notes_text.insert(tk.END, self.text)            # Tell the parent frame that this has changed            ScrollableContainer.update_text_widget(self.notes_text,                                                   False)            # Disable the text widget - set_selected may undo this            self.notes_text.configure(state="disabled")            if self.selected:                self.set_selected(True)    def append_text(self, new_text):"""        Adds the new text to the end of the widget text, separated with        a linefeed character.        :param new_text: text to be added to the text widget        :return:"""        if 0 < len(new_text):            # only had a newline if there's already some text            sep = "\n" if 0 < len(self.text) else ""            self.text += sep + new_text            self.notes_text.insert(tk.END, sep + new_text)class ActionFrameEditor(tk.Frame):"""    A scrollable frame to display the action frames and enable them    to have their notes text edited or be grouped/ungrouped"""    actions = None    currently_selected = None # the currently selected action frame    next_step = None  # the next action frame - usually the same as currently_selected    scrollable_frame = None    def __init__(self, parent):""" Set up panes and frame. """        super().__init__()        self.parent = parent        self.set_title('Action Editor')        self.parent.minsize(WINDOW_WIDTH, WINDOW_HEIGHT)        self.make_editor(self.parent, WINDOW_WIDTH, WINDOW_HEIGHT)        # Ctrl-Tab to move down the list, Ctrl-Shift-Tab to move up        self.parent.bind("<Tab>", self.step_forward)        self.parent.bind("<Shift-ISO_Left_Tab>", self.step_back)    def set_title(self, new_title):            self.master.title(new_title)    def make_editor(self, parent, editor_width, editor_height):"""        Make the frame to hold the action editor.        :param parent: The parent widget        :param editor_width: The preferred width of the editor        :param editor_height: The preferred height of the editor"""        frame_canvas = tk.Canvas(parent, width=editor_width, height=editor_height)        frame_canvas.pack(side=tk.RIGHT, fill=tk.Y, expand=True)        # top_frame has the group buttons        top_frame = tk.Frame(frame_canvas)        # button to group actions - omitting ungroup button for simplicity        group_button = tk.Button(top_frame,                                 text="Group",                                 command=self.group_selection)        group_button.grid(row=0, column=0)        top_frame.pack(side=tk.TOP)        self.scrollable_frame = ScrollableContainer(frame_canvas)        self.scrollable_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True)    def unselect(self):"""        Unselect the currently selected action frame.        :return:"""        self.scrollable_frame.clear_all_add_selections()        if self.currently_selected is not None:            # Tell the action frame it is no longer selected            self.actions[self.currently_selected].set_selected(False)            # Tell this class that nothing is selected            self.currently_selected = None    def select(self, selection_number):"""        Select action frame #selection_number. This includes clearing any        add selections, unselecting the previously selected action frame,        selecting the new action frame, and scrolling to the new selected        frame.        :param selection_number: The index in the actions list for the                                 actions frame to be selected."""        self.unselect() # unselect the previously selected action        if selection_number < len(self.actions):            self.actions[selection_number].set_selected(True)        # Selection may add a few pixels to widget height (the focus        #   border on the text widget), so the scroll        #   window has to be recalculated        self.scrollable_frame.canvas.configure(            scrollregion=self.scrollable_frame.canvas.bbox('all'))        self.currently_selected = selection_number # set this one as selected        # Get the widget for the selected action        widget = self.scrollable_frame.frame.winfo_children()[selection_number]        # get the height of the scrollable frame        height = self.scrollable_frame.frame.winfo_height()        # get the location for the selected action frame        action_location = widget.winfo_rooty() - self.scrollable_frame.frame.winfo_rooty()        self.scrollable_frame.canvas.yview_moveto(action_location / height)        #####        #####        # if you comment out the previous two lines and uncomment the next three,        #  scrolling seems to work better but still not correct        #height_to_here = 0        # for next_child in range(selection_number):        #     height_to_here +=         #        self.scrollable_frame.frame.winfo_children()[next_child].winfo_height() - 1        # self.scrollable_frame.canvas.yview_moveto(height_to_here / height)    def step_back(self, event=None):"""        Step to the previous action frame as long as the        current frame isn't the first frame        :param event: (not used) Event object from mouse click."""        if self.next_step > 0:            self.next_step -= 1            self.select(self.next_step)    def step_forward(self, event=None):"""        Step to the next action frame as long as the current        frame isn't the last frame        :param event: (not used) Event object from mouse click."""        if self.next_step < len(self.actions) - 1:            self.next_step += 1            self.select(self.next_step)    def group_selection(self):"""        Combine all add_selection actions into the currently_selected action.        Remove the add_selection actions from the list of actions and        from the scrollable frame."""        # only if there is a current selection and there's one or more adds        if (self.currently_selected is not None                and 0 < len(self.scrollable_frame.add_selections)):            # get the selected action and its text            this_action = self.actions[self.currently_selected]            this_text = this_action.text            # loop over actions to be added            while 0 < len(self.scrollable_frame.add_selections):                next_selection = self.scrollable_frame.add_selections[0]                for next_tuple in next_selection.action_tuples:                    this_action.add_tuple(next_tuple)                # get this actions notes text without trailing whitespace                new_text = next_selection.notes_text.get('1.0', tk.END).rstrip(" \n")                if 0 < len(new_text):                    # only add the text if there is some                    sep = "\n" if 0 < len(this_text) else ""                    this_text += sep + new_text                # remove the added action from the action list                self.actions.remove(next_selection)                # remove this action from the actions to be added                self.scrollable_frame.add_selections.remove(next_selection)                # remove the added action frame from the scrollable list                next_selection.this_frame.pack_forget()            # reload the notes text            this_action.notes_text.delete('1.0', tk.END)            this_action.notes_text.insert(tk.END, this_text)            ScrollableContainer.update_text_widget(this_action.notes_text)            # update the containing widget            self.scrollable_frame.frame.update()            # let the scrollable frame adjust to changes in content            self.scrollable_frame.canvas.configure(                scrollregion=self.scrollable_frame.canvas.bbox('all'))    def import_tuples(self, new_tuples):"""        Import the action tuples. For simplification, these are        actions only, no supporting data.        :param new_tuples: The new list of actions to import"""        self.actions = []        for action in new_tuples:            next_action = ActionFrame()            next_action.action_tuples.append(action)            self.actions.append(next_action)        self.actions[0].set_selected(True)        self.scrollable_frame.clear_scrollable_content()        self.scrollable_frame.actions = self.actions        self.scrollable_frame.load_scrollable_content(self.scrollable_frame)        self.next_step = 0        self.select(self.next_step)def create_random_tuples():    tuples = random.choices(range(len(ActionFrame.action_list) - 1),k=169)    return tuplesdef main():""" Run this as an application. """    root = tk.Tk()    editor = ActionFrameEditor(root)    editor.import_tuples(create_random_tuples())    root.mainloop()if __name__ == '__main__':""" Run from the command line. """    main()

Viewing all articles
Browse latest Browse all 23131

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>