program_schedule_window

Defines the ProgramScheduleWindow class, a Tkinter Toplevel window. Creates and displays the detailed experimental schedule in a scrollable table format.

This view presents the pre-generated trial sequence, stimuli details, and allows for updates of trial outcomes (like lick counts and actual TTC state completion time) as the experiment progresses. It relies on data provided by external objects (like models.experiment_process_data ExperimentProcessData) to populate and update its content.

  1"""
  2Defines the ProgramScheduleWindow class, a Tkinter Toplevel window.
  3Creates and displays the detailed experimental schedule in a scrollable table format.
  4
  5This view presents the pre-generated trial sequence, stimuli details, and allows
  6for updates of trial outcomes (like lick counts and actual `TTC` state completion time)
  7as the experiment progresses. It relies on data provided by external objects
  8(like `models.experiment_process_data` ExperimentProcessData) to populate and update its content.
  9"""
 10
 11import tkinter as tk
 12
 13### Type Hinting###
 14from models.experiment_process_data import ExperimentProcessData
 15### Type Hinting###
 16
 17from views.gui_common import GUIUtils
 18
 19
 20class ProgramScheduleWindow(tk.Toplevel):
 21    """
 22    A Toplevel window that displays the experimental program schedule as a table.
 23
 24    This window visualizes the `program_schedule_df` DataFrame.
 25    Accomplished by creating a scrollable canvas containing a grid
 26    of Tkinter Labels representing the schedule data. It updates
 27    cell contents (licks, TTC) and highlights the currently active trial row
 28    based on calls from the main `app_logic` StateMachine.
 29
 30    The window is initially hidden and populated only when `show()` is first called.
 31
 32    Attributes
 33    ----------
 34    - **exp_data** (*ExperimentProcessData*): An instance that allows for access to experiment related data and methods. (E.g stimuli names,
 35      access trial lick data, etc.)
 36    - **canvas_frame** (*tk.Frame*): The main frame holding the canvas and scrollbar.
 37    - **canvas** (*tk.Canvas*): The widget providing the scrollable area for the table.
 38    - **scrollbar** (*tk.Scrollbar*): The scrollbar linked to the canvas.
 39    - **stimuli_frame** (*tk.Frame*): The frame placed inside the canvas, containing the grid of Label widgets that form the table.
 40    - **header_labels** (*list[tk.Label] OR None*): List of tk.Label widgets for the table column headers. `None` until populated.
 41    - **cell_labels** (*list[list[tk.Label]] OR  None*): A 2D list (list of rows, each row is a list of tk.Label widgets)
 42        representing the table data cells. `None` until populated.
 43
 44    Methods
 45    -------
 46    - `refresh_end_trial(...)`
 47        Updates the display after a trial ends (licks, TTC).
 48    - `refresh_start_trial(...)`
 49        Updates the display when a new trial starts (row highlighting).
 50    - `update_ttc_actual(...)`
 51        Specifically updates the 'TTC Actual' cell for a given trial.
 52    - `update_licks(...)`
 53        Specifically updates the lick count cells ('Port 1 Licks', 'Port 2 Licks') for a given trial.
 54    - `update_row_color(...)`
 55        Highlights the current trial row in yellow and resets the previous row's color.
 56    - `show()`
 57        Makes the window visible. Populates the table with data on the first call if not already done.
 58    - `populate_stimuli_table()`
 59        Creates the header and data cell Label widgets within `stimuli_frame` based on `exp_data.program_schedule_df`.
 60    """
 61
 62    def __init__(self, exp_process_data: ExperimentProcessData) -> None:
 63        super().__init__()
 64        self.exp_data = exp_process_data
 65
 66        self.title("Program Schedule")
 67
 68        self.grid_rowconfigure(0, weight=1)
 69        self.grid_columnconfigure(0, weight=1)
 70
 71        self.resizable(False, False)
 72
 73        # bind control + w shortcut to hiding the window
 74        self.bind("<Control-w>", lambda event: self.withdraw())
 75        self.protocol("WM_DELETE_WINDOW", lambda: self.withdraw())
 76
 77        # Setup the canvas and scrollbar
 78        self.canvas_frame = tk.Frame(self)
 79        self.canvas_frame.grid(row=0, column=0, sticky="nsew")
 80        self.canvas_frame.grid_rowconfigure(0, weight=1)
 81        self.canvas_frame.grid_columnconfigure(0, weight=1)
 82
 83        self.canvas = tk.Canvas(self.canvas_frame)
 84
 85        self.scrollbar = tk.Scrollbar(
 86            self.canvas_frame, orient="vertical", command=self.canvas.yview
 87        )
 88
 89        self.canvas.configure(yscrollcommand=self.scrollbar.set)
 90        self.canvas.grid(row=0, column=0, sticky="nsew")
 91        self.scrollbar.grid(row=0, column=1, sticky="ns")
 92
 93        self.grid_rowconfigure(0, weight=1)
 94        self.grid_columnconfigure(0, weight=1)
 95
 96        self.stimuli_frame = tk.Frame(self.canvas)
 97        self.canvas.create_window((0, 0), window=self.stimuli_frame, anchor="nw")
 98
 99        # initialize stimuli table variables to None, this is used in show() method to
