|
Post by Uncle Buddy on Mar 26, 2021 5:34:18 GMT -8
I've been working on this for months, off and on. I think I've finally gotten it right.
EDIT: Since posting this on March 26, 2021, I've made several minor improvements that became apparent only when trying to apply the Combobox in a more complex situation than what I'd already tested. I also found this diatribe on why I'm creating a Combobox when tkinter already has ttk.Combobox. The improved code will be posted as a reply to this one.
The ttk.Combobox looks good and works OK but I have a few complaints, some of which depend on which Windows theme you're using:
1) It's ttk so you have to stand on your head to style it. The ttk widgets were designed to look more modern and to enforce Windows themes, so some options expected from tkinter widgets are not available. But each Windows theme ignores a different subset of options. The tkinter documentation and expected error messages functionality kinda fizzles out around the time some well-meaning folks decided to "modernize" tkinter by adding ttk. So ttk.Style often does nothing and the whole idea of trying to configure ttk widgets quickly becomes a waste of time--especially when configuring tk widgets by class is easy. When they added a Combobox and a Notebook to Tkinter, they should have added them to Tkinter and made a ttk version for people who like to work twice as hard to style widgets to look like what was considered modern when ttk was born.
2) You can't bind to an event in the dropdown so you have to use Return key (not spacebar) to select a highlighted widget because that's all the creators of ttk gave us. Since the items in the dropdown are like buttons, I expect them to work like tkinter Buttons: I want to be able to select the highlighted item with the spacebar.
3) The dropdown list doesn't drop down unless you click that teeny arrow.
4) On focus-in the only way you know you're focused into the combo is if you notice the background color changed on that teeny arrow. If I recall, if you want the arrow to highlight on focus-in, you have to tell it to.
5) If the combobox is close to the bottom of the screen, the dropdown will fly up instead of down, which is correct, but the Down key has to be used to make it fly up which is anti-intuitive. The Up OR Down key should always open the dropdown whether it needs to fly up or fly down.
6) When using arrows to traverse the dropdown list, when it gets to the frame or bottom the traversal stops instead of going around to the other end.
So basically the ttk.Combobox feels like unfinished work like other ttk widgets when you really try to do something with it.
By the way, the ttk.Combobox dropdown is a tk.Listbox. The Listbox is one of the few tk widgets I've neglected to use because using it is weird, inconsistent with using the other tk widgets, complicated. Last time I tried to incorporate a tk.Listbox into a normal GUI I found that it caused its accomanying Text widget to act strangely, and hours of research didn't find a solution. So I don't know why the makers of ttk married the cumbersome tk.Listbox to the tk.Entry to create the ttk.Combobox when they could have just written some custom listbox code and given it a lot more flexibility.
|
|
|
Post by Uncle Buddy on Mar 27, 2021 0:18:54 GMT -8
Here's a simplified improvement of the Toykinter Combobox. Main change here is getting rid of the self.height parameter which was unneeded and allowed for getting rid of some nasty hacks. See it in context at the github repo at ProfessorUdGuru (replaced 2022-08-24)
class Combobox(FrameHilited3): hive = [] formats = formats highlight_bg = formats["highlight_bg"] head_bg = formats["head_bg"] bg = formats["bg"]
def __init__( self, master, root, callback=None, values=[], scrollbar_size=24, *args, **kwargs): FrameHilited3.__init__(self, master, *args, **kwargs) ''' This is a replacement for ttk.Combobox. '''
self.master = master self.callback = callback self.root = root self.values = values self.scrollbar_size = scrollbar_size
self.buttons = [] self.selected = None self.result_string = ''
self.entered = None self.lenval = len(self.values) self.owt = None self.scrollbar_clicked = False self.typed = None
self.screen_height = self.winfo_screenheight() self.config(bd=0)
# simulate <<ComboboxSelected>>: self.var = tk.StringVar() self.var.trace_add('write', lambda *args, **kwargs: self.combobox_selected())
# simulate ttk.Combobox.current() self.current = 0
self.make_widgets() self.master.bind_all('<ButtonRelease-1>', self.close_dropdown, add='+')
# self.root.bind('<Configure>', self.hide_all_drops) # DO NOT DELETE # Above binding closes dropdown if Windows title bar is clicked, it # has no other purpose. But it causes minor glitches e.g. if a # dropdown button is highlighted and focused, the Entry has to be # clicked twice to put it back into the alternating drop/undrop # cycle as expected. Without this binding, the click on the title # bar lowers the dropdown below the root window which is good # enough for now. To get around it, use the Border class in # window_border.py instead of the built-in Windows title bar that # comes with Tkinter.
# expose only unique methods of Entry e.g. not self.config (self is a # Frame and the Entry, Toplevel, Canvas, and window have to be # configured together) so to size the entry use # instance.config_drop_width(72) self.insert = self.entry.insert self.delete = self.entry.delete self.get = self.entry.get
def make_widgets(self): self.entry = Entry(self, textvariable=self.var) self.arrow = ComboArrow(self, text='\u25BC', width=2)
self.entry.grid(column=0, row=0) self.arrow.grid(column=1, row=0)
self.update_idletasks() self.width = self.winfo_reqwidth()
self.drop = ToplevelHilited(self, bd=0) self.drop.bind('<Destroy>', self.clear_reference_to_dropdown) self.drop.withdraw() Combobox.hive.append(self.drop) for widg in (self.master, self.drop): widg.bind('<Escape>', self.hide_all_drops, add='+')
self.drop.columnconfigure(0, weight=1) self.drop.rowconfigure(0, weight=1)
self.canvas = CanvasHilited(self.drop) self.canvas.grid(column=0, row=0, sticky='news')
self.scrollv_combo = Scrollbar( self.drop, hideable=True, command=self.canvas.yview, width=self.scrollbar_size) self.canvas.config(yscrollcommand=self.scrollv_combo.set) self.content = Frame(self.canvas)
self.host_width = self.winfo_reqwidth() self.window = self.canvas.create_window( 0, 0, anchor='nw', window=self.content, width=self.host_width)
self.content.columnconfigure(0, weight=1) self.content.rowconfigure('all', weight=1) self.scrollv_combo.grid(column=1, row=0, sticky='ns')
self.entry.bind('<KeyPress>', self.open_or_close_dropdown) self.entry.bind('<Tab>', self.open_or_close_dropdown)
for widg in (self.entry, self.arrow): widg.bind('<Button-1>', self.open_or_close_dropdown, add='+') self.arrow.bind('<Button-1>', self.focus_entry_on_arrow_click, add='+') for frm in (self, self.content): """ Bind arrow events to frames to make arrow label highlight on click or focus in. I don't remember why it had to be done this way, but changing it to run a local callback breaks the highlighting. """ frm.bind('<FocusIn>', self.arrow.highlight_arrow) frm.bind('<FocusOut>', self.arrow.unhighlight_arrow)
self.drop.bind('<FocusIn>', self.focus_dropdown) self.drop.bind('<Unmap>', self.unhighlight_all_drop_items)
self.current_combo_parts = [self, self.entry, self.arrow, self.scrollv_combo] for part in self.current_combo_parts: part.bind('<Enter>', self.unbind_combo_parts, add="+") part.bind('<Leave>', self.rebind_combo_parts, add="+") self.config_values(self.values)
configall(self.drop, Combobox.formats)
def unbind_combo_parts(self, evt): self.master.unbind_all('<ButtonRelease-1>')
def rebind_combo_parts(self, evt): self.master.bind_all('<ButtonRelease-1>', self.close_dropdown, add='+')
def unhighlight_all_drop_items(self, evt): for child in self.content.winfo_children(): child.config(bg=Combobox.highlight_bg)
def clear_reference_to_dropdown(self, evt): dropdown = evt.widget if dropdown in Combobox.hive: idx = Combobox.hive.index(dropdown) del Combobox.hive[idx] dropdown = None
def config_values(self, values): ''' The vertical scrollbar, when there is one, overlaps the dropdown button highlight but both still work. To change this, the button width can be changed when the scrollbar appears and disappears. ''' # Make a sample button to get its height. b = ButtonFlatHilited(self.content, text='Sample') one_height = b.winfo_reqheight() b.destroy() self.fit_height = one_height * len(values)
self.values = values self.lenval = len(self.values)
for button in self.buttons: button.destroy() self.buttons = []
self.host_width = self.winfo_reqwidth() self.canvas.config(scrollregion=(0, 0, self.host_width, self.fit_height))
c = 0 for item in values: bt = ButtonFlatHilited(self.content, text=item, anchor='w') bt.grid(column=0, row=c, sticky='ew') for event in ('<Button-1>', '<Return>', '<space>'): bt.bind(event, self.get_clicked, add='+') bt.bind('<Enter>', self.highlight_button) bt.bind('<Leave>', self.unhighlight_button) bt.bind('<Tab>', self.tab_out_of_dropdown_fwd) bt.bind('<Shift-Tab>', self.tab_out_of_dropdown_back) bt.bind('<KeyPress>', self.traverse_on_arrow) bt.bind('<FocusOut>', self.unhighlight_button) bt.bind('<FocusOut>', self.get_tip_widg, add='+') bt.bind('<FocusIn>', self.get_tip_widg) bt.bind('<Enter>', self.get_tip_widg, add='+') bt.bind('<Leave>', self.get_tip_widg, add='+') self.buttons.append(bt) c += 1 for b in self.buttons: b.config(command=self.callback)
def get_tip_widg(self, evt): ''' '10' is FocusOut, '9' is FocusIn ''' if self.winfo_reqwidth() <= evt.widget.winfo_reqwidth(): widg = evt.widget evt_type = evt.type if evt_type in ('7', '9'): self.show_overwidth_tip(widg) elif evt_type in ('8', '10'): self.hide_overwidth_tip()
def show_overwidth_tip(self, widg): ''' Instead of a horizontal scrollbar, if a dropdown item doesn't all show in the space allotted, the full text will appear in a tooltip on highlight. Most of this code is borrowed from Michael Foord. ''' text=widg.cget('text') if self.owt: return x, y, cx, cy = widg.bbox() x = x + widg.winfo_rootx() + 32 y = y + cy + widg.winfo_rooty() + 32 self.owt = ToplevelHilited(self) self.owt.wm_overrideredirect(1) l = LabelTip2(self.owt, text=text) l.pack(ipadx=6, ipady=3) self.owt.wm_geometry('+{}+{}'.format(x, y))
def hide_overwidth_tip(self): tip = self.owt self.owt = None if tip: tip.destroy()
def focus_entry_on_arrow_click(self, evt): self.focus_set() self.entry.select_range(0, 'end')
def hide_other_drops(self): for dropdown in Combobox.hive: if dropdown != self.drop: dropdown.withdraw()
def hide_all_drops(self, evt=None): for dropdown in Combobox.hive: dropdown.withdraw()
def close_dropdown(self, evt): ''' Runs only on ButtonRelease-1.
In the case of a destroyable combobox in a dialog, after the combobox is destroyed, this event will cause an error because the dropdown no longer exists. I think this is harmless so I added the try/except to pass on it instead of figuring out how to prevent the error. ''' widg = evt.widget if widg == self.scrollv_combo: self.scrollbar_clicked = True try: self.drop.withdraw() except tk.TclError: pass
def config_drop_width(self, new_width): """ If running manually, it has to run after config_values(). """ self.entry.config(width=new_width) self.update_idletasks() self.width = self.winfo_reqwidth() self.drop.geometry('{}x{}'.format(self.width, self.fit_height)) self.scrollregion_width = new_width self.canvas.itemconfigure(self.window, width=self.width) self.canvas.configure(scrollregion=(0, 0, new_width, self.fit_height))
def open_or_close_dropdown(self, evt=None): if evt is None: # dropdown item clicked--no evt because of Button command option if self.callback: self.callback(self.selected) self.drop.withdraw() return if len(self.buttons) == 0: return evt_type = evt.type evt_sym = evt.keysym if evt_sym == 'Tab': self.drop.withdraw() return elif evt_sym == 'Escape': self.hide_all_drops() return first = None last = None if len(self.buttons) != 0: first = self.buttons[0] last = self.buttons[len(self.buttons) - 1] # self.drop.winfo_ismapped() gets the wrong value # if the scrollbar was the last thing clicked # so drop_is_open has to be used also. if evt_type == '4': if self.drop.winfo_ismapped() == 1: drop_is_open = True elif self.drop.winfo_ismapped() == 0: drop_is_open = False if self.scrollbar_clicked is True: drop_is_open = True self.scrollbar_clicked = False if drop_is_open is True: self.drop.withdraw() drop_is_open = False return elif drop_is_open is False: pass elif evt_type == '2': if evt_sym not in ('Up', 'Down'): return elif first is None or last is None: pass elif evt_sym == 'Down': first.config(bg=Combobox.bg) first.focus_set() self.canvas.yview_moveto(0.0) elif evt_sym == 'Up': last.config(bg=Combobox.bg) last.focus_set() self.canvas.yview_moveto(1.0)
self.update_idletasks() x = self.winfo_rootx() y = self.winfo_rooty() combo_height = self.winfo_reqheight()
self.fit_height = self.content.winfo_reqheight() self.drop.wm_overrideredirect(1) fly_up = self.get_vertical_pos(combo_height, evt) if fly_up[0] is False: y = y + combo_height else: y = fly_up[1] self.drop.geometry('{}x{}+{}+{}'.format( self.width, self.fit_height, x, y)) self.drop.deiconify() self.hide_other_drops()
def get_vertical_pos(self, combo_height, evt): fly_up = False vert_pos = evt.y_root - evt.y clearance = self.screen_height - (vert_pos + combo_height) if clearance < self.fit_height: fly_up = True
return (fly_up, vert_pos - self.fit_height)
def highlight_button(self, evt): for widg in self.buttons: widg.config(bg=Combobox.highlight_bg) widget = evt.widget widget.config(bg=Combobox.bg) self.selected = widget widget.focus_set()
def unhighlight_button(self, evt): x, y = self.winfo_pointerxy() hovered = self.winfo_containing(x,y) if hovered in self.buttons: evt.widget.config(bg=Combobox.highlight_bg)
def hide_drops_on_title_bar_click(self, evt): x, y = self.winfo_pointerxy() hovered = self.winfo_containing(x,y)
def focus_dropdown(self, evt): for widg in self.buttons: widg.config(takefocus=1)
def handle_tab_out_of_dropdown(self, go):
for widg in self.buttons: widg.config(takefocus=0)
self.entry.delete(0, 'end') self.entry.insert(0, self.selected.cget('text')) self.drop.withdraw() self.entry.focus_set() if go == 'fwd': goto = self.entry.tk_focusNext() elif go == 'back': goto = self.entry.tk_focusPrev() goto.focus_set()
def tab_out_of_dropdown_fwd(self, evt): self.selected = evt.widget self.handle_tab_out_of_dropdown('fwd')
def tab_out_of_dropdown_back(self, evt): self.selected = evt.widget self.handle_tab_out_of_dropdown('back')
def get_clicked(self, evt):
self.selected = evt.widget self.current = self.selected.grid_info()['row'] self.entry.delete(0, 'end') self.entry.insert(0, self.selected.cget('text')) self.entry.select_range(0, 'end') self.open_or_close_dropdown()
def get_typed(self): self.typed = self.var.get()
def highlight_on_traverse(self, evt, next_item=None, prev_item=None):
evt_type = evt.type evt_sym = evt.keysym # 2 is key press, 4 is button press
for widg in self.buttons: widg.config(bg=Combobox.highlight_bg) if evt_type == '4': self.selected = evt.widget elif evt_type == '2' and evt_sym == 'Down': self.selected = next_item elif evt_type == '2' and evt_sym == 'Up': self.selected = prev_item
self.selected.config(bg=Combobox.bg) self.widg_height = int(self.fit_height / self.lenval) widg_screenpos = self.selected.winfo_rooty() widg_listpos = self.selected.winfo_y() win_top = self.drop.winfo_rooty() win_bottom = win_top + self.fit_height list_ratio = widg_listpos / self.fit_height widg_ratio = self.widg_height / self.fit_height up_ratio = list_ratio - widg_ratio
if widg_screenpos > win_bottom - 0.75 * self.widg_height: self.canvas.yview_moveto(float(list_ratio)) elif widg_screenpos < win_top: self.canvas.yview_moveto(float(up_ratio)) self.selected.focus_set()
def traverse_on_arrow(self, evt): if evt.keysym not in ('Up', 'Down'): return widg = evt.widget sym = evt.keysym self.widg_height = int(self.fit_height / self.lenval) self.update_idletasks() next_item = widg.tk_focusNext() prev_item = widg.tk_focusPrev()
if sym == 'Down': if next_item in self.buttons: self.highlight_on_traverse(evt, next_item=next_item) else: next_item = self.buttons[0] next_item.focus_set() next_item.config(bg=Combobox.bg) self.canvas.yview_moveto(0.0)
elif sym == 'Up': if prev_item in self.buttons: self.highlight_on_traverse(evt, prev_item=prev_item) else: prev_item = self.buttons[self.lenval-1] prev_item.focus_set() prev_item.config(bg=Combobox.bg) self.canvas.yview_moveto(1.0)
def callback(self): ''' A function specified on instantiation. ''' # print('this will not print if overridden (callback)') pass
def combobox_selected(self): ''' A function specified on instantiation will run when the selection is made. Similar to ttk's <<ComboboxSelected>> but instead of binding to a virtual event. ''' # print('this will not print if overridden (combobox_selected)') pass
class ComboArrow(Labelx): head_bg = formats["head_bg"] fg = formats["fg"] output_font = formats["output_font"] highlight_bg = formats["highlight_bg"] def __init__(self, master, *args, **kwargs): Labelx.__init__(self, master, *args, **kwargs) self.config( bg=ComboArrow.highlight_bg, fg=ComboArrow.fg, font=ComboArrow.output_font)
def highlight_arrow(self, evt): self.config(bg=ComboArrow.head_bg)
def unhighlight_arrow(self, evt): self.config(bg=ComboArrow.highlight_bg)
|
|