From 776a86ae054228104050d0d44ecea988ea90bfa4 Mon Sep 17 00:00:00 2001 From: "Dr.-Ing. Amilcar do Carmo Lucas" Date: Sat, 3 Aug 2024 16:32:30 +0200 Subject: [PATCH] FEATURE: Add dynamic filtered parameter name suggestions to the add parameter button --- .../frontend_tkinter_entry_dynamic.py | 288 ++++++++++++++++++ ...frontend_tkinter_parameter_editor_table.py | 66 +++- 2 files changed, 344 insertions(+), 10 deletions(-) create mode 100644 MethodicConfigurator/frontend_tkinter_entry_dynamic.py diff --git a/MethodicConfigurator/frontend_tkinter_entry_dynamic.py b/MethodicConfigurator/frontend_tkinter_entry_dynamic.py new file mode 100644 index 0000000..875c363 --- /dev/null +++ b/MethodicConfigurator/frontend_tkinter_entry_dynamic.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 + +''' +This file is part of Ardupilot methodic configurator. https://github.com/ArduPilot/MethodicConfigurator + +https://code.activestate.com/recipes/580770-combobox-autocomplete/ + +SPDX-FileCopyrightText: 2024 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +''' + + +from tkinter import StringVar, Entry, Listbox +from tkinter.constants import END, HORIZONTAL, N, S, E, W, VERTICAL, SINGLE + +import tkinter as tk +from tkinter import ttk + + +def autoscroll(sbar, first, last): + """Hide and show scrollbar as needed.""" + first, last = float(first), float(last) + if first <= 0 and last >= 1: + sbar.grid_remove() + else: + sbar.grid() + sbar.set(first, last) + + +class EntryWithDynamicalyFilteredListbox(Entry): # pylint: disable=too-many-ancestors, too-many-instance-attributes + """ + Entry with dynamicaly filtered ListBox to emulate an inteligent combobox widget + """ + def __init__(self, master, list_of_items=None, custom_filter_function=None, listbox_width=None, listbox_height=12, # pylint: disable=too-many-arguments + ignorecase_match=False, startswith_match=True, vscrollbar=True, hscrollbar=True, **kwargs): + if list_of_items is None: + raise ValueError("List_of_items can't be 'None'") + self._list_of_items = list_of_items + + self.filter_function = custom_filter_function if custom_filter_function else self.default_filter_function + + self._listbox_width = listbox_width + self._listbox_height = int(listbox_height) + self._ignorecase_match = ignorecase_match + self._startswith_match = startswith_match + self._use_vscrollbar = vscrollbar + self._use_hscrollbar = hscrollbar + + kwargs.setdefault("background", "white") + + if "textvariable" in kwargs: + self._entry_var = kwargs["textvariable"] + else: + self._entry_var = kwargs["textvariable"] = StringVar() + + Entry.__init__(self, master, **kwargs) + + self._trace_id = self._entry_var.trace_add('write', self._on_change_entry_var) + + self._listbox = None + + self.bind("", self._previous) + self.bind("", self._next) + self.bind('', self._next) + self.bind('', self._previous) + + self.bind("", self.update_entry_from_listbox) + self.bind("", lambda event: self.unpost_listbox()) + + def default_filter_function(self, entry_data): + if self._ignorecase_match: + if self._startswith_match: + return [item for item in self._list_of_items if item.lower().startswith(entry_data.lower())] + return [item for item in self._list_of_items if entry_data.lower() in item.lower()] + if self._startswith_match: + return [item for item in self._list_of_items if item.startswith(entry_data)] + return [item for item in self._list_of_items if entry_data in item] + + def _on_change_entry_var(self, _name, _index, _mode): + + entry_data = self._entry_var.get() + + if entry_data == '': + self.unpost_listbox() + self.focus() + else: + values = self.filter_function(entry_data) + if values: + if self._listbox is None: + self._build_listbox(values) + else: + self._listbox.delete(0, END) + + height = min(self._listbox_height, len(values)) + self._listbox.configure(height=height) + + for item in values: + self._listbox.insert(END, item) + + else: + self.unpost_listbox() + self.focus() + + def _build_listbox(self, values): + listbox_frame = ttk.Frame(self.master) + + self._listbox = Listbox(listbox_frame, background="white", selectmode=SINGLE, activestyle="none", + exportselection=False) + self._listbox.grid(row=0, column=0,sticky = N+E+W+S) + + self._listbox.bind("", self.update_entry_from_listbox) + self._listbox.bind("", self.update_entry_from_listbox) + self._listbox.bind("", lambda event: self.unpost_listbox()) + + self._listbox.bind('', self._next) + self._listbox.bind('', self._previous) + + if self._use_vscrollbar: + vbar = ttk.Scrollbar(listbox_frame, orient=VERTICAL, command= self._listbox.yview) + vbar.grid(row=0, column=1, sticky=N+S) + + self._listbox.configure(yscrollcommand= lambda first, last: autoscroll(vbar, first, last)) + + if self._use_hscrollbar: + hbar = ttk.Scrollbar(listbox_frame, orient=HORIZONTAL, command= self._listbox.xview) + hbar.grid(row=1, column=0, sticky=E+W) + + self._listbox.configure(xscrollcommand= lambda first, last: autoscroll(hbar, first, last)) + + listbox_frame.grid_columnconfigure(0, weight= 1) + listbox_frame.grid_rowconfigure(0, weight= 1) + + x = -self.cget("borderwidth") - self.cget("highlightthickness") + y = self.winfo_height()-self.cget("borderwidth") - self.cget("highlightthickness") + + if self._listbox_width: + width = self._listbox_width + else: + width=self.winfo_width() + + listbox_frame.place(in_=self, x=x, y=y, width=width) + + height = min(self._listbox_height, len(values)) + self._listbox.configure(height=height) + + for item in values: + self._listbox.insert(END, item) + + def post_listbox(self): + if self._listbox is not None: + return + + entry_data = self._entry_var.get() + if entry_data == '': + return + + values = self.filter_function(entry_data) + if values: + self._build_listbox(values) + + def unpost_listbox(self): + if self._listbox is not None: + self._listbox.master.destroy() + self._listbox = None + + def get_value(self): + return self._entry_var.get() + + def set_value(self, text, close_dialog=False): + self._set_var(text) + + if close_dialog: + self.unpost_listbox() + + self.icursor(END) + self.xview_moveto(1.0) + + def _set_var(self, text): + self._entry_var.trace_remove("write", self._trace_id) + self._entry_var.set(text) + self._trace_id = self._entry_var.trace_add('write', self._on_change_entry_var) + + def update_entry_from_listbox(self, _event): + if self._listbox is not None: + current_selection = self._listbox.curselection() + + if current_selection: + text = self._listbox.get(current_selection) + self._set_var(text) + + self._listbox.master.destroy() + self._listbox = None + + self.focus() + self.icursor(END) + self.xview_moveto(1.0) + + return "break" + + def _previous(self, _event): + if self._listbox is not None: + current_selection = self._listbox.curselection() + + if len(current_selection)==0: + self._listbox.selection_set(0) + self._listbox.activate(0) + else: + index = int(current_selection[0]) + self._listbox.selection_clear(index) + + if index == 0: + index = END + else: + index -= 1 + + self._listbox.see(index) + self._listbox.selection_set(first=index) + self._listbox.activate(index) + + return "break" + + def _next(self, _event): + if self._listbox is not None: + + current_selection = self._listbox.curselection() + if len(current_selection)==0: + self._listbox.selection_set(0) + self._listbox.activate(0) + else: + index = int(current_selection[0]) + self._listbox.selection_clear(index) + + if index == self._listbox.size() - 1: + index = 0 + else: + index +=1 + + self._listbox.see(index) + self._listbox.selection_set(index) + self._listbox.activate(index) + return "break" + +if __name__ == '__main__': + def main(): + + list_of_items = [ + "Cordell Cannata", "Lacey Naples", "Zachery Manigault", "Regan Brunt", + "Mario Hilgefort", "Austin Phong", "Moises Saum", "Willy Neill", + "Rosendo Sokoloff", "Salley Christenberry", "Toby Schneller", + "Angel Buchwald", "Nestor Criger", "Arie Jozwiak", "Nita Montelongo", + "Clemencia Okane", "Alison Scaggs", "Von Petrella", "Glennie Gurley", + "Jamar Callender", "Titus Wenrich", "Chadwick Liedtke", "Sharlene Yochum", + "Leonida Mutchler", "Duane Pickett", "Morton Brackins", "Ervin Trundy", + "Antony Orwig", "Audrea Yutzy", "Michal Hepp", "Annelle Hoadley", + "Hank Wyman", "Mika Fernandez", "Elisa Legendre", "Sade Nicolson", + "Jessie Yi", "Forrest Mooneyhan", "Alvin Widell", "Lizette Ruppe", + "Marguerita Pilarski", "Merna Argento", "Jess Daquila", "Breann Bevans", + "Melvin Guidry", "Jacelyn Vanleer", "Jerome Riendeau", "Iraida Nyquist", + "Micah Glantz", "Dorene Waldrip", "Fidel Garey", "Vertie Deady", + "Rosalinda Odegaard", "Chong Hayner", "Candida Palazzolo", "Bennie Faison", + "Nova Bunkley", "Francis Buckwalter", "Georgianne Espinal", "Karleen Dockins", + "Hertha Lucus", "Ike Alberty", "Deangelo Revelle", "Juli Gallup", + "Wendie Eisner", "Khalilah Travers", "Rex Outman", "Anabel King", + "Lorelei Tardiff", "Pablo Berkey", "Mariel Tutino", "Leigh Marciano", + "Ok Nadeau", "Zachary Antrim", "Chun Matthew", "Golden Keniston", + "Anthony Johson", "Rossana Ahlstrom", "Amado Schluter", "Delila Lovelady", + "Josef Belle", "Leif Negrete", "Alec Doss", "Darryl Stryker", + "Michael Cagley", "Sabina Alejo", "Delana Mewborn", "Aurelio Crouch", + "Ashlie Shulman", "Danielle Conlan", "Randal Donnell", "Rheba Anzalone", + "Lilian Truax", "Weston Quarterman", "Britt Brunt", "Leonie Corbett", + "Monika Gamet", "Ingeborg Bello", "Angelique Zhang", "Santiago Thibeau", + "Eliseo Helmuth" + ] + + root = tk.Tk() + root.geometry("300x300") + + combobox_autocomplete = EntryWithDynamicalyFilteredListbox(root, list_of_items=list_of_items, + ignorecase_match=True, startswith_match=False) + + combobox_autocomplete.pack() + + combobox_autocomplete.focus() + + root.mainloop() + + main() diff --git a/MethodicConfigurator/frontend_tkinter_parameter_editor_table.py b/MethodicConfigurator/frontend_tkinter_parameter_editor_table.py index e980e95..b4c15a2 100644 --- a/MethodicConfigurator/frontend_tkinter_parameter_editor_table.py +++ b/MethodicConfigurator/frontend_tkinter_parameter_editor_table.py @@ -13,7 +13,6 @@ import tkinter as tk from tkinter import messagebox from tkinter import ttk -from tkinter import simpledialog from logging import debug as logging_debug from logging import info as logging_info @@ -32,9 +31,12 @@ #from MethodicConfigurator.frontend_tkinter_base import AutoResizeCombobox from MethodicConfigurator.frontend_tkinter_base import ScrollFrame from MethodicConfigurator.frontend_tkinter_base import get_widget_font +from MethodicConfigurator.frontend_tkinter_base import BaseWindow from MethodicConfigurator.frontend_tkinter_connection_selection import PairTupleCombobox +from MethodicConfigurator.frontend_tkinter_entry_dynamic import EntryWithDynamicalyFilteredListbox + from MethodicConfigurator.annotate_params import Par @@ -423,32 +425,76 @@ def __on_parameter_delete(self, param_name): self.parameter_editor.repopulate_parameter_table(self.current_file) def __on_parameter_add(self, fc_parameters): + add_parameter_window = BaseWindow(self.root) + add_parameter_window.root.title("Add Parameter to " + self.current_file) + add_parameter_window.root.geometry("450x300") + + # Label for instruction + instruction_label = ttk.Label(add_parameter_window.main_frame, text="Enter the parameter name to add:") + instruction_label.pack(pady=5) + + if self.local_filesystem.doc_dict: + param_dict = self.local_filesystem.doc_dict + else: + param_dict = fc_parameters + + if not param_dict: + messagebox.showerror("Operation not possible", + "No apm.pdef.xml file and no FC connected. Not possible autocomplete parameter names.") + return + + # Remove the parameters that are already displayed in this configuration step + possible_add_param_names = [param_name for param_name in param_dict \ + if param_name not in self.local_filesystem.file_parameters[self.current_file]] + + possible_add_param_names.sort() + # Prompt the user for a parameter name - param_name = simpledialog.askstring("New parameter name", "Enter new parameter name:") + parameter_name_combobox = EntryWithDynamicalyFilteredListbox(add_parameter_window.main_frame, + possible_add_param_names, + startswith_match=False, ignorecase_match=True, + listbox_height=12, width=28) + parameter_name_combobox.pack(padx=5, pady=5) + BaseWindow.center_window(add_parameter_window.root, self.root) + parameter_name_combobox.focus() + + def custom_selection_handler(event): + parameter_name_combobox.update_entry_from_listbox(event) + if self.__confirm_parameter_addition(parameter_name_combobox.get().upper(), fc_parameters): + add_parameter_window.root.destroy() + else: + add_parameter_window.root.focus() + + # Bindings to handle Enter press and selection while respecting original functionalities + parameter_name_combobox.bind("", custom_selection_handler) + parameter_name_combobox.bind("<>", custom_selection_handler) + + def __confirm_parameter_addition(self, param_name: str, fc_parameters: dict) -> bool: if not param_name: - messagebox.showerror("Parameter name can not be empty.") - return + messagebox.showerror("Invalid parameter name.", "Parameter name can not be empty.") + return False if param_name in self.local_filesystem.file_parameters[self.current_file]: - messagebox.showerror("Parameter already exists, edit it instead") - return + messagebox.showerror("Invalid parameter name.", "Parameter already exists, edit it instead") + return False if fc_parameters: if param_name in fc_parameters: self.local_filesystem.file_parameters[self.current_file][param_name] = Par(fc_parameters[param_name], "") self.at_least_one_param_edited = True self.parameter_editor.repopulate_parameter_table(self.current_file) - else: - messagebox.showerror("Invalid parameter name.", "Parameter name not found in the flight controller.") + return True + messagebox.showerror("Invalid parameter name.", "Parameter name not found in the flight controller.") elif self.local_filesystem.doc_dict: if param_name in self.local_filesystem.doc_dict: self.local_filesystem.file_parameters[self.current_file][param_name] = Par( \ self.local_filesystem.param_default_dict.get(param_name, Par(0, "")).value, "") self.at_least_one_param_edited = True self.parameter_editor.repopulate_parameter_table(self.current_file) - else: - messagebox.showerror("Invalid parameter name.", "Parameter name not found in the apm.pdef.xml file.") + return True + messagebox.showerror("Invalid parameter name.", f"'{param_name}' not found in the apm.pdef.xml file.", ) else: messagebox.showerror("Operation not possible", "Can not add parameter when no FC is connected and no apm.pdef.xml file exists.") + return False def __on_parameter_value_change(self, event, current_file, param_name):