100        # understand if window has been initialized yet
101        self.header_labels: list[tk.Label] | None = None
102        self.cell_labels: list[list[tk.Label]] | None = None
103
104        self.withdraw()
105
106    def refresh_end_trial(self, logical_trial: int) -> None:
107        """
108        Updates the table display with data from a completed trial.
109
110        Specifically calls methods to update lick counts and the actual
111        Trial Completion Time (TTC) for the given trial index. Forces an
112        update of the display using update_idletasks.
113
114        Parameters
115        ----------
116        - **logical_trial** (*int*): The zero-based index of the trial that just ended, corresponding to the row in the
117            DataFrame and `cell_labels`.
118
119        Raises
120        ------
121        - *AttributeError*: If `self.exp_data` or its `program_schedule_df` is not properly initialized.
122        - *IndexError*: If `logical_trial` is out of bounds for `self.cell_labels`.
123        - *KeyError*: If expected columns ('Port 1 Licks', etc.) are missing from the DataFrame.
124        - *tk.TclError*: If there's an issue updating the Label widgets.
125        """
126        self.update_licks(logical_trial)
127        self.update_ttc_actual(logical_trial)
128
129        self.update_idletasks()
130
131    def refresh_start_trial(self, current_trial: int) -> None:
132        """
133        Updates the table display to indicate the trial that is starting.
134
135        Specifically highlights the row corresponding to the `current_trial`.
136        Forces an update of the display.
137
138        Parameters
139        ----------
140        - **current_trial** (*int*): The one-based number of the trial that is starting.
141
142        Raises
143        ------
144        - *AttributeError*: If `self.cell_labels` is not initialized (i.e., table not populated).
145        - *IndexError*: If `current_trial` (adjusted for zero-based index) is out of bounds.
146        - *tk.TclError*: If there's an issue updating the Label widgets' background color.
147        """
148        self.update_row_color(current_trial)
149
150        self.update_idletasks()
151
152    def update_ttc_actual(self, logical_trial: int) -> None:
153        """
154        Updates the 'TTC Actual' cell for the specified trial row.
155
156        Retrieves the value from `self.exp_data.program_schedule_df` and sets
157        the text of the corresponding Label widget in `self.cell_labels`.
158
159        Parameters
160        ----------
161        - **logical_trial** (*int*): The zero-based index of the trial row to update.
162
163        Raises
164        ------
165        - *AttributeError*: If `self.exp_data` or `program_schedule_df` is None.
166        - *IndexError*: If `logical_trial` or the column index (9) is out of bounds.
167        - *KeyError*: If the column 'TTC Actual' does not exist in the DataFrame.
168        - *tk.TclError*: If updating the Label text fails.
169        """
170        df = self.exp_data.program_schedule_df
171
172        if self.cell_labels is None:
173            return
174
175        # ttc actual time taken update
176        self.cell_labels[logical_trial][9].configure(
177            text=df.loc[logical_trial, "TTC Actual"]
178        )
179
180    def update_licks(self, logical_trial: int) -> None:
181        """
182        Updates the 'Port 1 Licks' and 'Port 2 Licks' cells for the specified trial row.
183
184        Retrieves values from `self.exp_data.program_schedule_df` and sets
185        the text of the corresponding Label widgets in `self.cell_labels`.
186
187        Parameters
188        ----------
189        - **logical_trial** (*int*): The zero-based index of the trial row to update.
190
191        Raises
192        ------
193        - *AttributeError*: If `self.exp_data` or `program_schedule_df` is None.
194        - *IndexError*: If `logical_trial` or column indices (4, 5) are out of bounds.
195        - *KeyError*: If columns 'Port 1 Licks' or 'Port 2 Licks' do not exist.
196        - *tk.TclError*: If updating the Label text fails.
197        """
198
199        if self.cell_labels is None:
200            return
201
202        df = self.exp_data.program_schedule_df
203        # side 1 licks update
204        self.cell_labels[logical_trial][4].configure(
205            text=df.loc[logical_trial, "Port 1 Licks"]
206        )
207        # side 2 licks update
208        self.cell_labels[logical_trial][5].configure(
209            text=df.loc[logical_trial, "Port 2 Licks"]
210        )
211
212    def update_row_color(self, logical_trial: int) -> None:
213        """
214        Highlights the current trial row and resets the previous trial row's color.
215
216        Sets the background color of all labels in the `current_trial` row (one-based)
217        to yellow. If it's not the first trial, it also resets the background color
218        of the previous trial's row labels to white.
219
220        Parameters
221        ----------
222        - **logical_trial** (*int*): The zero-based number of the trial to highlight.
223
224        Raises
225        ------
226        - *AttributeError*: If `self.cell_labels` is None.
227        - *IndexError*: If `current_trial` (adjusted for zero-based index) is out of bounds for `self.cell_labels`.
228        - *tk.TclError*: If configuring label background colors fails.
229        """
230        # Adjust indices for zero-based indexing
231        if self.cell_labels is None:
232            return
233
234        if logical_trial == 1:
235            for i in range(10):
236                self.cell_labels[logical_trial][i].configure(bg="yellow")
237        else:
238            for i in range(10):
239                self.cell_labels[logical_trial - 1][i].configure(bg="white")
240            for i in range(10):
241                self.cell_labels[logical_trial][i].configure(bg="yellow")
242
243    def show(self) -> None:
244        """
245        Makes the window visible and populates the schedule table if not already done.
246
247        Checks if the table labels (`header_labels`) have been created. If not, it
248        attempts to populate the table using `populate_stimuli_table()`. Before
249        populating, it checks if the required DataFrame (`program_schedule_df`) exists
250        and displays an error via `GUIUtils.display_error` if it's missing.
251        After successful population (or if already populated), it makes the window
252        visible using `deiconify()`. It also sets the canvas size and scroll region
253        after the first population.
254
255        Raises
256        ------
257        - *AttributeError*: If `self.exp_data` is None or lacks `program_schedule_df` when checked.
258        - *Exception*: Can propagate exceptions from `populate_stimuli_table()` during the initial population.
259        """
260        # if this is our first time calling the function, then grab the data and fill the table
261
262        # if we have already initialized we don't need to do anything. updating the window is handled
263        # by the main app when a trial ends
264        if self.header_labels is None:
265            if self.exp_data.program_schedule_df.empty:
266                GUIUtils.display_error(
267                    "Schedule Not Generated",
268                    "The schedule has not yet been generated, try doing that in the Valve / Stimuli window first and come back!",
269                )
270                return
271            self.populate_stimuli_table()
272            # Calculate dimensions for canvas and scrollbar
273            canvas_width = self.stimuli_frame.winfo_reqwidth()
274
275            # max height 500px
276            canvas_height = min(500, self.stimuli_frame.winfo_reqheight() + 50)
277
278            # Adjust the canvas size and center the window
279            self.canvas.config(scrollregion=self.canvas.bbox("all"))
280            self.canvas.config(width=canvas_width, height=canvas_height)
281
282            # self.update_row_color(self.controller.data_mgr.current_trial_number)
283        self.deiconify()
284
285    def populate_stimuli_table(self) -> None:
286        """
287        Creates and .grid()s the Tkinter Label widgets for the schedule table headers and data.
288
289        Reads data from `self.exp_data.program_schedule_df`. Creates a header row
290        and then iterates through the DataFrame rows, creating a Label for each cell.
291        Stores the created labels in `self.header_labels` and `self.cell_labels`.
292        Adds horizontal separators periodically based on experiment settings
293        (Num Stimuli / 2). Assumes `self.stimuli_frame` exists.
294
295        Raises
296        ------
297        - *AttributeError*: If `self.exp_data` or `program_schedule_df` is None or malformed.
298        - *KeyError*: If `exp_data.exp_var_entries["Num Stimuli"]` is missing or invalid when calculating separator frequency.
299        - *tk.TclError*: If widget creation or grid placement fails.
300        - *TypeError*: If data types in the DataFrame are incompatible with Label text.
301        """
302
303        df = self.exp_data.program_schedule_df
304        self.header_labels = []
305        self.cell_labels = []
306
307        # Create labels for the column headers
308        for j, col in enumerate(df.columns):
309            header_label = tk.Label(
310                self.stimuli_frame,
311                text=col,
312                bg="light blue",
313                fg="black",
314                font=("Helvetica", 10, "bold"),
315                highlightthickness=2,
316                highlightbackground="dark blue",
317            )
318            header_label.grid(row=0, column=j, sticky="nsew", padx=5, pady=2.5)
319            self.header_labels.append(header_label)
320
321        # Get the total number of columns for spanning the separator
322        total_columns = len(df.columns)
323        current_row = 1  # Start at 1 to account for the header row
324
325        # Create cells for each row of data
326        for i, row in enumerate(df.itertuples(index=False), start=1):
327            row_labels = []
328            for j, value in enumerate(row):
329                cell_label = tk.Label(
330                    self.stimuli_frame,
331                    text=value,
332                    bg="white",
333                    fg="black",
334                    font=("Helvetica", 10),
335                    highlightthickness=1,
336                    highlightbackground="black",
337                )
338                cell_label.grid(
339                    row=current_row, column=j, sticky="nsew", padx=5, pady=2.5
340                )
341                row_labels.append(cell_label)
342            self.cell_labels.append(row_labels)
343            current_row += 1  # Move to the next row
344
345            # Insert a horizontal line below every two rows
346            if i % (self.exp_data.exp_var_entries["Num Stimuli"] / 2) == 0:
347                separator_frame = tk.Frame(self.stimuli_frame, height=2, bg="black")
348                separator_frame.grid(
349                    row=current_row,
350                    column=0,
351                    columnspan=total_columns,
352                    sticky="ew",
353                    padx=5,
354                )
355                self.stimuli_frame.grid_rowconfigure(current_row, minsize=2)
356                current_row += (
357                    1  # Increment the current row to account for the separator
358                )
359
360        # Make sure the last separator is not placed outside the data rows
361        if len(df) % 2 == 0:
362            self.stimuli_frame.grid_rowconfigure(current_row - 1, minsize=0)
363        self.stimuli_frame.update_idletasks()
class ProgramScheduleWindow(tkinter.Toplevel):
 21class ProgramScheduleWindow(tk.Toplevel):
 22    """
 23    A Toplevel window that displays the experimental program schedule as a table.
 24
 25    This window visualizes the `program_schedule_df` DataFrame.
 26    Accomplished by creating a scrollable canvas containing a grid
 27    of Tkinter Labels representing the schedule data. It updates
 28    cell contents (licks, TTC) and highlights the currently active trial row
 29    based on calls from the main `app_logic` StateMachine.
 30
 31    The window is initially hidden and populated only when `show()` is first called.
 32
 33    Attributes
 34    ----------
 35    - **exp_data** (*ExperimentProcessData*): An instance that allows for access to experiment related data and methods. (E.g stimuli names,
 36      access trial lick data, etc.)
 37    - **canvas_frame** (*tk.Frame*): The main frame holding the canvas and scrollbar.
 38    - **canvas** (*tk.Canvas*): The widget providing the scrollable area for the table.
 39    - **scrollbar** (*tk.Scrollbar*): The scrollbar linked to the canvas.
 40    - **stimuli_frame** (*tk.Frame*): The frame placed inside the canvas, containing the grid of Label widgets that form the table.
 41    - **header_labels** (*list[tk.Label] OR None*): List of tk.Label widgets for the table column headers. `None` until populated.
 42    - **cell_labels** (*list[list[tk.Label]] OR  None*): A 2D list (list of rows, each row is a list of tk.Label widgets)
 43        representing the table data cells. `None` until populated.
 44
 45    Methods
 46    -------
 47    - `refresh_end_trial(...)`
 48        Updates the display after a trial ends (licks, TTC).
 49    - `refresh_start_trial(...)`
 50        Updates the display when a new trial starts (row highlighting).
 51    - `update_ttc_actual(...)`
 52        Specifically updates the 'TTC Actual' cell for a given trial.
 53    - `update_licks(...)`
 54        Specifically updates the lick count cells ('Port 1 Licks', 'Port 2 Licks') for a given trial.
 55    - `update_row_color(...)`
 56        Highlights the current trial row in yellow and resets the previous row's color.
 57    - `show()`
 58        Makes the window visible. Populates the table with data on the first call if not already done.
 59    - `populate_stimuli_table()`
 60        Creates the header and data cell Label widgets within `stimuli_frame` based on `exp_data.program_schedule_df`.
 61    """
 62
 63    def __init__(self, exp_process_data: ExperimentProcessData) -> None:
 64        super().__init__()
 65        self.exp_data = exp_process_data
 66
 67        self.title("Program Schedule")
 68
 69        self.grid_rowconfigure(0, weight=1)
 70        self.grid_columnconfigure(0, weight=1)
 71
 72        self.resizable(False, False)
 73
 74        # bind control + w shortcut to hiding the window
 75        self.bind("<Control-w>", lambda event: self.withdraw())
 76        self.protocol("WM_DELETE_WINDOW", lambda: self.withdraw())
 77
 78        # Setup the canvas and scrollbar
 79        self.canvas_frame = tk.Frame(self)
 80        self.canvas_frame.grid(row=0, column=0, sticky="nsew")
 81        self.canvas_frame.grid_rowconfigure(0, weight=1)
 82        self.canvas_frame.grid_columnconfigure(0, weight=1)
 83
 84        self.canvas = tk.Canvas(self.canvas_frame)
 85
 86        self.scrollbar = tk.Scrollbar(
 87            self.canvas_frame, orient="vertical", command=self.canvas.yview
 88        )
 89
 90        self.canvas.configure(yscrollcommand=self.scrollbar.set)
 91        self.canvas.grid(row=0, column=0, sticky="nsew")
 92        self.scrollbar.grid(row=0, column=1, sticky="ns")
 93
 94        self.grid_rowconfigure(0, weight=1)
 95        self.grid_columnconfigure(0, weight=1)
 96
 97        self.stimuli_frame = tk.Frame(self.canvas)
 98        self.canvas.create_window((0, 0), window=self.stimuli_frame, anchor="nw")
 99
