Custom combobox rewritten again
Jul 1, 2024 23:44:42 GMT -8
Post by Uncle Buddy on Jul 1, 2024 23:44:42 GMT -8
This amazing combobox won't run with the other code that's on the repo today (July 2, 2024) but next time I commit code to the repo on this forum, this new combobox will work with that code.
The coming repo commit represents a huge rewrite of many key features and the addition of some new features. Treebard is getting better every day.
The coming repo commit represents a huge rewrite of many key features and the addition of some new features. Treebard is getting better every day.
import tkinter as tk
from tkinter.font import Font
from timeit import default_timer as timer
from widgets import (
configall, make_formats_dict, FrameHilited2, Scrollbar, CanvasHilited, LabelHilited2)
import dev_tools as dt
from dev_tools import look, seeline
''' Not recommended for large values lists much over 100. It runs fine but after
a large dropdown closes, something else is taking time to process before the
next procedure will be handled. For example a click to open a different
combobox dropdown seems unresponsive but is just waiting for the large
dropdown to finish doing something behind the scenes after it's been closed.
I think I've tested the pertinent methods and it seems to be Tkinter
internals that is causing this.
'''
'''
Replaces ttk.Combobox with an easily configurable widget.
Configuration is done tkinter style, instead of pitting ttk.Style
and Windows themes against each other to see which one wins, as is
the norm when trying to configure ttk widgets. The ttk.Combobox uses
a tk.Listbox as its dropdown window, which is like hiring your ex-wife's
homeless brother to run your business.
Unlike ttk.Combobox...
...dropdown items are selected with mouse or spacebar
...colors including Entry background and dropdown background are easily
configured
...clicking either Entry or Arrow opens then closes dropdown on
alternate clicks
...FocusOut event can be bound to the dropdown items
...arrow traversal thru dropdown items loops to top or bottom when
bottom or top is reached
...dropdown opens with either top or bottom item highlighted depending
on whether Up or Down arrow key is used to open the dropdown
...a dropdown item with text longer than the window displays in
multiple lines.
This combobox was created by the authors of Treebard Genealogy Software, June
2024 after years of rewritings and revisions. The license is public domain, and
please give credit to the Treebard project whence this woundrous widget
laboriously sprang into existence.
'''
font = ("Courier", 12)
class ComboboxDropdown(tk.Toplevel):
''' This is only a widget constructor. All code for manipulating this
dropdown and its values goes in the `Combobox` class.
'''
def __init__(self, master, formats, *args, **kwargs):
tk.Toplevel.__init__(self, master, *args, **kwargs)
self["bd"] = 0
self.master = master
self.formats = formats
self.make_combobox_dropdown()
def make_combobox_dropdown(self):
""" Only one combobox shows a dropdown at a time so there's only one per
family tree. This is done by giving each family tree its own inherited
copy of this class, and creating the class's single dropdown in the
inherited classes only. So this class-level function is run only
in the inherited classes, not the parent `Combobox` class.
"""
def fill_canvas(evt):
""" Make the content frame expand. """
self.canvas.itemconfig(frame_id, width=evt.width)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.canvas = CanvasHilited(self, formats, bd=0, highlightthickness=0)
self.sbv = Scrollbar(self, formats=formats, command=self.canvas.yview)
self.canvas.config(yscrollcommand=self.sbv.set)
self.canvas.grid(column=0, row=0, sticky="news")
self.sbv.grid(column=1, row=0, sticky="ns")
self.content = FrameHilited2(self.canvas, self.formats, bd=0)
frame_id = self.canvas.create_window(0, 0, anchor='nw', window=self.content)
self.canvas.bind("<Configure>", fill_canvas) # IS THIS NEEDED*******************
self.wm_overrideredirect(1)
self.withdraw()
class Combobox(tk.Frame):
""" Only one combobox dropdown is shown at a time, so a strategy is needed
for closing a dropdown when another opens. This is accomplished most
easily by allowing only one dropdown to exist per family tree, so
instead of destroying and re-creating dropdown windows every time one
closes or opens, the same one is kept alive in the background and
reconfigured as needed.
"""
def __init__(
self, master, formats, tree, values=None, dialog_in=None, *args, **kwargs):
tk.Frame.__init__(self, master, *args, *kwargs)
self.master = master
self.formats = formats
self.tree = tree
self.values = values
self.dialog_in = dialog_in
self.screen_height = self.winfo_screenheight()
self.config(bd=0)
self.dropdown_is_open = False
self.selected = None
self.make_combodrop_and_variable()
if self.values:
self.lenval = len(self.values)
self.current = 0
self.make_widgets()
self.config_values()
self.master.bind('<ButtonRelease-1>', self.close_dropdown, add='+')
# self.focus_set = self.entry.focus_set() # Don't do this.
configall(self, self.formats)
def make_combodrop_and_variable(self):
self.dropdown = ComboboxDropdown(self, self.formats)
self.tree.comboboxes[self] = tk.StringVar()
def make_widgets(self):
self.entry = tk.Entry(self, textvariable=self.tree.comboboxes[self])
self.arrow = LabelHilited2(self, self.formats, text='\u25BC', width=2)
self.columnconfigure(0, weight=1)
self.entry.grid(column=0, row=0, sticky="ew")
self.arrow.grid(column=1, row=0, sticky="e")
self.tree.bind('<Escape>', self.hide_dropdown, add='+')
self.dropdown.bind('<FocusIn>', self.focus_dropdown)
self.dropdown.bind('<Unmap>', lambda evt, formats=self.formats:
self.unhighlight_all_drop_items(evt, formats))
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='+')
def format_comboboxes(self, new_formats=None):
""" Format all the widgets that are part of the composite Combobox
widget when `configall` runs, i.e. when the user changes the
color scheme.
"""
if new_formats:
self.formats = formats = new_formats
else:
formats = self.formats
self.entry["bg"] = formats["highlight_bg"]
self.arrow["bg"] = formats["head_bg"]
self.arrow["fg"] = formats["fg"]
self.dropdown["bg"] = formats["highlight_bg"]
for button in self.dropdown.content.winfo_children():
button["bg"] = formats["highlight_bg"]
button["fg"] = formats["fg"]
button["font"] = formats["font"]
button["activebackground"] = formats["fg"]
button["activeforeground"] = formats["bg"]
def open_or_close_dropdown(self, evt=None):
""" Set `dialog_in` to a containing Toplevel widget only if that
Toplevel is modal. Without `dialog_in`, the Combobox dropdown is
disabled by a containing dialog's use of `grab_set`.
"""
# start = timer()
if self.values is None:
return
if evt is None:
# dropdown item clicked--no evt because of Button command option
self.dropdown.withdraw()
if self.dialog_in:
self.dialog_in.grab_set()
self.dropdown_is_open = False
return
if len(self.dropdown.content.winfo_children()) == 0:
return
evt_type = evt.type
evt_sym = evt.keysym
if evt_sym == 'Tab':
self.dropdown.withdraw()
if self.dialog_in:
self.dialog_in.grab_set()
self.dropdown_is_open = False
return
elif evt_sym == 'Escape':
self.hide_dropdown()
if self.dialog_in:
self.dialog_in.grab_set()
return
first = None
last = None
dropdown_buttons = self.dropdown.content.winfo_children()
if len(dropdown_buttons) != 0:
first = dropdown_buttons[0]
last = dropdown_buttons[-1]
if evt_type == '4':
if self.dropdown_is_open:
self.arrow["bg"] = self.formats["highlight_bg"]
self.dropdown.withdraw()
if self.dialog_in:
self.dialog_in.grab_set()
self.dropdown_is_open = False
return
else:
self.arrow["bg"] = self.formats["head_bg"]
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=self.formats["bg"])
first.focus_set()
self.combobox_selected()
self.dropdown.canvas.yview_moveto(0.0)
elif evt_sym == 'Up':
last.config(bg=self.formats["bg"])
last.focus_set()
self.combobox_selected()
self.dropdown.canvas.yview_moveto(1.0)
self.dropdown.deiconify()
self.fly_up_or_drop_down(evt)
if self.dialog_in:
self.dialog_in.grab_release()
self.dropdown_is_open = True
def fly_up_or_drop_down(self, evt):
# def fly_up_or_drop_down(self, evt, width):
""" Set the dropdown position to either drop down or fly up depending on
the position of the combobox in relation to the top and bottom edges
of the screen. Before it can be decided whether the dropdown must fly
up instead of dropping down, its height has to be limited if it has
too many values to fit into the screen height or a set portion thereof.
"""
if self.max_dropdown_height < self.fit_height:
self.dropdown.sbv.grid()
height = self.max_dropdown_height
else:
height = self.fit_height
self.dropdown.sbv.grid_remove()
combo_height = self.winfo_reqheight()
over_in_screen = self.entry.winfo_rootx()
down_in_screen = self.entry.winfo_rooty()
down = down_in_screen + combo_height
combo_width = self.winfo_reqwidth()
# Get vertical position of mouse click:
click_from_screen_top = evt.y_root
click_from_combo_top = evt.y
fly_up = False
combo_to_screen_top = click_from_screen_top - click_from_combo_top
combo_to_screen_bottom = (
self.screen_height - (combo_to_screen_top + combo_height))
if combo_to_screen_bottom < height:
fly_up = True
fly_up_clearance = combo_to_screen_top - height
if fly_up is False:
drop_y = down
else:
drop_y = fly_up_clearance
self.dropdown.geometry(f"{combo_width}x{height}+{over_in_screen}+{drop_y}")
def config_values(self, new_values=None):
def scroll_start(evt):
self.dropdown.canvas.scan_mark(evt.x, evt.y)
self.fixed_x = evt.x
def on_mousewheel(event):
self.dropdown.canvas.yview_scroll(-1 * (event.delta // 120), "units")
if self.dropdown_is_open:
self.dropdown_is_open = False
return
start = timer()
if new_values:
self.values = new_values
if self.values is None:
return
for button in self.dropdown.content.winfo_children():
button.destroy()
self.update_idletasks()
wraplength = self.winfo_reqwidth()
scridth = self.dropdown.sbv.winfo_reqwidth()
self.max_dropdown_height = int(self.screen_height * 0.33)
self.dropdown.content.columnconfigure(0, weight=1)
for text in self.values:
item = ComboboxDropdownButton(
self.dropdown.content, wraplength, text=text, anchor='w')
item.grid(sticky="ew")
for evt_stg in ('<Button-1>', '<space>'):
item.bind(evt_stg, self.get_clicked, add='+')
item.bind('<Enter>', self.highlight_button, add='+')
item.bind('<Leave>', self.unhighlight_button, add='+')
item.bind('<Tab>', self.tab_out_of_dropdown_fwd)
item.bind('<Shift-Tab>', self.tab_out_of_dropdown_back)
item.bind('<KeyPress>', self.traverse_on_arrow)
item.bind('<FocusOut>', self.unhighlight_button, add='+')
item.bind("<FocusOut>", self.bind_to_dropdown_items, add='+')
item.bind("<FocusIn>", self.combobox_selected, add="+")
configall(self.dropdown, self.formats)
self.update_idletasks()
self.fit_height = height = self.dropdown.content.winfo_reqheight()
if self.fit_height >= self.max_dropdown_height:
self.dropdown.canvas.config(
height=self.max_dropdown_height, width=wraplength - scridth,
scrollregion=(0, 0, 0, self.fit_height))
else:
self.dropdown.canvas.config(
width=wraplength, height=self.fit_height,
scrollregion=(0, 0, 0, self.fit_height))
end = timer()
print("config_values:", end - start)
def combobox_selected(self, evt=None):
''' Do something on focus into a dropdown item. '''
if len(self.tree.comboboxes[self].get()) == 0:
return
print("self.tree.comboboxes[self].get()", self.tree.comboboxes[self].get())
def focus_entry_on_arrow_click(self, evt):
self.focus_set()
self.entry.select_range(0, 'end')
def bind_to_dropdown_items(self, evt):
print("evt.widget['text'] on FocusOut:", evt.widget['text'])
def highlight_button(self, evt):
for widg in self.dropdown.content.winfo_children():
widg.config(bg=self.formats["highlight_bg"])
widget = evt.widget
widget.config(bg=self.formats["bg"])
self.selected = widget
widget.focus_set()
self.combobox_selected()
def unhighlight_button(self, evt):
x, y = self.winfo_pointerxy()
hovered = self.winfo_containing(x,y)
if hovered in self.dropdown.content.winfo_children():
evt.widget.config(bg=self.formats["highlight_bg"])
def unhighlight_all_drop_items(self, evt, formats):
for child in self.dropdown.content.winfo_children():
child.config(bg=formats["highlight_bg"])
def focus_dropdown(self, evt):
for widg in self.dropdown.content.winfo_children():
widg.config(takefocus=1)
def handle_tab_out_of_dropdown(self, go):
for idx,widg in enumerate(self.dropdown.content.winfo_children()):
widg.config(takefocus=0)
if widg == self.selected:
text = self.values[idx]
break
self.entry.delete(0, 'end')
self.entry.insert(0, text)
self.dropdown.withdraw()
self.dropdown_is_open = False
self.entry.focus_set()
self.combobox_selected()
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
children = self.dropdown.content.winfo_children()
for idx,widg in enumerate(children):
if widg == self.selected:
text = self.values[idx]
break
self.current = children.index(self.selected)
self.entry.delete(0, 'end')
self.entry.insert(0, text)
self.entry.select_range(0, 'end')
self.open_or_close_dropdown()
def get_typed(self):
self.typed = self.tree.combovar.get()
def highlight_on_traverse(self, evt, next_item=None, prev_item=None):
evt_type = evt.type # '2' is key press, '4' is button press
evt_sym = evt.keysym
for widg in self.dropdown.content.winfo_children():
widg.config(bg=self.formats["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=self.formats["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.dropdown.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.dropdown.canvas.yview_moveto(float(list_ratio))
elif widg_screenpos < win_top:
self.dropdown.canvas.yview_moveto(float(up_ratio))
self.selected.focus_set()
self.combobox_selected()
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)
next_item = widg.tk_focusNext()
prev_item = widg.tk_focusPrev()
if sym == 'Down':
if next_item in self.dropdown.content.winfo_children():
self.highlight_on_traverse(evt, next_item=next_item)
else:
next_item = self.dropdown.content.winfo_children()[0]
next_item.focus_set()
self.combobox_selected()
next_item.config(bg=self.formats["bg"])
self.dropdown.canvas.yview_moveto(0.0)
elif sym == 'Up':
if prev_item in self.dropdown.content.winfo_children():
self.highlight_on_traverse(evt, prev_item=prev_item)
else:
prev_item = self.dropdown.content.winfo_children()[-1]
prev_item.focus_set()
self.combobox_selected()
prev_item.config(bg=self.formats["bg"])
self.dropdown.canvas.yview_moveto(1.0)
def hide_dropdown(self, evt=None):
self.dropdown.withdraw()
self.dropdown_is_open = False
def close_dropdown(self, evt):
""" This callback is bound to all widgets on ButtonRelease so that a
click anywhere will close the dropdown. But if the combobox has been
destroyed, then referencing the nonexistent dropdown would cause an error.
"""
start = timer()
try:
self.dropdown.withdraw()
except:
print("The dropdown no longer exists.")
pass
finally:
self.dropdown_is_open = False
end = timer()
print("close_dropdown:", end - start)
class ComboboxDropdownButton(tk.Button):
def __init__(self, master, wraplength, *args, **kwargs):
tk.Button.__init__(self, master, *args, **kwargs)
self.config(
justify="left", anchor='w', bd=0, relief="flat", overrelief="flat",
wraplength=wraplength)
def highlight(self, evt):
self.config(
bg=formats['highlight_bg'],
fg=formats['fg'],
activebackground=formats['fg'],
activeforeground=formats['bg'])
if __name__ == "__main__":
def open_tree(values):
geom = "300x200+700+300"
if values == values1:
tree = FamilyTree(root)
tree.title("Tree One")
tree.geometry(geom)
cbo1 = Combobox(tree.main, formats, tree, values)
cbo1.grid(column=0, row=0)
cbo4 = Combobox(tree.main, formats, tree, values=("dancer", "singer", "actor"))
cbo4.grid(column=0, row=2, pady=12)
cbo1.config_values(new_values)
elif values == values2:
tree = FamilyTree(root)
tree.title("Tree Two")
tree.geometry(geom)
tree.combovar = tk.StringVar()
tree.main.rowconfigure(0, weight=1)
cbo2 = Combobox(tree.main, formats, tree, values=("forest", "river", "swamp"))
cbo2.grid(column=0, row=0)
spacer = tk.Frame(tree.main, bg="pink")
spacer.grid(column=0, row=1, sticky="news")
cbo3 = Combobox(tree.main, formats, tree, values)
cbo3.grid(column=0, row=2, pady=12)
class FamilyTree(tk.Toplevel):
def __init__(self, master, *args, **kwargs):
tk.Toplevel.__init__(self, master, *args, **kwargs)
self.root = master
self.main = tk.Frame(self, bg="pink")
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.main.grid(sticky="news")
self.comboboxes = {}
values1 = ("red", "green", "orange is the new brown is the new pink", "blue",
"I don't believe I know what the color chartreuse is supposed to look like."
)
values2 = ("dog", "cat", "lizard", "aardvark", "man eating spider monkey armadillo", "horse", "cow", "human", "dolphin", "whale", "brother", "sister", "mother", "father", "dog", "cat", "lizard", "aardvark", "man eating spider monkey armadillo", "horse", "cow", "human", "dolphin", "whale", "brother", "sister", "mother", "father", "dog", "cat", "lizard", "aardvark", "man eating spider monkey armadillo", "horse", "cow", "human", "dolphin", "whale", "brother", "sister", "mother", "father", "dog", "cat", "lizard", "aardvark", "man eating spider monkey armadillo", "horse", "cow", "human", "dolphin", "whale", "brother", "sister", "mother", "father", "dog", "cat", "lizard", "aardvark", "man eating spider monkey armadillo", "horse", "cow", "human", "dolphin", "whale", "brother", "sister", "mother", "father", "dog", "cat", "lizard", "aardvark", "man eating spider monkey armadillo", "horse", "cow", "human", "dolphin", "whale", "brother", "sister", "mother", "father", "dog", "cat", "lizard", "aardvark", "man eating spider monkey armadillo", "horse", "cow", "human", "dolphin", "whale", "brother", "sister", "mother", "father", "dog", "cat", "lizard", "aardvark", "man eating spider monkey armadillo", "horse", "cow", "human", "dolphin", "whale", "brother", "sister", "mother", "father", "dog", "cat", "lizard", "aardvark", "man eating spider monkey armadillo", "horse", "cow", "human", "dolphin", "whale", "brother", "sister", "mother", "father", "dog", "cat", "lizard", "aardvark", "man eating spider monkey armadillo", "horse", "cow", "human", "dolphin", "whale", "brother", "sister", "mother", "father", "dog", "cat", "lizard", "aardvark", "man eating spider monkey armadillo", "horse", "cow", "human", "dolphin", "whale", "brother", "sister", "mother", "father", "dog", "cat", "lizard", "aardvark", "man eating spider monkey armadillo", "horse", "cow", "human", "dolphin", "whale", "brother", "sister", "mother", "father", "dog", "cat", "lizard", "aardvark", "man eating spider monkey armadillo", "horse", "cow", "human", "dolphin", "whale", "brother", "sister", "mother", "father", )
print("line", look(seeline()).lineno, "len(values2)", len(values2))
formats = make_formats_dict("sample_tree", 242)
root = tk.Tk()
root.geometry("400x400+600+300")
b1 = tk.Button(
root, text="OPEN TREE 1", command=lambda values=values1: open_tree(values))
b1.grid(column=0, row=0)
b2 = tk.Button(
root, text="OPEN TREE 2", command=lambda values=values2: open_tree(values))
b2.grid(column=1, row=0)
new_values = ("bread", "tacos", "champagne", "french fries")
root.mainloop()