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()