100        # initialize stimuli table variables to None, this is used in show() method to
101        # understand if window has been initialized yet
102        self.header_labels: list[tk.Label] | None = None
103        self.cell_labels: list[list[tk.Label]] | None = None
104
105        self.withdraw()
106
107    def refresh_end_trial(self, logical_trial: int) -> None:
108        """
109        Updates the table display with data from a completed trial.
110
111        Specifically calls methods to update lick counts and the actual
112        Trial Completion Time (TTC) for the given trial index. Forces an
113        update of the display using update_idletasks.
114
115        Parameters
116        ----------
117        - **logical_trial** (*int*): The zero-based index of the trial that just ended, corresponding to the row in the
118            DataFrame and `cell_labels`.
119
120        Raises
121        ------
122        - *AttributeError*: If `self.exp_data` or its `program_schedule_df` is not properly initialized.
123        - *IndexError*: If `logical_trial` is out of bounds for `self.cell_labels`.
124        - *KeyError*: If expected columns ('Port 1 Licks', etc.) are missing from the DataFrame.
125        - *tk.TclError*: If there's an issue updating the Label widgets.
126        """
127        self.update_licks(logical_trial)
128        self.update_ttc_actual(logical_trial)
129
130        self.update_idletasks()
131
132    def refresh_start_trial(self, current_trial: int) -> None:
133        """
134        Updates the table display to indicate the trial that is starting.
135
136        Specifically highlights the row corresponding to the `current_trial`.
137        Forces an update of the display.
138
139        Parameters
140        ----------
141        - **current_trial** (*int*): The one-based number of the trial that is starting.
142
143        Raises
144        ------
145        - *AttributeError*: If `self.cell_labels` is not initialized (i.e., table not populated).
146        - *IndexError*: If `current_trial` (adjusted for zero-based index) is out of bounds.
147        - *tk.TclError*: If there's an issue updating the Label widgets' background color.
148        """
149        self.update_row_color(current_trial)
150
151        self.update_idletasks()
152
153    def update_ttc_actual(self, logical_trial: int) -> None:
154        """
155        Updates the 'TTC Actual' cell for the specified trial row.
156
157        Retrieves the value from `self.exp_data.program_schedule_df` and sets
158        the text of the corresponding Label widget in `self.cell_labels`.
159
160        Parameters
161        ----------
162        - **logical_trial** (*int*): The zero-based index of the trial row to update.
163
164        Raises
165        ------
166        - *AttributeError*: If `self.exp_data` or `program_schedule_df` is None.
167        - *IndexError*: If `logical_trial` or the column index (9) is out of bounds.
168        - *KeyError*: If the column 'TTC Actual' does not exist in the DataFrame.
169        - *tk.TclError*: If updating the Label text fails.
170        """
171        df = self.exp_data.program_schedule_df
172
173        if self.cell_labels is None:
174            return
175
176        # ttc actual time taken update
177        self.cell_labels[logical_trial][9].configure(
178            text=df.loc[logical_trial, "TTC Actual"]
179        )
180
181    def update_licks(self, logical_trial: int) -> None:
182        """
183        Updates the 'Port 1 Licks' and 'Port 2 Licks' cells for the specified trial row.
184
185        Retrieves values from `self.exp_data.program_schedule_df` and sets
186        the text of the corresponding Label widgets in `self.cell_labels`.
187
188        Parameters
189        ----------
190        - **logical_trial** (*int*): The zero-based index of the trial row to update.
191
192        Raises
193        ------
194        - *AttributeError*: If `self.exp_data` or `program_schedule_df` is None.
195        - *IndexError*: If `logical_trial` or column indices (4, 5) are out of bounds.
196        - *KeyError*: If columns 'Port 1 Licks' or 'Port 2 Licks' do not exist.
197        - *tk.TclError*: If updating the Label text fails.
198        """
199
200        if self.cell_labels is None:
201            return
202
203        df = self.exp_data.program_schedule_df
204        # side 1 licks update
205        self.cell_labels[logical_trial][4].configure(
206            text=df.loc[logical_trial, "Port 1 Licks"]
207        )
208        # side 2 licks update
209        self.cell_labels[logical_trial][5].configure(
210            text=df.loc[logical_trial, "Port 2 Licks"]
211        )
212
213    def update_row_color(self, logical_trial: int) -> None:
214        """
215        Highlights the current trial row and resets the previous trial row's color.
216
217        Sets the background color of all labels in the `current_trial` row (one-based)
218        to yellow. If it's not the first trial, it also resets the background color
219        of the previous trial's row labels to white.
220
221        Parameters
222        ----------
223        - **logical_trial** (*int*): The zero-based number of the trial to highlight.
224
225        Raises
226        ------
227        - *AttributeError*: If `self.cell_labels` is None.
228        - *IndexError*: If `current_trial` (adjusted for zero-based index) is out of bounds for `self.cell_labels`.
229        - *tk.TclError*: If configuring label background colors fails.
230        """
231        # Adjust indices for zero-based indexing
232        if self.cell_labels is None:
233            return
234
235        if logical_trial == 1:
236            for i in range(10):
237                self.cell_labels[logical_trial][i].configure(bg="yellow")
238        else:
239            for i in range(10):
240                self.cell_labels[logical_trial - 1][i].configure(bg="white")
241            for i in range(10):
242                self.cell_labels[logical_trial][i].configure(bg="yellow")
243
244    def show(self) -> None:
245        """
246        Makes the window visible and populates the schedule table if not already done.
247
248        Checks if the table labels (`header_labels`) have been created. If not, it
249        attempts to populate the table using `populate_stimuli_table()`. Before
250        populating, it checks if the required DataFrame (`program_schedule_df`) exists
251        and displays an error via `GUIUtils.display_error` if it's missing.
252        After successful population (or if already populated), it makes the window
253        visible using `deiconify()`. It also sets the canvas size and scroll region
254        after the first population.
255
256        Raises
257        ------
258        - *AttributeError*: If `self.exp_data` is None or lacks `program_schedule_df` when checked.
259        - *Exception*: Can propagate exceptions from `populate_stimuli_table()` during the initial population.
260        """
261        # if this is our first time calling the function, then grab the data and fill the table
262
263        # if we have already initialized we don't need to do anything. updating the window is handled
264        # by the main app when a trial ends
265        if self.header_labels is None:
266            if self.exp_data.program_schedule_df.empty:
267                GUIUtils.display_error(
268                    "Schedule Not Generated",
269                    "The schedule has not yet been generated, try doing that in the Valve / Stimuli window first and come back!",
270                )
271                return
272            self.populate_stimuli_table()
273            # Calculate dimensions for canvas and scrollbar
274            canvas_width = self.stimuli_frame.winfo_reqwidth()
275
276            # max height 500px
277            canvas_height = min(500, self.stimuli_frame.winfo_reqheight() + 50)
278
279            # Adjust the canvas size and center the window
280            self.canvas.config(scrollregion=self.canvas.bbox("all"))
281            self.canvas.config(width=canvas_width, height=canvas_height)
282
283            # self.update_row_color(self.controller.data_mgr.current_trial_number)
284        self.deiconify()
285
286    def populate_stimuli_table(self) -> None:
287        """
288        Creates and .grid()s the Tkinter Label widgets for the schedule table headers and data.
289
290        Reads data from `self.exp_data.program_schedule_df`. Creates a header row
291        and then iterates through the DataFrame rows, creating a Label for each cell.
292        Stores the created labels in `self.header_labels` and `self.cell_labels`.
293        Adds horizontal separators periodically based on experiment settings
294        (Num Stimuli / 2). Assumes `self.stimuli_frame` exists.
295
296        Raises
297        ------
298        - *AttributeError*: If `self.exp_data` or `program_schedule_df` is None or malformed.
299        - *KeyError*: If `exp_data.exp_var_entries["Num Stimuli"]` is missing or invalid when calculating separator frequency.
300        - *tk.TclError*: If widget creation or grid placement fails.
301        - *TypeError*: If data types in the DataFrame are incompatible with Label text.
302        """
303
304        df = self.exp_data.program_schedule_df
305        self.header_labels = []
306        self.cell_labels = []
307
308        # Create labels for the column headers
309        for j, col in enumerate(df.columns):
310            header_label = tk.Label(
311                self.stimuli_frame,
312                text=col,
313                bg="light blue",
314                fg="black",
315                font=("Helvetica", 10, "bold"),
316                highlightthickness=2,
317                highlightbackground="dark blue",
318            )
319            header_label.grid(row=0, column=j, sticky="nsew", padx=5, pady=2.5)
320            self.header_labels.append(header_label)
321
322        # Get the total number of columns for spanning the separator
323        total_columns = len(df.columns)
324        current_row = 1  # Start at 1 to account for the header row
325
326        # Create cells for each row of data
327        for i, row in enumerate(df.itertuples(index=False), start=1):
328            row_labels = []
329            for j, value in enumerate(row):
330                cell_label = tk.Label(
331                    self.stimuli_frame,
332                    text=value,
333                    bg="white",
334                    fg="black",
335                    font=("Helvetica", 10),
336                    highlightthickness=1,
337                    highlightbackground="black",
338                )
339                cell_label.grid(
340                    row=current_row, column=j, sticky="nsew", padx=5, pady=2.5
341                )
342                row_labels.append(cell_label)
343            self.cell_labels.append(row_labels)
344            current_row += 1  # Move to the next row
345
346            # Insert a horizontal line below every two rows
347            if i % (self.exp_data.exp_var_entries["Num Stimuli"] / 2) == 0:
348                separator_frame = tk.Frame(self.stimuli_frame, height=2, bg="black")
349                separator_frame.grid(
350                    row=current_row,
351                    column=0,
352                    columnspan=total_columns,
353                    sticky="ew",
354                    padx=5,
355                )
356                self.stimuli_frame.grid_rowconfigure(current_row, minsize=2)
357                current_row += (
358                    1  # Increment the current row to account for the separator
359                )
360
361        # Make sure the last separator is not placed outside the data rows
362        if len(df) % 2 == 0:
363            self.stimuli_frame.grid_rowconfigure(current_row - 1, minsize=0)
364        self.stimuli_frame.update_idletasks()

