pythontkinterscrollttkwidgetsttkbootstrap

Why is my Tkinter canvas not allowing me to scroll all the way down after generating new widgets?


import tkinter as tk
from tkinter import *
from tkinter import ttk
from datetime import datetime
import ttkbootstrap as ttk
from ttkbootstrap import window
import pandas as pd
import calendar
import random
from tkinter import font  
import tkinter.messagebox as tkmb

    


root = ttk.Window(themename="darkly")
root.title("shifts_scheduler")
w_int = root.winfo_screenwidth()
h_int = root.winfo_screenheight()

root.geometry(f"{w_int}x{h_int}")
root.update_idletasks()
root.resizable(True, True)

# Create a canvas and a vertical scrollbar
canvas = ttk.Canvas(root, width=root.winfo_screenwidth(), height=root.winfo_screenheight())





# Create a frame inside the canvas
master_window = ttk.Frame(master=canvas, )
master_window.bind("<Configure>", lambda e: on_frame_configure(canvas))

# Pack the frame into the canvas and center it horizontally
canvas.create_window((root.winfo_screenwidth() // 2, 0), window=master_window, anchor="n")
canvas.grid(row=1, column=1, rowspan=6, padx=0, pady=0)
scrollbar = ttk.Scrollbar(master=root, orient="vertical", command=canvas.yview)
canvas.configure(yscrollcommand=scrollbar.set)
scrollbar.grid(column=1, row=0, sticky="ne")
root.grid_rowconfigure(0, weight=1)
root.grid_columnconfigure(0, weight=1)
canvas.bind("<Configure>", lambda e: on_frame_configure(canvas))

# Track the entries in a dictionary keyed by the row number
max_entries = {}

worker_entries = []
workers_data = []
workers_list = []

def on_frame_configure(canvas):
    # Reset the scroll region to encompass the inner frame
    canvas.configure(scrollregion=canvas.bbox("all"))

def max_box(mvar, row):
    if mvar.get() == 1:
        # Create a new Entry widget for the given row
        max_entry = ttk.Entry(master=generated_frame, font="Calibri 14")
        max_entry.grid(row=row, column=7, padx=10, pady=10)
        max_entries[row] = max_entry  # Store it in the dictionary
    else:
        # Destroy the entry if it exists and remove it from the dictionary
        if row in max_entries:
            max_entries[row].destroy()
            del max_entries[row]

def is_weekend(date_str):
    date_obj = datetime.strptime(date_str, '%d %m %Y')
    day_num = date_obj.weekday()  # 0 for Monday, 1 for Tuesday, ..., 6 for Sunday
    return day_num >= 5  # Returns True if it's Saturday or Sunday, indicating a weekend
 
def day_of_week(date_str):
    date_obj = datetime.strptime(date_str, '%d %m %Y')
    day_num = date_obj.weekday()  # 0 for Monday, 1 for Tuesday, ..., 6 for Sunday
    return day_num

def get_days_in_month(year_int, month):
    return calendar.monthrange(year_int, month)[1]

def generate_workers():
    global worker_entries  # Declare global at the start of the function
    number_workers = workers_int.get()
     
    
    # Destroy all previous widgets in the generated frame
    for widget in generated_frame.winfo_children():
        widget.destroy()
    
    # Reset the max_entries dictionary and worker names list
    max_entries.clear()
    worker_entries.clear()  # Clear the list to avoid old entries
    workers_data.clear()
    workers_list.clear()  # Clear the worker data list
      # Clear the worker data list
    
    # Generate labels and entries for each worker
    tested_label = ttk.Label(master=generated_frame, text="✔", font="Calibri 21")
    untested_label = ttk.Label(master=generated_frame, text="❌", font="Calibri 15")
    name_label = ttk.Label(master=generated_frame, text="Name", font="Calibri 24")
    unavilable_label = ttk.Label(master=generated_frame, text="Unavailable", font="Calibri 24")
    avilable_label = ttk.Label(master=generated_frame, text="Available", font="Calibri 24")
    maxs_label = ttk.Label(master=generated_frame, text="Max", font="Calibri 24")
    
    tested_label.grid(row=0, padx=10, pady=10, column=1)
    untested_label.grid(row=0, padx=10, pady=10, column=2)
    name_label.grid(row=0, padx=10, pady=10, column=3)
    unavilable_label.grid(row=0, padx=10, pady=10, column=4)
    avilable_label.grid(row=0, padx=10, pady=10, column=5)
    maxs_label.grid(row=0, padx=10, pady=10, column=6)
    
    for i in range(number_workers):
        y_var = tk.IntVar() 
        n_var = tk.IntVar()
        m_var = tk.IntVar()
        
        tested_box = tk.Checkbutton(master=generated_frame, variable=y_var, onvalue=1, offvalue=0)
        untested_box = tk.Checkbutton(master=generated_frame, variable=n_var,onvalue=1, offvalue=0)
        workers_name = ttk.Entry(master=generated_frame, font="Calibri 14")
        unavailable_input = ttk.Entry(master=generated_frame, font="Calibri 14")
        available_input = ttk.Entry(master=generated_frame, font="Calibri 14")
        maxs_box = tk.Checkbutton(master=generated_frame, variable=m_var, command=lambda mvar=m_var, row=i+1: max_box(mvar, row), onvalue=1, offvalue=0)
        
        tested_box.grid(row=i+1, pady=10, padx=10, column=1)
        untested_box.grid(row=i+1, pady=10, padx=10, column=2)
        workers_name.grid(row=i+1, pady=10, padx=10, column=3)
        unavailable_input.grid(row=i+1, pady=10, padx=10, column=4)
        available_input.grid(row=i+1, pady=10, padx=10, column=5)
        maxs_box.grid(row=i+1, pady=10, padx=10, column=6)

        # Add the worker details to the appropriate lists
        workers_list.append({
            "name": workers_name,
            "available": available_input,
            "unavailable": unavailable_input,
            "max": max_entries,
            "tested": y_var,
            "untested": n_var
        })
    canvas.update_idletasks()
      
def generate_schedule():
    output_window.delete("1.0", "end")
    global workers_list
    month = month_int.get()
    if month > 12 or month < 1:
        tkmb.showerror("Error:", f"Month must be beetwen 1 - 12.")
        return
    days_in_month = get_days_in_month(year_int, month)

    worker_info = {}
    full_day = {i: None for i in range(1, days_in_month + 1)}  # Initialize the schedule for each day
    stand_day = {i: None for i in range(1, days_in_month + 1)}  # Standby worker assignments
    
    # Gather worker data and store it in worker_info
    for i, worker in enumerate(workers_list):
        name = worker["name"].get()
        available = worker["available"].get()
        unavailable = worker["unavailable"].get()

        # Get the status from the checkboxes (assuming both checkboxes exist and are mutually exclusive)
        tested = worker["tested"].get()  # Checkbox for 'tested'
        untested = worker["untested"].get()  # Checkbox for 'untested'

        # Determine if the worker is tested or untested
        if tested:
            is_tested = True
        elif untested:
            is_tested = False
        else:
            tkmb.showerror("Error:", f"Error: Worker '{name}' must be marked as either tested or untested.\n", )
            return
              # Skip this worker if no valid status is selected

        # Convert strings to lists of integers
        available_days = [int(d.strip()) for d in available.split(",") if d.strip().isdigit()] if available else []
        unavailable_days = [int(d.strip()) for d in unavailable.split(",") if d.strip().isdigit()] if unavailable else []

        # Check for conflicting entries between available and unavailable days
        conflicting_days = set(available_days).intersection(unavailable_days)
        if conflicting_days:
            tkmb.showerror("Error", f"Error: Worker '{name}' has conflicting days in available and unavailable inputs: {conflicting_days}\n")
            return  # Skip this worker in case of an error

        # Get the maximum shifts if provided
        max_shifts = max_entries.get(i + 1)
        max_value = int(max_shifts.get()) if max_shifts and max_shifts.get().isdigit() else None

        worker_info[i] = {
            "name": name,
            "available_days": available_days,
            "unavailable_days": unavailable_days,
            "tested": is_tested,  # Store whether the worker is tested or not
            "max": max_value,
            "count": 0,
            "shifts": 0,
            "standby": 0  # Initial shift count
        }

    # Step 1: Assign workers to available days first (shift assignments)
    for day in range(1, days_in_month + 1):
        available_workers = [
            worker for worker in worker_info.values()
            if worker["available_days"] and day in worker["available_days"] and worker["name"] != stand_day[day] and (worker["max"] is None or worker["count"] < worker["max"])
        ]
        if available_workers:
            # Sort available workers by shifts and standbys (lowest first)
            available_workers.sort(key=lambda w: (w["shifts"]))
            selected_worker = available_workers[0]  # Choose the worker with the fewest shifts/standbys
            full_day[day] = selected_worker["name"]
            selected_worker["shifts"] += 1
            selected_worker["count"] += 1

    for day in range(1, days_in_month + 1):
        if full_day[day] is None:  # No worker assigned
            available_workers = [
                worker for worker in worker_info.values()
                if not worker["available_days"] and day not in worker["unavailable_days"] and worker["name"] != stand_day[day] and (worker["max"] is None or worker["count"] < worker["max"])
            ]
            if available_workers:
                available_workers.sort(key=lambda w: (w["shifts"]))
                selected_worker = available_workers[0]  # Choose the worker with the fewest shifts
                full_day[day] = selected_worker["name"]
                selected_worker["shifts"] += 1
                selected_worker["count"] += 1
    
    # Step 2: Assign workers to standby roles on available days
    for day in range(1, days_in_month + 1):
        if not stand_day[day]:  # If no standby assigned
            shift_worker = full_day[day]
            shift_worker_info = next((worker for worker in worker_info.values() if worker["name"] == shift_worker), None)
            
            available_workers = [
                worker for worker in worker_info.values()
                if worker["available_days"] and day in worker["available_days"] and worker["name"] != full_day[day]
            ]
            
            # Check if the shift worker is untested, and filter available workers accordingly
            if shift_worker_info and not shift_worker_info["tested"]:
                available_workers = [worker for worker in available_workers if worker["tested"]]

            if available_workers:
                # Sort available workers by shifts and standbys (lowest first)
                available_workers.sort(key=lambda w: (w["standby"], w["shifts"]))
                selected_worker = available_workers[0]  # Choose the worker with the fewest standbys
                stand_day[day] = selected_worker["name"]
                selected_worker["standby"] += 1
                selected_worker["count"] += 1

    # Step 3: Assign standby workers for unassigned days
    for day in range(1, days_in_month + 1):
        if stand_day[day] is None:
            shift_worker = full_day[day]
            shift_worker_info = next((worker for worker in worker_info.values() if worker["name"] == shift_worker), None)
            
            available_workers = [
                worker for worker in worker_info.values()
                if not worker["available_days"] and day not in worker["unavailable_days"] and worker["name"] != full_day[day]
            ]
            
            # Check if the shift worker is untested, and filter available workers accordingly
            if shift_worker_info and not shift_worker_info["tested"]:
                available_workers = [worker for worker in available_workers if worker["tested"]]

            if available_workers:
                available_workers.sort(key=lambda w: (w["standby"], w["shifts"]))
                selected_worker = available_workers[0]
                stand_day[day] = selected_worker["name"]
                selected_worker["standby"] += 1
                selected_worker["count"] += 1

    
    for day in range(1, days_in_month + 1):
        fday = format(day, '02d')

        date_str = f'{fday} {month} {year_int}'
        weekday_num = day_of_week(date_str)

        if is_weekend(date_str):
            output_window.insert("end", f"{fday}-{month}-{year_int} - Shift: {full_day[day]} - Standby: {stand_day[day]}\n")
            output_window.tag_add(f"day{day}", f"{day}.0", f"{day}.end")
            output_window.tag_configure(f"day{day}", font=("Calibri", 18, "bold"))
        else:
            output_window.insert("end", f"{fday}-{month}-{year_int} - Shift: {full_day[day]} - Standby: {stand_day[day]}\n")
            output_window.tag_add(f"day{day}", f"{day}.0", f"{day}.end")
            output_window.tag_configure(f"day{day}", font=("Calibri", 14))
        
        
        
    for worker in worker_info.values():
        output_window.insert("end",f"Worker: {worker['name']} - Shifts: {worker['shifts']} - Standby: {worker['standby']}\n")
    canvas.update_idletasks()

def load():
    pass

def save():
    pass

def export():
    pass

def copy():
    master_window.clipboard_clear()  # Optional.
    master_window.clipboard_append(output_window.get('1.0', tk.END).rstrip())

# Input fields
input_frame = ttk.Frame(master=master_window)
month_int = tk.IntVar()
month_label = ttk.Label(master=input_frame, text="Month:", font="Calibri 24") 
month_entry = ttk.Entry(master=input_frame, font="Calibri 14", textvariable=month_int)

year_int = datetime.now().year
year_label = ttk.Label(master=input_frame, text="Year:", font="Calibri 24") 
year_entry = ttk.Entry(master=input_frame, font="Calibri 14")
year_entry.insert(0, str(year_int))

save_buton = ttk.Button(master=input_frame, text="Save", command=save)
load_buton = ttk.Button(master=input_frame, text="Load", command=load)

input_frame.pack(pady=10)
load_buton.pack(side="left", padx=10)
save_buton.pack(side="left", padx=10)
year_label.pack(side="left", padx=10)
year_entry.pack(side="left")
month_label.pack(side="left", padx=10)
month_entry.pack(side="left")

# Generate worker fields
workers_frame = ttk.Frame(master=master_window)
workers_int = ttk.IntVar()
workers_label = ttk.Label(master=workers_frame, text="Workers:", font="Calibri 14")
workers_entry = ttk.Entry(master=workers_frame, font="Calibri 14", textvariable=workers_int)
workers_button = ttk.Button(master=workers_frame, text="Generate", command=generate_workers)

workers_frame.pack(pady=10)
workers_label.pack(side="left", padx=10)
workers_entry.pack(side="left")
workers_button.pack(side="left", padx=10)

# Frame to hold generated fields
generated_frame = ttk.Frame(master=master_window)
generated_frame.pack(pady=10)

# Output frame with centered elements
def on_output_window_mousewheel(event):
    output_window.yview_scroll(int(-1 * (event.delta / 120)), "units")
    return "break"  # Prevents the event from propagating to other widgets

def on_canvas_mousewheel(event):
    canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")

# Create output_frame and related widgets
output_frame = ttk.Frame(master=master_window)
output_button = ttk.Button(master=output_frame, text="Generate Schedule", command=generate_schedule)
output_window = ttk.Text(master=output_frame, width=int(w_int / 16))
exporttoexcel_button = ttk.Button(master=output_frame, text="Export to excel", command=export)
copy_button = ttk.Button(master=output_frame, text="Copy to clipboard", command=copy)

# Pack the widgets
output_frame.pack(pady=10)
output_button.pack(pady=10)
output_window.pack()
exporttoexcel_button.pack(side="left", pady=10)
copy_button.pack(side="left", pady=10, padx=10)

# Bind mouse wheel event to the output_window for internal scrolling
output_window.bind("<MouseWheel>", on_output_window_mousewheel)

# Bind mouse wheel event to the canvas for overall scrolling
canvas.bind_all("<MouseWheel>", on_canvas_mousewheel)


root.mainloop()

When I run my Tkinter code, the initial window loads as expected, but I encounter several issues related to scrolling once I generate new widgets dynamically.

  1. Scrolling Issue: After generating some widgets, I am unable to scroll all the way down to see the newly added content. The scrolling seems to be restricted to a certain height, preventing me from reaching the bottom of the window. This behavior persists even though I expect the scroll region to expand as new widgets are created.

  2. Immediate Upward Scrolling: Right after starting the program, I'm able to scroll up even though there's no content above the visible window. Ideally, I shouldn't be able to scroll up until new content is generated that exceeds the window height.

  3. Scrollbar Positioning: The scrollbar is not appearing on the right-hand side of the window where it should be. Instead, the scrollbar seems misaligned, making the UI less intuitive to use.

I’ve already attempted solutions like adjusting the scroll region dynamically, but these issues persist. Any suggestions for ensuring proper scrolling behavior, both upward and downward, and fixing the scrollbar positioning


Solution

  • The problem isn't with the scrolling per se, the problem is that the scrollbar is tiny, stucky in the upper-right corner of the UI.

    My recommendation is to use pack for the scrollbar and the canvas. Using grid is possible, but requires more likes of code and is more difficult to visualize in the code.

    If you remove the calls to scrollbar.grid and canvas.grid, and then create the scrollbar and canvas like the following, it should work as you expect with less code:

    # Create a canvas and a vertical scrollbar as the
    # only two widgets in the root, so we can scroll the
    # entire UI
    canvas = ttk.Canvas(root)
    scrollbar = ttk.Scrollbar(master=root, orient="vertical", command=canvas.yview)
    canvas.configure(yscrollcommand=scrollbar.set)
    
    scrollbar.pack(side="right", fill="y")
    canvas.pack(side="left", fill="both", expand=True)