Python ttk.combobox 强制发布/打开

问题描述 投票:0回答:3

我正在尝试扩展 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
python python-3.x combobox tkinter ttk
3个回答
2
投票

下面演示了使用工具提示实现此用户体验的解决方法。这是使用

PySimpleGUI
实现的,但应该很容易适应“纯”tkinter。

Resulting UI/UX

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()

该解决方案的灵感来自于这个要点这个讨论


1
投票

该事件不需要继承ttk.Combobox;只需使用 event_generate 强制下拉:

box = Combobox(...)
def callback(box):
    box.event_generate('<Down>')

0
投票

我知道这是一篇旧帖子,但认为它可能对人们有帮助。这是一个完整的 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()
© www.soinside.com 2019 - 2024. All rights reserved.