A Toplevel window that displays the experimental program schedule as a table.

This window visualizes the program_schedule_df DataFrame. Accomplished by creating a scrollable canvas containing a grid of Tkinter Labels representing the schedule data. It updates cell contents (licks, TTC) and highlights the currently active trial row based on calls from the main app_logic StateMachine.

The window is initially hidden and populated only when show() is first called.

Attributes

  • exp_data (ExperimentProcessData): An instance that allows for access to experiment related data and methods. (E.g stimuli names, access trial lick data, etc.)
  • canvas_frame (tk.Frame): The main frame holding the canvas and scrollbar.
  • canvas (tk.Canvas): The widget providing the scrollable area for the table.
  • scrollbar (tk.Scrollbar): The scrollbar linked to the canvas.
  • stimuli_frame (tk.Frame): The frame placed inside the canvas, containing the grid of Label widgets that form the table.
  • header_labels (list[tk.Label] OR None): List of tk.Label widgets for the table column headers. None until populated.
  • cell_labels (list[list[tk.Label]] OR None): A 2D list (list of rows, each row is a list of tk.Label widgets) representing the table data cells. None until populated.

Methods

  • refresh_end_trial(...) Updates the display after a trial ends (licks, TTC).
  • refresh_start_trial(...) Updates the display when a new trial starts (row highlighting).
  • update_ttc_actual(...) Specifically updates the 'TTC Actual' cell for a given trial.
  • update_licks(...) Specifically updates the lick count cells ('Port 1 Licks', 'Port 2 Licks') for a given trial.
  • update_row_color(...) Highlights the current trial row in yellow and resets the previous row's color.
  • show() Makes the window visible. Populates the table with data on the first call if not already done.
  • populate_stimuli_table() Creates the header and data cell Label widgets within stimuli_frame based on exp_data.program_schedule_df.
