blob: e104a8955ca9f8c80ca7e247db6023860e379b86 [file] [log] [blame]
# 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()