我正在尝试扩展 ttk 组合框类以允许自动建议。我目前的代码运行良好,但我想让它在输入一些文本后显示下拉列表,而不从小部件的输入部分移除焦点。
我正在努力解决的部分是找到一种强制下拉的方法,在python文档中我找不到任何提及这一点的内容,但是在tk文档中我确实找到了一个我相信应该做到这一点的post方法,除了它没有似乎没有在 python 包装器中实现。
我还尝试在自动建议发生后生成向下箭头键事件,但是,虽然这确实显示了下拉菜单,但它会删除焦点,并且尝试在此事件后设置焦点似乎也不起作用(焦点不会返回)
有人知道我可以用来实现此目的的功能吗?
我的代码适用于仅使用标准库的 python 3.3:
class AutoCombobox(ttk.Combobox):
def __init__(self, parent, **options):
ttk.Combobox.__init__(self, parent, **options)
self.bind("<KeyRelease>", self.AutoComplete_1)
self.bind("<<ComboboxSelected>>", self.Cancel_Autocomplete)
self.bind("<Return>", self.Cancel_Autocomplete)
self.autoid = None
def Cancel_Autocomplete(self, event=None):
self.after_cancel(self.autoid)
def AutoComplete_1(self, event):
if self.autoid != None:
self.after_cancel(self.autoid)
if event.keysym in ["BackSpace", "Delete", "Return"]:
return
self.autoid = self.after(200, self.AutoComplete_2)
def AutoComplete_2(self):
data = self.get()
if data != "":
for entry in self["values"]:
match = True
try:
for index in range(0, len(data)):
if data[index] != entry[index]:
match = False
break
except IndexError:
match = False
if match == True:
self.set(entry)
self.selection_range(len(data), "end")
self.event_generate("<Down>",when="tail")
self.focus_set()
break
self.autoid = None
下面演示了使用工具提示实现此用户体验的解决方法。这是使用
PySimpleGUI
实现的,但应该很容易适应“纯”tkinter。
from functools import partial
from typing import Callable, Any
from fuzzywuzzy import process, fuzz
import PySimpleGUI as sg
# SG: Helper functions:
def clear_combo_tooltip(*_, ui_handle: sg.Element, **__) -> None:
if tt := ui_handle.TooltipObject:
tt.hidetip()
ui_handle.TooltipObject = None
def show_combo_tooltip(ui_handle: sg.Element, tooltip: str) -> None:
ui_handle.set_tooltip(tooltip)
tt = ui_handle.TooltipObject
tt.y += 40
tt.showtip()
def symbol_text_updated(event_data: dict[str, Any], all_values: list[str], ui_handle: sg.Element) -> None:
new_text = event_data[ui_handle.key]
if new_text == '':
ui_handle.update(values=all_values)
return
matches = process.extractBests(new_text, all_values, scorer=fuzz.ratio, score_cutoff=40)
sym = [m[0] for m in matches]
ui_handle.update(new_text, values=sym)
# tk.call('ttk::combobox::Post', ui_handle.widget) # This opens the list of options, but takes focus
clear_combo_tooltip(ui_handle=ui_handle)
show_combo_tooltip(ui_handle=ui_handle, tooltip="\n".join(sym))
# Prepare data:
all_symbols = ["AAPL", "AMZN", "MSFT", "TSLA", "GOOGL", "BRK.B", "UNH", "JNJ", "XOM", "JPM", "META", "PG", "NVDA", "KO"]
# SG: Layout
sg.theme('DarkAmber')
layout = [
[
sg.Text('Symbol:'),
sg.Combo(all_symbols, enable_per_char_events=True, key='-SYMBOL-')
]
]
# SG: Window
window = sg.Window('Symbol data:', layout, finalize=True)
window['-SYMBOL-'].bind("<Key-Down>", "KeyDown")
# SG: Event loop
callbacks: dict[str: Callable] = {
'-SYMBOL-': partial(symbol_text_updated, all_values=all_symbols, ui_handle=window['-SYMBOL-']),
'-SYMBOL-KeyDown': partial(clear_combo_tooltip, ui_handle=window['-SYMBOL-']),
}
unhandled_event_callback = partial(lambda x: print(f"Unhandled event key: {event}. Values: {x}"))
while True:
event, values = window.read()
if event in (sg.WIN_CLOSED, 'Exit'):
break
callbacks.get(event, unhandled_event_callback)(values)
# SG: Cleanup
window.close()
该事件不需要继承ttk.Combobox;只需使用 event_generate 强制下拉:
box = Combobox(...)
def callback(box):
box.event_generate('<Down>')
我知道这是一篇旧帖子,但认为它可能对人们有帮助。这是一个完整的 Tkinter 解决方案,我还添加了一些功能,例如光标定位。这样,如果用户移动光标,选择就会改变。我确信在某个地方有一个更优雅的解决方案,但这是我可以很快想出的。
import tkinter as tk
from tkinter import ttk
class AutoSuggestCombobox(ttk.Combobox):
def __init__(self, master=None, **kwargs):
super().__init__(master, **kwargs)
self._completion_list = []
self._hits = []
self._hit_index = 0
self.position = 0
self.bind('<KeyRelease>', self._handle_keyrelease)
self.bind('<FocusOut>', self._handle_focusout)
self.bind('<FocusIn>', self._handle_focusin)
self.bind('<Return>', self._handle_return) # bind Enter key
self.bind('<Down>', self._down_arrow) # bind Up arrow key
self.bind('<Up>', self._up_arrow)
self.bind('<Button-1>', self._handle_click) # bind mouse click
master.bind("<Button-1>", self._handle_root_click) # bind mouse click on root window
self._popup_menu = None
def set_completion_list(self, completion_list):
"""Set the list of possible completions."""
self._completion_list = sorted(completion_list)
self['values'] = self._completion_list
def _handle_keyrelease(self, event):
"""Handle key release events."""
value = self.get()
cursor_index = self.index(tk.INSERT)
if value == '':
self._hits = self._completion_list
else:
# Determine the word before the cursor
before_cursor = value[:cursor_index].rsplit(' ', 1)[-1]
# Filter suggestions based on the word before the cursor
self._hits = [item for item in self._completion_list if item.lower().startswith(before_cursor.lower())]
# Ignore Down and Up arrow key presses
if event.keysym in ['Down', 'Up', 'Return']:
return
if self._hits:
self._show_popup(self._hits)
def _show_popup(self, values):
"""Display the popup listbox."""
if self._popup_menu:
self._popup_menu.destroy()
self._popup_menu = tk.Toplevel(self)
self._popup_menu.wm_overrideredirect(True)
self._popup_menu.config(bg='black')
# Add a frame with a black background to create the border effect
popup_frame = tk.Frame(self._popup_menu, bg='gray10', borderwidth=0.1)
popup_frame.pack(padx=1, pady=(1, 1), fill='both', expand=True)
listbox = tk.Listbox(popup_frame, borderwidth=0, relief=tk.FLAT, bg='white', selectbackground='#0078d4', bd=0, highlightbackground='black')
scrollbar = ttk.Scrollbar(popup_frame, orient=tk.VERTICAL, command=listbox.yview)
listbox.config(yscrollcommand=scrollbar.set)
for value in values:
listbox.insert(tk.END, value)
listbox.bind("<ButtonRelease-1>", self._on_listbox_select)
listbox.bind("<FocusOut>", self._on_listbox_focusout)
listbox.bind("<Motion>", self._on_mouse_motion)
# Automatically select the first entry if no mouse hover has occurred yet
if not listbox.curselection():
listbox.selection_set(0)
listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
# Adjust popup width to match entry box
popup_width = self.winfo_width()
self._popup_menu.geometry(f"{popup_width}x165")
x = self.winfo_rootx()
y = self.winfo_rooty() + self.winfo_height()
self._popup_menu.geometry(f"+{x}+{y}")
def _on_listbox_select(self, event):
"""Select a value from the listbox."""
widget = event.widget
selection = widget.curselection()
if selection:
value = widget.get(selection[0])
self._select_value(value)
def _on_mouse_motion(self, event):
"""Handle mouse motion over the listbox."""
widget = event.widget
index = widget.nearest(event.y)
widget.selection_clear(0, tk.END)
widget.selection_set(index)
def _on_listbox_focusout(self, event):
"""Handle listbox losing focus."""
if self._popup_menu:
self._popup_menu.destroy()
self._popup_menu = None
def _select_value(self, value):
"""Select a value from the popup listbox."""
self.set(value)
self.icursor(tk.END) # Move cursor to the end
self.selection_range(0, tk.END) # Select entire text
if self._popup_menu:
self._popup_menu.destroy()
self._popup_menu = None
def _handle_focusout(self, event):
"""Handle focus out events."""
if self._popup_menu:
try:
if not self._popup_menu.winfo_containing(event.x_root, event.y_root):
self._popup_menu.destroy()
self._popup_menu = None
except tk.TclError:
pass
def _handle_focusin(self, event):
"""Handle focus in events."""
if self._popup_menu:
self._popup_menu.destroy()
self._popup_menu = None
def _handle_return(self, event):
"""Handle Enter key press."""
if self._popup_menu:
listbox = self._popup_menu.winfo_children()[0].winfo_children()[0]
current_selection = listbox.curselection()
if current_selection:
value = listbox.get(current_selection[0])
self._select_value(value)
def _down_arrow(self, event):
"""Handle down arrow key press."""
if self._popup_menu:
listbox = self._popup_menu.winfo_children()[0].winfo_children()[0]
current_selection = listbox.curselection()
if current_selection:
current_index = current_selection[0]
next_index = (current_index + 1) % len(self._hits)
listbox.selection_clear(0, tk.END)
listbox.selection_set(next_index)
listbox.activate(next_index)
return 'break' # prevent default behavior
def _up_arrow(self, event):
"""Handle up arrow key press."""
if self._popup_menu:
listbox = self._popup_menu.winfo_children()[0].winfo_children()[0]
current_selection = listbox.curselection()
if current_selection:
current_index = current_selection[0]
next_index = (current_index - 1) % len(self._hits)
listbox.selection_clear(0, tk.END)
listbox.selection_set(next_index)
listbox.activate(next_index)
return 'break' # prevent default behavior
def _handle_click(self, event):
"""Handle mouse click events."""
value = self.get()
if value == '':
self._hits = self._completion_list
else:
self._hits = [item for item in self._completion_list if item.lower().startswith(value.lower())]
if self._hits:
self._show_popup(self._hits)
def _handle_root_click(self, event):
"""Handle mouse click events on root window."""
if self._popup_menu:
x, y = event.x_root, event.y_root
x0, y0, x1, y1 = self.winfo_rootx(), self.winfo_rooty(), self.winfo_rootx() + self.winfo_width(), self.winfo_rooty() + self.winfo_height()
if not (x0 <= x <= x1 and y0 <= y <= y1):
self._popup_menu.destroy()
self._popup_menu = None
这里有一个main函数来测试一下。
def main():
root = tk.Tk()
root.geometry("300x200")
label = ttk.Label(root, text="Type a fruit name:")
label.pack(pady=10)
fruits = ['Apple', 'Apricot', 'Avocado', 'Banana', 'Blackberry', 'Blueberry', 'Cherry', 'Coconut', 'Date', 'Dragonfruit', 'Grape', 'Kiwi', 'Lemon', 'Lime', 'Mango', 'Melon', 'Orange', 'Peach', 'Pear', 'Pineapple', 'Plum', 'Pomegranate', 'Raspberry', 'Strawberry', 'Watermelon']
combo = AutoSuggestCombobox(root)
combo.set_completion_list(fruits)
combo.pack(pady=10, padx=10)
root.mainloop()
if __name__ == '__main__':
main()