ProgramScheduleWindow( exp_process_data: models.experiment_process_data.ExperimentProcessData)
 63    def __init__(self, exp_process_data: ExperimentProcessData) -> None:
 64        super().__init__()
 65        self.exp_data = exp_process_data
 66
 67        self.title("Program Schedule")
 68
 69        self.grid_rowconfigure(0, weight=1)
 70        self.grid_columnconfigure(0, weight=1)
 71
 72        self.resizable(False, False)
 73
 74        # bind control + w shortcut to hiding the window
 75        self.bind("<Control-w>", lambda event: self.withdraw())
 76        self.protocol("WM_DELETE_WINDOW", lambda: self.withdraw())
 77
 78        # Setup the canvas and scrollbar
 79        self.canvas_frame = tk.Frame(self)
 80        self.canvas_frame.grid(row=0, column=0, sticky="nsew")
 81        self.canvas_frame.grid_rowconfigure(0, weight=1)
 82        self.canvas_frame.grid_columnconfigure(0, weight=1)
 83
 84        self.canvas = tk.Canvas(self.canvas_frame)
 85
 86        self.scrollbar = tk.Scrollbar(
 87            self.canvas_frame, orient="vertical", command=self.canvas.yview
 88        )
 89
 90        self.canvas.configure(yscrollcommand=self.scrollbar.set)
 91        self.canvas.grid(row=0, column=0, sticky="nsew")
 92        self.scrollbar.grid(row=0, column=1, sticky="ns")
 93
 94        self.grid_rowconfigure(0, weight=1)
 95        self.grid_columnconfigure(0, weight=1)
 96
 97        self.stimuli_frame = tk.Frame(self.canvas)
 98        self.canvas.create_window((0, 0), window=self.stimuli_frame, anchor="nw")
 99
