Skip to content

Commit

Permalink
FEATURE: Add dynamic filtered parameter name suggestions to the add p…
Browse files Browse the repository at this point in the history
…arameter button
  • Loading branch information
amilcarlucas committed Aug 29, 2024
1 parent ea9baa5 commit 776a86a
Show file tree
Hide file tree
Showing 2 changed files with 344 additions and 10 deletions.
288 changes: 288 additions & 0 deletions MethodicConfigurator/frontend_tkinter_entry_dynamic.py
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>
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("<Up>", self._previous)
self.bind("<Down>", self._next)
self.bind('<Control-n>', self._next)
self.bind('<Control-p>', self._previous)

self.bind("<Return>", self.update_entry_from_listbox)
self.bind("<Escape>", 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("<ButtonRelease-1>", self.update_entry_from_listbox)
self._listbox.bind("<Return>", self.update_entry_from_listbox)
self._listbox.bind("<Escape>", lambda event: self.unpost_listbox())

self._listbox.bind('<Control-n>', self._next)
self._listbox.bind('<Control-p>', 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()
66 changes: 56 additions & 10 deletions MethodicConfigurator/frontend_tkinter_parameter_editor_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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("<Return>", custom_selection_handler)
parameter_name_combobox.bind("<<ComboboxSelected>>", 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):
Expand Down

0 comments on commit 776a86a

Please sign in to comment.