| # Copyright 2025 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import re |
| import sys |
| import tkinter as tk |
| import webbrowser |
| from tkinter import messagebox as mb |
| from tkinter import ttk |
| from typing import Any |
| |
| import cli |
| import data_structure |
| import util |
| |
| |
| class GUI: |
| def __init__(self) -> None: |
| self.cli: cli.CLI | None = None |
| |
| def inform(self, out: util.Outputs) -> None: |
| if not self.cli: |
| self.cli = cli.CLI() |
| self.cli.inform(out) |
| |
| def test_list(self, tests: data_structure.Tests) -> None: |
| GUIWithTests(tests).run() |
| |
| def bail(self, failure_message: str) -> None: |
| print(failure_message) |
| sys.exit(1) |
| |
| |
| class GUIWithTests: |
| def __init__(self, tests: data_structure.Tests) -> None: |
| self.tests = tests |
| self.cli: cli.CLI | None = None |
| self.unsaved = False |
| self.window = Window() |
| self.tree = Tree(self) |
| self.edit_pane = EditPane(self, self.tree) |
| self.edit_pane.display("Select a test case") |
| self.control_pane = ControlPane(self) |
| self.tree.fill(self.select_test_suites()) |
| self.window.window.bind("<Control-s>", self.save) |
| self.window.window.bind("<Control-q>", self.try_quit) |
| |
| def save(self, event: Any = None) -> None: |
| self.tests.save() |
| self.unsaved = False |
| |
| def run(self) -> None: |
| self.window.mainloop() |
| |
| def select_test_suites( |
| self, filter: str = "", disabled_only: bool = False |
| ) -> list[data_structure.TestSuite]: |
| test_list = self.tests.tests |
| if filter == "" and not disabled_only: |
| return test_list |
| filtered_suites: list[data_structure.TestSuite] = [] |
| for t in test_list: |
| if filter in t.compact_name and not disabled_only: |
| filtered_suites.append(t) |
| else: |
| filtered_cases = [] |
| for c in t.cases: |
| if filter in c.name and not ( |
| disabled_only and not c.disabled |
| ): |
| filtered_cases.append(c) |
| if filtered_cases: |
| filtered_suites.append( |
| data_structure.TestSuite( |
| name=t.name, cases=filtered_cases |
| ) |
| ) |
| return filtered_suites |
| |
| def set_unsaved(self) -> None: |
| self.unsaved = True |
| |
| def try_quit(self, _: Any = None) -> None: |
| if self.unsaved: |
| response = mb.askyesnocancel( |
| "Unsaved changes!", |
| "Do you want to save before quitting?", |
| default=mb.YES, |
| icon=mb.QUESTION, |
| ) |
| if response is None: |
| return |
| if response: |
| self.save() |
| sys.exit(0) |
| |
| def filter_tree(self, filter_string: str, disabled_only: bool) -> None: |
| self.tree.fill(self.select_test_suites(filter_string, disabled_only)) |
| |
| |
| DISPLAY_DISABLED = " Disabled" |
| DISPLAY_ENABLED = " -" |
| |
| |
| class Tree: |
| def __init__(self, state: GUIWithTests) -> None: |
| self.state = state |
| self.cases_lookup: dict[str, data_structure.TestCase] = {} |
| t = ttk.Treeview(columns=("disabled", "bug")) |
| self.tree = t |
| t.heading("#0", text="Name") |
| t.heading("disabled", text="Disabled") |
| t.heading("bug", text="Bug") |
| t.column("#0", width=300) |
| t.column("disabled", width=80, stretch=tk.NO) |
| t.column("bug", width=140) |
| t.bind("<<TreeviewSelect>>", self.selected) |
| t.pack(fill=tk.BOTH, expand=True) |
| |
| def fill(self, tests: list[data_structure.TestSuite]) -> None: |
| for item in self.tree.get_children(): |
| self.tree.delete(item) |
| |
| self.cases_lookup = {} |
| for t in tests: |
| t_tkid = self.tree.insert("", tk.END, text=t.compact_name) |
| self.tree.item(t_tkid, open=True) |
| for c in t.cases: |
| case_id = self.tree.insert( |
| t_tkid, |
| tk.END, |
| text=c.name, |
| values=(self.disabled_string(c.disabled), c.bug), |
| ) |
| self.cases_lookup[case_id] = c |
| |
| def selected_name_and_values(self) -> tuple[str, Any]: |
| item_and_foo = self.tree.selection() |
| if len(item_and_foo) != 1: |
| return "", [] |
| item = item_and_foo[0] |
| name = self.tree.item(item, "text") |
| values = self.tree.item(item, "values") |
| return name, values |
| |
| def selected(self, _: Any) -> None: |
| self.tree.after_idle(self.deferred_selected) |
| |
| def deferred_selected(self) -> None: |
| name, values = self.selected_name_and_values() |
| if len(values) != 2: |
| self.state.edit_pane.display(name) |
| else: |
| self.state.edit_pane.edit_tree_selection() |
| |
| def edit_values(self) -> tuple[str, bool, str]: |
| name, values = self.selected_name_and_values() |
| if len(values) != 2: |
| return name, False, "" |
| disabled_string, bug = values |
| parent_item = self.tree.parent(self.tree.selection()[0]) |
| parent_name = self.tree.item(parent_item, "text") |
| return f"{parent_name}:{name}", disabled_string == DISPLAY_DISABLED, bug |
| |
| def update(self, disabled: bool, bug: str) -> None: |
| item = self.tree.selection()[0] |
| self.tree.item(item, values=(self.disabled_string(disabled), bug)) |
| case = self.cases_lookup[item] |
| case.disabled = disabled |
| case.bug = bug |
| self.state.set_unsaved() |
| |
| def disabled_string(self, disabled: bool) -> str: |
| return DISPLAY_DISABLED if disabled else DISPLAY_ENABLED |
| |
| |
| class EditPane: |
| def __init__(self, state: GUIWithTests, tree: Tree) -> None: |
| self.frame = ttk.Frame(state.window.window) |
| # subframe holds the 2nd line of the EditPane with several widgets in it. |
| self.subframe = ttk.Frame(self.frame) |
| self.disable_widget_actions = False |
| self.label_contents = tk.StringVar(value="") |
| self.label = ttk.Label(self.frame, textvariable=self.label_contents) |
| # We want label above subframe |
| self.label.pack(side="top", anchor="w") |
| self.checkbox_value = tk.BooleanVar(value=False) |
| self.checkbox = ttk.Checkbutton( |
| self.subframe, |
| text="Disable?", |
| variable=self.checkbox_value, |
| onvalue=True, |
| offvalue=False, |
| command=self.update, |
| ) |
| self.checkbox.grid(row=0, column=0, padx=20, sticky="w") |
| self.b = ttk.Label(self.subframe, text="Bug:") |
| self.b.grid(row=0, column=1, padx=(20, 0), sticky="w") |
| self.edit_value = tk.StringVar(value="") |
| self.editor = ttk.Entry( |
| self.subframe, textvariable=self.edit_value, width=40 |
| ) |
| self.editor.grid(row=0, column=2, padx=(0, 20), sticky="ew") |
| self.edit_value.trace_add("write", self.update) |
| self.clickable_url = ttk.Label(self.subframe, text="") |
| self.clickable_url.grid(row=0, column=3, sticky="w") |
| self.subframe.pack( |
| side="bottom", fill=tk.X, expand=False, anchor="w", padx=20, pady=20 |
| ) |
| self.frame.pack( |
| side="bottom", fill=tk.X, expand=False, anchor="w", padx=20, pady=20 |
| ) |
| self.tree = tree |
| self.editing = False |
| |
| def update(self, _1: Any = None, _2: Any = None, _3: Any = None) -> None: |
| if self.editing and not self.disable_widget_actions: |
| bug = self.edit_value.get() |
| self.update_clickable_url(bug) |
| self.tree.update(self.checkbox_value.get(), bug) |
| |
| def done_with_programmatic_change(self) -> None: |
| """Start treating UI updates as user actions. |
| |
| The program should not call this to balance |
| start_programmatic_change(). Instead, start_programmatic_change() will |
| queue this up to be called after Tkinter has drained all the events |
| associated with the programmatic change.""" |
| |
| self.disable_widget_actions = False |
| |
| def start_programmatic_change(self) -> None: |
| """Don't treat UI updates as user actions. |
| |
| This is needed because Tkinter processes events in a strange order, and |
| a checkbox may still be False after I've told it to be True. If I display |
| a new test that's disabled, and the previous test was not disabled, then |
| setting the checkbox to disabled might cause an update() call on the tree, |
| which would check the checkbox and find disabled=False still. |
| |
| So this function should be called when the program is modifying the UI |
| values. It will queue up setting disable_widget_actions to False after |
| all the event-loop stuff has finished.""" |
| |
| self.disable_widget_actions = True |
| self.frame.after_idle(self.done_with_programmatic_change) |
| |
| def update_clickable_url(self, bug: str | None) -> None: |
| def open_url_in_browser(url: str) -> None: |
| try: |
| webbrowser.open_new_tab(url) |
| except webbrowser.Error as e: |
| print(f"Could not open URL: {e}") |
| |
| if bug is not None: |
| bug_text = re.search(r"\bb/(\d+)\b", bug) |
| if bug_text: |
| url = f"fxbug.dev/{bug_text.group(1)}" |
| self.clickable_url.config( |
| text=url, foreground="blue", cursor="hand2" |
| ) |
| # Python footgun: Beware of capturing the wrong version of the |
| # variable in the lambda. In this case, it's OK to use |
| # the latest, but if I were putting up several different |
| # URLs I'd want to do something like: |
| # lambda event, bound_url_value=f"https://{url}": \ |
| # open_url_in_browser(bound_url_value) |
| self.clickable_url.bind( |
| "<Button-1>", |
| lambda event: open_url_in_browser(f"https://{url}"), |
| ) |
| return |
| self.clickable_url.config(text="", cursor="") |
| self.clickable_url.unbind("<Button-1>") |
| |
| def display(self, name: str) -> None: |
| self.start_programmatic_change() |
| self.update_clickable_url(None) |
| self.editing = False |
| self.label_contents.set(name) |
| self.checkbox.config(state="disabled") |
| self.editor.config(state="disabled") |
| self.b.config(foreground="gray") |
| |
| def edit_tree_selection(self) -> None: |
| self.start_programmatic_change() |
| text, disabled, bug = self.tree.edit_values() |
| self.update_clickable_url(bug) |
| self.label_contents.set(text) |
| self.edit_value.set(bug) |
| self.checkbox_value.set(disabled) |
| self.checkbox.config(state="normal") |
| self.editor.config(state="normal") |
| self.b.config(foreground="black") |
| self.editing = True |
| |
| |
| class ControlPane: |
| def __init__(self, state: GUIWithTests) -> None: |
| self.state = state |
| self.frame = ttk.Frame(state.window.window) |
| self.filter_label = ttk.Label(self.frame, text="Filter:") |
| self.filter_label.pack(side="left", padx=(20, 0)) |
| self.filter_value = tk.StringVar(value="") |
| self.filter_entry = ttk.Entry( |
| self.frame, textvariable=self.filter_value |
| ) |
| self.filter_entry.pack(side="left", padx=(0, 20)) |
| self.filter_value.trace_add("write", self.update) |
| self.checkbox_value = tk.BooleanVar(value=False) |
| self.checkbox = ttk.Checkbutton( |
| self.frame, |
| text="Disabled Only", |
| variable=self.checkbox_value, |
| onvalue=True, |
| offvalue=False, |
| command=self.update, |
| ) |
| self.checkbox.pack(side="left", padx=20) |
| self.save_button = ttk.Button( |
| self.frame, text="Save ^S", command=self.state.save |
| ) |
| self.save_button.pack(side="left", padx=20) |
| self.quit_button = ttk.Button( |
| self.frame, text="Quit ^Q", command=state.try_quit |
| ) |
| self.quit_button.pack(side="left", padx=20) |
| self.frame.pack(side="bottom", padx=20, pady=20) |
| |
| def update(self, *_) -> None: # type: ignore |
| self.state.filter_tree( |
| self.filter_value.get(), self.checkbox_value.get() |
| ) |
| |
| |
| class Window: |
| def __init__(self) -> None: |
| w = tk.Tk() |
| w.title("CTF Test Disabler") |
| w.geometry("1000x600") |
| w.update_idletasks() |
| # w.geometry(f"800x{w.winfo_reqheight()}") |
| self.window = w |
| |
| def mainloop(self) -> None: |
| self.window.mainloop() |