100        # initialize stimuli table variables to None, this is used in show() method to
101        # understand if window has been initialized yet
102        self.header_labels: list[tk.Label] | None = None
103        self.cell_labels: list[list[tk.Label]] | None = None
104
105        self.withdraw()

Construct a toplevel widget with the parent MASTER.

Valid resource names: background, bd, bg, borderwidth, class, colormap, container, cursor, height, highlightbackground, highlightcolor, highlightthickness, menu, relief, screen, takefocus, use, visual, width.

exp_data
canvas_frame
canvas
scrollbar
stimuli_frame
header_labels: list[tkinter.Label] | None
cell_labels: list[list[tkinter.Label]] | None
def refresh_end_trial(self, logical_trial: int) -> None:
107    def refresh_end_trial(self, logical_trial: int) -> None:
108        """
109        Updates the table display with data from a completed trial.
110
111        Specifically calls methods to update lick counts and the actual
112        Trial Completion Time (TTC) for the given trial index. Forces an
113        update of the display using update_idletasks.
114
115        Parameters
116        ----------
117        - **logical_trial** (*int*): The zero-based index of the trial that just ended, corresponding to the row in the
118            DataFrame and `cell_labels`.
119
120        Raises
121        ------
122        - *AttributeError*: If `self.exp_data` or its `program_schedule_df` is not properly initialized.
123        - *IndexError*: If `logical_trial` is out of bounds for `self.cell_labels`.
124        - *KeyError*: If expected columns ('Port 1 Licks', etc.) are missing from the DataFrame.
125        - *tk.TclError*: If there's an issue updating the Label widgets.
126        """
127        self.update_licks(logical_trial)
128        self.update_ttc_actual(logical_trial)
129
130        self.update_idletasks()

Updates the table display with data from a completed trial.

Specifically calls methods to update lick counts and the actual Trial Completion Time (TTC) for the given trial index. Forces an update of the display using update_idletasks.

Parameters

  • logical_trial (int): The zero-based index of the trial that just ended, corresponding to the row in the DataFrame and cell_labels.

Raises

  • AttributeError: If self.exp_data or its program_schedule_df is not properly initialized.
  • IndexError: If logical_trial is out of bounds for self.cell_labels.
  • KeyError: If expected columns ('Port 1 Licks', etc.) are missing from the DataFrame.
  • tk.TclError: If there's an issue updating the Label widgets.
def refresh_start_trial(self, current_trial: int) -> None:
132    def refresh_start_trial(self, current_trial: int) -> None:
133        """
134        Updates the table display to indicate the trial that is starting.
135
136        Specifically highlights the row corresponding to the `current_trial`.
137        Forces an update of the display.
138
139        Parameters
140        ----------
141        - **current_trial** (*int*): The one-based number of the trial that is starting.
142
143        Raises
144        ------
145        - *AttributeError*: If `self.cell_labels` is not initialized (i.e., table not populated).
146        - *IndexError*: If `current_trial` (adjusted for zero-based index) is out of bounds.
147        - *tk.TclError*: If there's an issue updating the Label widgets' background color.
148        """
149        self.update_row_color(current_trial)
150
151        self.update_idletasks()

