Source code for buckpy.buckpy_gui
'''
This module contains the GUI of BuckPy.
'''
import os
import tkinter as tk
from tkinter import ttk
from tkinter import font
from tkinter import filedialog
import pandas as pd
from importlib.resources import files, as_file
from pathlib import Path
[docs]
class GUI:
'''
BuckPy Graphical User Interface (GUI) class.
This class creates and manages the main window for user interaction,
allowing users to select input files, enter pipeline and scenario IDs,
choose options, and view scenario data in a table. It handles all
user input validation and provides a structured interface for running
BuckPy workflows.
'''
def __init__(self):
'''
Initialize the BuckPy GUI.
Sets up the main window, configures fonts and geometry, creates
top and bottom frames, and initializes all widgets for user input.
'''
# Store the root window
self.root = tk.Tk()
# Set window title
self.root.title('BuckPy')
# Set window icon
try:
logo_res = files("buckpy").joinpath("_static", "logo.png")
with as_file(logo_res) as logo_path:
self.root.iconphoto(False, tk.PhotoImage(file=str(logo_path)))
except (ModuleNotFoundError, FileNotFoundError, OSError, PermissionError, tk.TclError):
try:
fallback = Path(__file__).with_name("_static") / "logo.png"
if fallback.exists():
self.root.iconphoto(False, tk.PhotoImage(file=str(fallback)))
except (OSError, PermissionError, tk.TclError):
pass
# Update geometry info
self.root.update_idletasks()
window_width = 1366
window_height = 768
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
x = (screen_width // 2) - (window_width // 2)
y = (screen_height // 2) - (window_height // 2)
self.root.geometry(f'{window_width}x{window_height}+{x}+{y}')
# Set modern default font
default_font = font.nametofont('TkDefaultFont')
default_font.configure(family='Segoe UI', size=11)
self.root.option_add('*Font', default_font)
# Create top and bottom frames for layout
self.top_frame = tk.Frame(self.root, padx=20, pady=20)
self.top_frame.grid(row=0, column=0, sticky='ew')
self.bottom_frame = tk.Frame(self.root, padx=20)
self.bottom_frame.grid(row=1, column=0, sticky='nsew')
# Add a bottom padding frame for spacing
self.bottom_padding = tk.Frame(self.root, height=20)
self.bottom_padding.grid(row=2, column=0, sticky='ew')
# Configure grid weights for resizing behavior
self.root.grid_columnconfigure(0, weight=1)
self.root.grid_rowconfigure(1, weight=1)
self.root.grid_rowconfigure(2, weight=0)
# Set minimum sizes and weights for columns in the top frame
self.top_frame.grid_columnconfigure(1, minsize=200)
self.top_frame.grid_columnconfigure(3, minsize=100)
self.top_frame.grid_columnconfigure(5, minsize=200, weight=1)
# Make the Treeview expand in the bottom frame
self.bottom_frame.grid_rowconfigure(0, weight=1)
self.bottom_frame.grid_columnconfigure(0, weight=1)
# Initialize file path variables
self.work_dir = None
self.input_file_name = None
self.tree = None
# Add all widgets to the GUI
self.create_widgets()
# Initialize variables to store user selections
self.scen_df: pd.DataFrame | None = None
self.excel_format: str | None = None
self.pipeline_id: str | None = None
self.scenario_id: str | None = None
self.bl_verbose: str | None = None
self.output_combination: str | None = None
[docs]
def create_widgets(self):
'''
Create and place all widgets in the GUI.
Adds labels, entry fields, buttons, comboboxes, and separators
to the top frame for user input and configuration.
'''
# Initialize row index for grid placement
self.irow_tree = 0
# Add a horizontal separator at the top
separator_horizontal = tk.Frame(self.top_frame, height=1, bg='#000000')
separator_horizontal.grid(row=self.irow_tree, column=0, columnspan=7, sticky='ew')
# Add header labels for Parameter, Input Entry, and Comments
self.irow_tree += 1
self.excel_label = tk.Label(self.top_frame, text='Parameter', anchor='w', font=('Segoe UI', 11, 'bold'))
self.excel_label.grid(row=self.irow_tree, column=1, sticky='ew', padx=10, pady=5, ipadx=10, ipady=3)
self.excel_label = tk.Label(self.top_frame, text='Input Entry', anchor='center', font=('Segoe UI', 11, 'bold'), width=10)
self.excel_label.grid(row=self.irow_tree, column=3, sticky='ew', padx=10, pady=5, ipadx=10, ipady=3)
self.excel_label = tk.Label(self.top_frame, text='Comments', anchor='w', font=('Segoe UI', 11, 'bold'))
self.excel_label.grid(row=self.irow_tree, column=5, sticky='ew', padx=10, pady=5, ipadx=10, ipady=3)
# Add a horizontal separator below the headers
self.irow_tree += 1
separator_horizontal = tk.Frame(self.top_frame, height=1, bg='#000000')
separator_horizontal.grid(row=self.irow_tree, column=0, columnspan=7, sticky='ew')
# Add output combination combobox row
self.irow_tree += 1
self.bl_output_combination_label1 = tk.Label(self.top_frame, text='Select Excel input file format', anchor='w')
self.bl_output_combination_label1.grid(row=self.irow_tree, column=1, sticky='w', padx=10, pady=5, ipadx=10, ipady=3)
self.bl_output_combination_list1 = ['Current', 'Legacy']
self.combobox_bl_output_combination1 = ttk.Combobox(self.top_frame, values=self.bl_output_combination_list1, justify='center', width=10)
self.combobox_bl_output_combination1.set(self.bl_output_combination_list1[0])
self.combobox_bl_output_combination1.grid(row=self.irow_tree, column=3, sticky='nsew', padx=10, pady=5, ipadx=10, ipady=3)
self.excel_format = self.combobox_bl_output_combination1.get()
# Add Excel file selection row
self.irow_tree += 1
self.excel_label = tk.Label(self.top_frame, text='Select Excel input file:', anchor='w')
self.excel_label.grid(row=self.irow_tree, column=1, sticky='ew', padx=10, pady=5, ipadx=10, ipady=3)
self.excel_button = tk.Button(self.top_frame, text='Open', command=self.open_file, anchor='center', width=10)
self.excel_button.grid(row=self.irow_tree, column=3, sticky='ew', padx=10, pady=5, ipadx=10)
self.excel_label = tk.Label(self.top_frame, text='Excel input file name', anchor='w')
self.excel_label.grid(row=self.irow_tree, column=5, sticky='ew', padx=10, pady=5, ipadx=10, ipady=3)
# Add Pipeline ID entry row
self.irow_tree += 1
self.pipeline_id_label = tk.Label(self.top_frame, text='Pipeline ID:', anchor='w')
self.pipeline_id_label.grid(row=self.irow_tree, column=1, sticky='w', padx=10, pady=5, ipadx=10, ipady=3)
self.pipeline_id_entry = tk.Entry(self.top_frame, justify='center', width=10)
self.pipeline_id_entry.grid(row=self.irow_tree, column=3, sticky='nsew', padx=10, pady=5, ipadx=10, ipady=3)
self.pipeline_id_entry.insert(0, 'Empty')
self.pipeline_id = self.pipeline_id_entry.get()
# Add Scenario IDs entry row
self.irow_tree += 1
self.scenario_id_label = tk.Label(self.top_frame, text='Scenario IDs (comma-separated):', anchor='w')
self.scenario_id_label.grid(row=self.irow_tree, column=1, sticky='w', padx=10, pady=5, ipadx=10, ipady=3)
self.scenario_id_entry = tk.Entry(self.top_frame, justify='center', width=10)
self.scenario_id_entry.grid(row=self.irow_tree, column=3, sticky='nsew', padx=10, pady=5, ipadx=10, ipady=3)
self.scenario_id_entry.insert(0, 'e.g. 1,2,3')
self.scenario_id = self.scenario_id_entry.get()
# Add verbose output combobox row
self.irow_tree += 1
self.bl_verbose_label = tk.Label(self.top_frame, text='Enable verbose output', anchor='w')
self.bl_verbose_label.grid(row=self.irow_tree, column=1, sticky='w', padx=10, pady=5, ipadx=10, ipady=3)
self.bl_verbose_list = ['True', 'False']
self.combobox_bl_verbose = ttk.Combobox(self.top_frame, values=self.bl_verbose_list, justify='center', width=10)
self.combobox_bl_verbose.grid(row=self.irow_tree, column=3, sticky='nsew', padx=10, pady=5, ipadx=10, ipady=3)
self.combobox_bl_verbose.set(self.bl_verbose_list[0])
self.bl_verbose = self.combobox_bl_verbose.get()
# Add output combination combobox row
self.irow_tree += 1
self.bl_output_combination_label2 = tk.Label(self.top_frame, text='Extract extended results', anchor='w')
self.bl_output_combination_label2.grid(row=self.irow_tree, column=1, sticky='w', padx=10, pady=5, ipadx=10, ipady=3)
self.bl_output_combination_list2 = ['True', 'False']
self.combobox_bl_output_combination2 = ttk.Combobox(self.top_frame, values=self.bl_output_combination_list2, justify='center', width=10)
self.combobox_bl_output_combination2.grid(row=self.irow_tree, column=3, sticky='nsew', padx=10, pady=5, ipadx=10, ipady=3)
self.combobox_bl_output_combination2.set(self.bl_output_combination_list2[-1])
self.output_combination = self.combobox_bl_output_combination2.get()
# Add OK button row
self.irow_tree += 1
self.bl_verbose_label = tk.Label(self.top_frame, text='Run all scenarios', anchor='w')
self.bl_verbose_label.grid(row=self.irow_tree, column=1, sticky='w', padx=10, pady=5, ipadx=10, ipady=3)
self.ok_button = tk.Button(self.top_frame, text='OK', command=self.close_app, width=10)
self.ok_button.grid(row=self.irow_tree, column=3, sticky='nsew', padx=10, pady=5, ipadx=10)
# Add a horizontal separator at the bottom
self.irow_tree += 1
separator_horizontal = tk.Frame(self.top_frame, height=1, bg='#000000')
separator_horizontal.grid(row=self.irow_tree, column=0, columnspan=7, sticky='ew')
# Add vertical separators between columns
separator = tk.Frame(self.top_frame, width=1, bg='#000000')
separator.grid(row=0, column=0, rowspan=self.irow_tree+1, sticky='ns', padx=0, pady=0)
separator = tk.Frame(self.top_frame, width=1, bg='#000000')
separator.grid(row=0, column=2, rowspan=self.irow_tree+1, sticky='ns', padx=0, pady=0)
separator = tk.Frame(self.top_frame, width=1, bg='#000000')
separator.grid(row=0, column=4, rowspan=self.irow_tree+1, sticky='ns', padx=0, pady=0)
separator = tk.Frame(self.top_frame, width=1, bg='#000000')
separator.grid(row=0, column=6, rowspan=self.irow_tree+1, sticky='ns', padx=0, pady=0)
[docs]
def open_file(self):
'''
Open a file dialog for the user to select an Excel input file.
Updates the displayed file name and triggers loading of scenario
data into the table if a valid file is selected.
'''
# Open a file dialog for Excel files
file_path = filedialog.askopenfilename(
initialdir=os.getcwd(),
filetypes=[('Excel files', '*.xlsx *.xls *.xlsm')],
title='Select Excel input file'
)
# If the user cancels the dialog, exit the method
if not file_path:
return
# Store the directory of the selected file
self.work_dir = os.path.dirname(file_path)
# Store the file name of the selected file
self.input_file_name = os.path.basename(file_path)
# Update the label to display the selected file name
self.excel_label.config(text=self.input_file_name)
# Load the scenario data into the Treeview
self.setup_treeview()
[docs]
def setup_treeview(self):
'''
Set up and populate the Treeview widget with scenario data.
Reads the selected Excel file, configures the table columns and
headings, and inserts scenario data rows for user review.
'''
# Read the 'Scenario' sheet from the selected Excel file into a DataFrame
self.scen_df = pd.read_excel(rf'{self.work_dir}/{self.input_file_name}', sheet_name='Scenario')
columns = self.scen_df.columns.tolist()
# Convert columns from 2 to (last-2) to integers
int_cols = self.scen_df.columns[1:-2]
self.scen_df[int_cols] = (
self.scen_df[int_cols]
.apply(pd.to_numeric, errors="coerce")
.fillna(0)
.astype("Int64")
)
# Create the Treeview widget with columns from the DataFrame
self.tree = ttk.Treeview(self.bottom_frame, columns=self.scen_df.columns.tolist(), show='headings')
self.tree.grid(row=0, column=0, sticky='nsew')
# Configure Treeview and heading styles
style = ttk.Style()
style.theme_use('default')
style.configure('Treeview.Heading', font=('Segoe UI', 10, 'bold'), padding=[0, 10], background='#f5f5f5')
style.configure('Treeview',font=('Segoe UI', 10), rowheight=30, borderwidth=1, relief='solid', background='#f5f5f5', fieldbackground='#f5f5f5')
# Set up column headings and widths
for i, col in enumerate(columns):
# Last column: left-aligned, stretchable
if i == len(columns) - 1:
self.tree.heading(col, text=col, anchor='w')
self.tree.column(col, stretch=True, anchor='w')
# Second and third last columns: center-aligned, fixed width
elif (i == len(columns) - 2) or (i == len(columns) - 3):
self.tree.heading(col, text=col, anchor='center')
self.tree.column(col, width=140, stretch=False, anchor='center')
# All other columns: center-aligned, smaller fixed width
else:
self.tree.heading(col, text=col, anchor='center')
self.tree.column(col, width=100, stretch=False, anchor='center')
# Insert each row of the DataFrame into the Treeview
for _, row in self.scen_df.iterrows():
self.tree.insert('', 'end', values=list(row))
# Reset other relevant fields
self.pipeline_id_entry.delete(0, tk.END)
self.scenario_id_entry.delete(0, tk.END)
self.pipeline_id_entry.insert(0, self.scen_df['Pipeline'].iloc[0])
self.scenario_id_entry.insert(0, self.scen_df['Scenario'].iloc[0])
[docs]
def close_app(self):
'''
Validate user input and close the GUI if all inputs are valid.
Checks that required fields are filled and formatted correctly.
If validation passes, stores user selections and closes the window.
Otherwise, displays appropriate warning or error messages.
'''
# Get the current values from the entry widgets
pipeline_id_value = self.pipeline_id_entry.get()
scenario_id_value = self.scenario_id_entry.get()
# Check if work_dir and input_file_name are set
if not self.work_dir or not self.input_file_name:
tk.messagebox.showwarning('Input Required', 'Please select a valid Excel input file.')
return
# Check for empty or placeholder values
if not pipeline_id_value or pipeline_id_value == 'Empty' or \
not scenario_id_value or scenario_id_value == 'e.g. 1,2,3':
tk.messagebox.showwarning('Input Required', 'Please enter valid Pipeline ID and Scenario IDs.')
return
# Optional: Check if Pipeline ID is a string
if pipeline_id_value.isdigit():
tk.messagebox.showwarning('Invalid Input', 'Pipeline ID should be a string.')
return
# Optional: Check if Scenario IDs are comma-separated numbers
scenario_ids = [s.strip() for s in scenario_id_value.split(',')]
if not all(s.isdigit() for s in scenario_ids):
tk.messagebox.showwarning('Invalid Input', 'Scenario IDs should be comma-separated numbers (e.g. 1,2,3).')
return
# Additional checks: Ensure pipeline_id_value and scenario_id_value(s) exist in the DataFrame
if self.scen_df is not None:
# Check if the Pipeline ID exists in the DataFrame
if pipeline_id_value not in self.scen_df['Pipeline'].astype(str).unique():
tk.messagebox.showwarning('Invalid Input', f'Pipeline ID "{pipeline_id_value}" not found in the input file.')
return
# Check if each Scenario ID exists in the DataFrame
scenario_unique = set(self.scen_df['Scenario'].astype(str).unique())
invalid_scenarios = [sid for sid in scenario_ids if sid not in scenario_unique]
if invalid_scenarios:
tk.messagebox.showwarning(
'Invalid Input',
f'Scenario ID(s) {", ".join(invalid_scenarios)} not found in the input file.'
)
return
try:
# Store the validated values in instance variables
self.excel_format = self.combobox_bl_output_combination1.get()
self.pipeline_id = pipeline_id_value
self.scenario_id = scenario_id_value
self.bl_verbose = self.combobox_bl_verbose.get()
self.output_combination = self.combobox_bl_output_combination2.get()
# Close the GUI window
self.root.destroy()
except tk.TclError as e:
# Show an error message if something unexpected happens
tk.messagebox.showerror('Error', f'An unexpected error occurred: {e}')