Updates the table display to indicate the trial that is starting.

Specifically highlights the row corresponding to the current_trial. Forces an update of the display.

Parameters

  • current_trial (int): The one-based number of the trial that is starting.

Raises

  • AttributeError: If self.cell_labels is not initialized (i.e., table not populated).
  • IndexError: If current_trial (adjusted for zero-based index) is out of bounds.
  • tk.TclError: If there's an issue updating the Label widgets' background color.
def update_ttc_actual(self, logical_trial: int) -> None:
153    def update_ttc_actual(self, logical_trial: int) -> None:
154        """
155        Updates the 'TTC Actual' cell for the specified trial row.
156
157        Retrieves the value from `self.exp_data.program_schedule_df` and sets
158        the text of the corresponding Label widget in `self.cell_labels`.
159
160        Parameters
161        ----------
162        - **logical_trial** (*int*): The zero-based index of the trial row to update.
163
164        Raises
165        ------
166        - *AttributeError*: If `self.exp_data` or `program_schedule_df` is None.
167        - *IndexError*: If `logical_trial` or the column index (9) is out of bounds.
168        - *KeyError*: If the column 'TTC Actual' does not exist in the DataFrame.
169        - *tk.TclError*: If updating the Label text fails.
170        """
171        df = self.exp_data.program_schedule_df
172
173        if self.cell_labels is None:
174            return
175
176        # ttc actual time taken update
177        self.cell_labels[logical_trial][9].configure(
178            text=df.loc[logical_trial, "TTC Actual"]
179        )

Updates the 'TTC Actual' cell for the specified trial row.

Retrieves the value from self.exp_data.program_schedule_df and sets the text of the corresponding Label widget in self.cell_labels.

Parameters

  • logical_trial (int): The zero-based index of the trial row to update.

Raises

  • AttributeError: If self.exp_data or program_schedule_df is None.
  • IndexError: If logical_trial or the column index (9) is out of bounds.
  • KeyError: If the column 'TTC Actual' does not exist in the DataFrame.
  • tk.TclError: If updating the Label text fails.
def update_licks(self, logical_trial: int) -> None:
181    def update_licks(self, logical_trial: int) -> None:
182        """
183        Updates the 'Port 1 Licks' and 'Port 2 Licks' cells for the specified trial row.
184
185        Retrieves values from `self.exp_data.program_schedule_df` and sets
186        the text of the corresponding Label widgets in `self.cell_labels`.
187
188        Parameters
189        ----------
190        - **logical_trial** (*int*): The zero-based index of the trial row to update.
191
192        Raises
193        ------
194        - *AttributeError*: If `self.exp_data` or `program_schedule_df` is None.
195        - *IndexError*: If `logical_trial` or column indices (4, 5) are out of bounds.
196        - *KeyError*: If columns 'Port 1 Licks' or 'Port 2 Licks' do not exist.
197        - *tk.TclError*: If updating the Label text fails.
198        """
199
200        if self.cell_labels is None:
201            return
202
203        df = self.exp_data.program_schedule_df
204        # side 1 licks update
205        self.cell_labels[logical_trial][4].configure(
206            text=df.loc[logical_trial, "Port 1 Licks"]
207        )
208        # side 2 licks update
209        self.cell_labels[logical_trial][5].configure(
210            text=df.loc[logical_trial, "Port 2 Licks"]
211        )

Updates the 'Port 1 Licks' and 'Port 2 Licks' cells for the specified trial row.

Retrieves values from self.exp_data.program_schedule_df and sets the text of the corresponding Label widgets in self.cell_labels.

Parameters

  • logical_trial (int): The zero-based index of the trial row to update.

Raises

  • AttributeError: If self.exp_data or program_schedule_df is None.
  • IndexError: If logical_trial or column indices (4, 5) are out of bounds.
  • KeyError: If columns 'Port 1 Licks' or 'Port 2 Licks' do not exist.
  • tk.TclError: If updating the Label text fails.
def update_row_color(self, logical_trial: int) -> None:
213    def update_row_color(self, logical_trial: int) -> None:
214        """
215        Highlights the current trial row and resets the previous trial row's color.
216
217        Sets the background color of all labels in the `current_trial` row (one-based)
218        to yellow. If it's not the first trial, it also resets the background color
219        of the previous trial's row labels to white.
220
221        Parameters
222        ----------
223        - **logical_trial** (*int*): The zero-based number of the trial to highlight.
224
225        Raises
226        ------
227        - *AttributeError*: If `self.cell_labels` is None.
228        - *IndexError*: If `current_trial` (adjusted for zero-based index) is out of bounds for `self.cell_labels`.
229        - *tk.TclError*: If configuring label background colors fails.
230        """
231        # Adjust indices for zero-based indexing
232        if self.cell_labels is None:
233            return
234
235        if logical_trial == 1:
236            for i in range(10):
237                self.cell_labels[logical_trial][i].configure(bg="yellow")
238        else:
239            for i in range(10):
240                self.cell_labels[logical_trial - 1][i].configure(bg="white")
241            for i in range(10):
242                self.cell_labels[logical_trial][i].configure(bg="yellow")

Highlights the current trial row and resets the previous trial row's color.

Sets the background color of all labels in the current_trial row (one-based) to yellow. If it's not the first trial, it also resets the background color of the previous trial's row labels to white.

Parameters

  • logical_trial (int): The zero-based number of the trial to highlight.

Raises

  • AttributeError: If self.cell_labels is None.
  • IndexError: If current_trial (adjusted for zero-based index) is out of bounds for self.cell_labels.
  • tk.TclError: If configuring label background colors fails.
def show(self) -> None:
244    def show(self) -> None:
245        """
246        Makes the window visible and populates the schedule table if not already done.
247
248        Checks if the table labels (`header_labels`) have been created. If not, it
249        attempts to populate the table using `populate_stimuli_table()`. Before
250        populating, it checks if the required DataFrame (`program_schedule_df`) exists
251        and displays an error via `GUIUtils.display_error` if it's missing.
252        After successful population (or if already populated), it makes the window
253        visible using `deiconify()`. It also sets the canvas size and scroll region
254        after the first population.
255
256        Raises
257        ------
258        - *AttributeError*: If `self.exp_data` is None or lacks `program_schedule_df` when checked.
259        - *Exception*: Can propagate exceptions from `populate_stimuli_table()` during the initial population.
260        """
261        # if this is our first time calling the function, then grab the data and fill the table
262
263        # if we have already initialized we don't need to do anything. updating the window is handled
264        # by the main app when a trial ends
265        if self.header_labels is None:
266            if self.exp_data.program_schedule_df.empty:
267                GUIUtils.display_error(
268                    "Schedule Not Generated",
269                    "The schedule has not yet been generated, try doing that in the Valve / Stimuli window first and come back!",
270                )
271                return
272            self.populate_stimuli_table()
273            # Calculate dimensions for canvas and scrollbar
274            canvas_width = self.stimuli_frame.winfo_reqwidth()
275
276            # max height 500px
277            canvas_height = min(500, self.stimuli_frame.winfo_reqheight() + 50)
278
279            # Adjust the canvas size and center the window
280            self.canvas.config(scrollregion=self.canvas.bbox("all"))
281            self.canvas.config(width=canvas_width, height=canvas_height)
282
283            # self.update_row_color(self.controller.data_mgr.current_trial_number)
284        self.deiconify()

Makes the window visible and populates the schedule table if not already done.

Checks if the table labels (header_labels) have been created. If not, it attempts to populate the table using populate_stimuli_table(). Before populating, it checks if the required DataFrame (program_schedule_df) exists and displays an error via GUIUtils.display_error if it's missing. After successful population (or if already populated), it makes the window visible using deiconify(). It also sets the canvas size and scroll region after the first population.

Raises

  • AttributeError: If self.exp_data is None or lacks program_schedule_df when checked.
  • Exception: Can propagate exceptions from populate_stimuli_table() during the initial population.
def populate_stimuli_table(self) -> None:
286    def populate_stimuli_table(self) -> None:
287        """
288        Creates and .grid()s the Tkinter Label widgets for the schedule table headers and data.
289
290        Reads data from `self.exp_data.program_schedule_df`. Creates a header row
291        and then iterates through the DataFrame rows, creating a Label for each cell.
292        Stores the created labels in `self.header_labels` and `self.cell_labels`.
293        Adds horizontal separators periodically based on experiment settings
294        (Num Stimuli / 2). Assumes `self.stimuli_frame` exists.
295
296        Raises
297        ------
298        - *AttributeError*: If `self.exp_data` or `program_schedule_df` is None or malformed.
299        - *KeyError*: If `exp_data.exp_var_entries["Num Stimuli"]` is missing or invalid when calculating separator frequency.
300        - *tk.TclError*: If widget creation or grid placement fails.
301        - *TypeError*: If data types in the DataFrame are incompatible with Label text.
302        """
303
304        df = self.exp_data.program_schedule_df
305        self.header_labels = []
306        self.cell_labels = []
307
308        # Create labels for the column headers
309        for j, col in enumerate(df.columns):
310            header_label = tk.Label(
311                self.stimuli_frame,
312                text=col,
313                bg="light blue",
314                fg="black",
315                font=("Helvetica", 10, "bold"),
316                highlightthickness=2,
317                highlightbackground="dark blue",
318            )
319            header_label.grid(row=0, column=j, sticky="nsew", padx=5, pady=2.5)
320            self.header_labels.append(header_label)
321
322        # Get the total number of columns for spanning the separator
323        total_columns = len(df.columns)
324        current_row = 1  # Start at 1 to account for the header row
325
326        # Create cells for each row of data
327        for i, row in enumerate(df.itertuples(index=False), start=1):
328            row_labels = []
329            for j, value in enumerate(row):
330                cell_label = tk.Label(
331                    self.stimuli_frame,
332                    text=value,
333                    bg="white",
334                    fg="black",
335                    font=("Helvetica", 10),
336                    highlightthickness=1,
337                    highlightbackground="black",
338                )
339                cell_label.grid(
340                    row=current_row, column=j, sticky="nsew", padx=5, pady=2.5
341                )
342                row_labels.append(cell_label)
343            self.cell_labels.append(row_labels)
344            current_row += 1  # Move to the next row
345
346            # Insert a horizontal line below every two rows
347            if i % (self.exp_data.exp_var_entries["Num Stimuli"] / 2) == 0:
348                separator_frame = tk.Frame(self.stimuli_frame, height=2, bg="black")
349                separator_frame.grid(
350                    row=current_row,
351                    column=0,
352                    columnspan=total_columns,
353                    sticky="ew",
354                    padx=5,
355                )
356                self.stimuli_frame.grid_rowconfigure(current_row, minsize=2)
357                current_row += (
358                    1  # Increment the current row to account for the separator
359                )
360
361        # Make sure the last separator is not placed outside the data rows
362        if len(df) % 2 == 0:
363            self.stimuli_frame.grid_rowconfigure(current_row - 1, minsize=0)
364        self.stimuli_frame.update_idletasks()

Creates and .grid()s the Tkinter Label widgets for the schedule table headers and data.

Reads data from self.exp_data.program_schedule_df. Creates a header row and then iterates through the DataFrame rows, creating a Label for each cell. Stores the created labels in self.header_labels and self.cell_labels. Adds horizontal separators periodically based on experiment settings (Num Stimuli / 2). Assumes self.stimuli_frame exists.

Raises

  • AttributeError: If self.exp_data or program_schedule_df is None or malformed.
  • KeyError: If exp_data.exp_var_entries["Num Stimuli"] is missing or invalid when calculating separator frequency.
  • tk.TclError: If widget creation or grid placement fails.
  • TypeError: If data types in the DataFrame are incompatible with Label text.