How to add a question mark [?] button on the top of a tkinter window
I think I got it working:
from PIL import Image, ImageTk
import tkinter as tk
import sys
USING_WINDOWS = ("win" in sys.platform)
THEME_OPTIONS = ("light", "dark")
THEME = "dark"
if THEME == "dark":
THEME_BG = "black"
THEME_FG = "white"
THEME_SEP_COLOUR = "grey"
THEME_HIGHLIGHT = "grey"
THEME_ACTIVE_TITLEBAR_BG = "black"
THEME_INACTIVE_TITLEBAR_BG = "grey17"
elif THEME == "light":
THEME_BG = "#f0f0ed"
THEME_FG = "black"
THEME_SEP_COLOUR = "grey"
THEME_HIGHLIGHT = "grey"
THEME_ACTIVE_TITLEBAR_BG = "white"
THEME_INACTIVE_TITLEBAR_BG = "grey80"
SNAP_THRESHOLD = 200
SEPARATOR_SIZE = 1
NUMBER_OF_CUSTOM_BUTTONS = 5
USE_UNICODE = False
class CustomButton(tk.Button):
def __init__(self, master, betterroot, name="#", function=None, column=0):
self.betterroot = betterroot
if function is None:
self.callback = lambda: None
else:
self.callback = function
super().__init__(master, text=name, relief="flat", bg=THEME_BG,
fg=THEME_FG, command=lambda: self.callback())
self.column = column
def show(self, column=None):
"""
Shows the button on the screen
"""
if column is None:
column = self.column
super().grid(row=1, column=column)
def hide(self):
"""
Hides the button from the screen
"""
super().grid_forget()
class MinimiseButton(tk.Button):
def __init__(self, master, betterroot):
self.betterroot = betterroot
if USE_UNICODE:
text = "\u2014"
else:
text = "_"
super().__init__(master, text=text, relief="flat", bg=THEME_BG,
fg=THEME_FG, command=self.minimise_window)
def minimise_window(self):
"""
Minimises the window
"""
self.betterroot.dummy_root.iconify()
self.betterroot.root.withdraw()
def show(self, column=NUMBER_OF_CUSTOM_BUTTONS+2):
"""
Shows the button on the screen
"""
super().grid(row=1, column=column)
def hide(self):
"""
Hides the button from the screen
"""
super().grid_forget()
class FullScreenButton(tk.Button):
def __init__(self, master, betterroot):
self.betterroot = betterroot
if USE_UNICODE:
text = "\u2610"
else:
text = "[]"
super().__init__(master, text=text, relief="flat", bg=THEME_BG,
fg=THEME_FG, command=self.toggle_fullscreen)
def toggle_fullscreen(self, event=None):
"""
Toggles fullscreen.
"""
# If it is called from double clicking:
if event is not None:
# Make sure that we didn't double click something else
if not self.betterroot.check_parent_titlebar(event):
return None
# If it is the title bar toggle fullscreen:
if self.betterroot.is_full_screen:
self.notfullscreen()
else:
self.fullscreen()
def fullscreen(self):
"""
Switches to full screen.
"""
if self.betterroot.is_full_screen:
return "error"
super().config(command=self.notfullscreen)
if USING_WINDOWS:
self.betterroot.root.overrideredirect(False)
else:
self.betterroot.root.attributes("-type", "normal")
self.betterroot.root.attributes("-fullscreen", True)
self.betterroot.is_full_screen = True
def notfullscreen(self):
"""
Switches to back to normal (not full) screen.
"""
if not self.betterroot.is_full_screen:
return "error"
# This toggles between the `fullscreen` and `notfullscreen` methods
super().config(command=self.fullscreen)
self.betterroot.root.attributes("-fullscreen", False)
if USING_WINDOWS:
self.betterroot.root.overrideredirect(True)
else:
self.betterroot.root.attributes("-type", "splash")
self.betterroot.is_full_screen = False
def show(self, column=NUMBER_OF_CUSTOM_BUTTONS+3):
"""
Shows the button on the screen
"""
super().grid(row=1, column=column)
def hide(self):
"""
Hides the button from the screen
"""
super().grid_forget()
class CloseButton(tk.Button):
def __init__(self, master, betterroot):
self.betterroot = betterroot
if USE_UNICODE:
text = "\u26cc"
else:
text = "X"
super().__init__(master, text=text, relief="flat", bg=THEME_BG,
fg=THEME_FG, command=self.close_window_protocol)
def close_window_protocol(self):
"""
Generates a `WM_DELETE_WINDOW` protocol request.
If unhandled it will automatically go to `root.destroy()`
"""
self.betterroot.protocol_generate("WM_DELETE_WINDOW")
def show(self, column=NUMBER_OF_CUSTOM_BUTTONS+4):
"""
Shows the button on the screen
"""
super().grid(row=1, column=column)
def hide(self):
"""
Hides the button from the screen
"""
super().grid_forget()
class BetterTk(tk.Frame):
"""
Attributes:
disable_north_west_resizing
*Buttons*
minimise_button
fullscreen_button
close_button
*List of all buttons*
buttons: [minimise_button, fullscreen_button, close_button, ...]
Methods:
*List of newly defined methods*
change_titlebar_bg(new_bg_colour) => None
protocol_generate(protocol) => None
#custom_buttons#
topmost() => None
*List of methods that act the same was as tkinter.Tk's methods*
title
config
protocol
geometry
focus_force
destroy
iconbitmap
resizable
attributes
withdraw
iconify
deiconify
maxsize
minsize
state
report_callback_exception
The buttons:
minimise_button:
minimise_window() => None
show(column) => None
hide() => None
fullscreen_button:
toggle_fullscreen() => None
fullscreen() => None
notfullscreen() => None
show(column) => None
hide() => None
close_button:
close_window_protocol() => None
show(column) => None
hide() => None
buttons: # It is a list of all of the buttons
The custom_buttons:
The proper way of using it is:
```
root = BetterTk()
root.custom_buttons = {"name": "?",
"function": questionmark_pressed,
"column": 0}
questionmark_button = root.buttons[-1]
root.custom_buttons = {"name": "\u2263",
"function": three_lines_pressed,
"column": 2}
threelines_button = root.buttons[-1]
```
You can call:
show(column) => None
hide() => None
"""
def __init__(self, master=None, Class=tk.Tk):
if Class == tk.Toplevel:
self.root = tk.Toplevel(master)
elif Class == tk.Tk:
self.root = tk.Tk()
else:
raise ValueError("Invalid `Class` argument.")
self.protocols = {"WM_DELETE_WINDOW": self.destroy}
self.window_destroyed = False
self.focused_widget = None
self.is_full_screen = False
# Create the dummy window
self.dummy_root = tk.Toplevel(self.root)
self.dummy_root.bind("<FocusIn>", self.focus_main)
self.dummy_root.protocol("WM_DELETE_WINDOW", lambda: self.protocol_generate("WM_DELETE_WINDOW"))
self.root.update()
self.dummy_root.after(1, self.dummy_root.geometry, "1x1")
geometry = "+%i+%i" % (self.root.winfo_x(), self.root.winfo_y())
if USING_WINDOWS:
self.root.overrideredirect(True)
else:
self.root.attributes("-type", "splash")
self.geometry(geometry)
self.root.bind("<FocusIn>", self.window_focused)
self.root.bind("<FocusOut>", self.window_unfocused)
# Master frame so that I can add a grey border around the window
self.master_frame = tk.Frame(self.root, highlightthickness=3, bd=0,
highlightbackground=THEME_HIGHLIGHT)
self.master_frame.pack(expand=True, fill="both")
self.resizable_window = ResizableWindow(self.master_frame, self)
# The actual <tk.Frame> where you can put your widgets
super().__init__(self.master_frame, bd=0, bg=THEME_BG, cursor="arrow")
super().pack(expand=True, side="bottom", fill="both")
# Set up the title bar frame
self.title_bar = tk.Frame(self.master_frame, bg=THEME_BG, bd=0,
cursor="arrow")
self.title_bar.pack(side="top", fill="x")
self.draggable_window = DraggableWindow(self.title_bar, self)
# Add a separator
self.separator = tk.Frame(self.master_frame, bg=THEME_SEP_COLOUR,
height=SEPARATOR_SIZE, bd=0, cursor="arrow")
self.separator.pack(fill="x")
# For the titlebar frame
self.title_frame = tk.Frame(self.title_bar, bg=THEME_BG)
self.title_frame.pack(expand=True, side="left", anchor="w", padx=5)
self.buttons_frame = tk.Frame(self.title_bar, bg=THEME_BG)
self.buttons_frame.pack(expand=True, side="right", anchor="e")
self.title_label = tk.Label(self.title_frame, text="Better Tk",
bg=THEME_BG, fg=THEME_FG)
self.title_label.grid(row=1, column=2, sticky="news")
self.icon_label = None
self.minimise_button = MinimiseButton(self.buttons_frame, self)
self.minimise_button.show()
self.fullscreen_button = FullScreenButton(self.buttons_frame, self)
self.fullscreen_button.show()
self.close_button = CloseButton(self.buttons_frame, self)
self.close_button.show()
# When the user double clicks on the titlebar
self.title_bar.bind_all("<Double-Button-1>",
self.fullscreen_button.toggle_fullscreen)
# When the user middle clicks on the titlebar
self.title_bar.bind_all("<Button-2>", self.snap_to_side)
self.buttons = [self.minimise_button, self.fullscreen_button,
self.close_button]
def snap_to_side(self, event):
"""
Moves the window to the side that it's close to.
"""
if (event is not None) and (not self.check_parent_titlebar(event)):
return None
rootx, rooty = self.root.winfo_rootx(), self.root.winfo_rooty()
width = self.master_frame.winfo_width()
height = self.master_frame.winfo_height()
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
geometry = [rootx, rooty]
if rootx < SNAP_THRESHOLD:
geometry[0] = 0
if rooty < SNAP_THRESHOLD:
geometry[1] = 0
if screen_width - (rootx + width) < SNAP_THRESHOLD:
geometry[0] = screen_width - width
if screen_height - (rooty + height) < SNAP_THRESHOLD:
geometry[1] = screen_height - height
self.geometry("+%i+%i" % tuple(geometry))
def focus_main(self, event=None):
"""
When the dummy window gets focused it passes the focus to the main
window. It also focuses the last focused widget.
"""
self.root.lift()
self.root.deiconify()
if self.focused_widget is None:
self.root.focus_force()
else:
self.focused_widget.focus_force()
def get_focused_widget(self, event=None):
widget = self.root.focus_get()
if not ((widget == self.root) or (widget == None)):
self.focused_widget = widget
def window_focused(self, event):
self.get_focused_widget()
self.change_titlebar_bg(THEME_ACTIVE_TITLEBAR_BG)
def window_unfocused(self, event):
self.get_focused_widget()
self.change_titlebar_bg(THEME_INACTIVE_TITLEBAR_BG)
def change_titlebar_bg(self, colour):
"""
Changes the bg of the root.
"""
items = (self.title_bar, self.buttons_frame, self.title_label)
items += tuple(self.buttons)
if self.icon_label is not None:
items += (self.icon_label, )
for item in items:
item.config(background=colour)
def protocol_generate(self, protocol):
"""
Generates a protocol.
"""
try:
function = self.protocols[protocol]
function()
except KeyError:
raise tk.TclError("Tried generating unknown protocol: \"%s\"" %
protocol)
def check_parent_titlebar(self, event):
# Get the widget that was pressed:
widget = event.widget
# Check if it is part of the title bar or something else
# It checks its parent and its parent's parent and
# its parent's parent's parent and ... until it finds
# whether or not the widget clicked on is the title bar.
while widget != self.root:
if widget == self.buttons_frame:
# Don't allow moving the window when buttons are clicked
return False
if widget == self.title_bar:
return True
# In some very rare cases `widget` can be `None`
# And widget.master will throw an error
if widget is None:
return False
widget = widget.master
return False
@property
def custom_buttons(self):
return None
@custom_buttons.setter
def custom_buttons(self, value):
self.custom_button = CustomButton(self.buttons_frame, self, **value)
self.custom_button.show()
self.buttons.append(self.custom_button)
@property
def disable_north_west_resizing(self):
return self.resizable_window.disable_north_west_resizing
@disable_north_west_resizing.setter
def disable_north_west_resizing(self, value):
self.resizable_window.disable_north_west_resizing = value
# Normal <tk.Tk> methods:
def title(self, title):
# Changing the title of the window
# Note the name will aways be shows and the window can't be resized
# to cover it up.
self.title_label.config(text=title)
self.root.title(title)
self.dummy_root.title(title)
def config(self, bg=None, **kwargs):
if bg is not None:
super().config(bg=bg)
self.root.config(**kwargs)
def protocol(self, protocol, function):
"""
Binds a function to a protocol.
"""
self.protocols.update({protocol: function})
def topmost(self):
self.attributes("-topmost", True)
def geometry(self, geometry):
if not isinstance(geometry, str):
raise ValueError("The geometry must be a string")
if geometry.count("+") not in (0, 2):
raise ValueError("Invalid geometry: \"%s\"" % repr(geometry)[1:-1])
dummy_geometry = ""
if "+" in geometry:
_, posx, posy = geometry.split("+")
dummy_geometry = "+%i+%i" % (int(posx) + 75, int(posy) + 20)
self.root.geometry(geometry)
self.dummy_root.geometry(dummy_geometry)
def focus_force(self):
self.root.deiconify()
self.root.focus_force()
def destroy(self):
if self.window_destroyed:
super().destroy()
else:
self.window_destroyed = True
self.root.destroy()
def iconbitmap(self, filename):
if self.icon_label is not None:
self.icon_label.destroy()
self.dummy_root.iconbitmap(filename)
self.root.lift()
self.root.update_idletasks()
size = self.title_frame.winfo_height()
img = Image.open(filename).resize((size, size), Image.LANCZOS)
self._tk_icon = ImageTk.PhotoImage(img, master=self.root)
bg = self.title_label.cget("background")
self.icon_label = tk.Label(self.title_frame, image=self._tk_icon, bg=bg)
self.icon_label.grid(row=1, column=1, sticky="news")
def resizable(self, width=None, height=None):
if width is not None:
self.resizable_window.resizable_horizontal = width
if height is not None:
self.resizable_window.resizable_vertical = height
return None
def attributes(self, *args, **kwargs):
self.root.attributes(*args, **kwargs)
def withdraw(self):
self.minimise_button.minimise_window()
self.dummy_root.withdraw()
def iconify(self):
self.dummy_root.iconify()
self.minimise_button.minimise_window()
def deiconify(self):
self.dummy_root.deiconify()
self.dummy_root.focus_force()
def maxsize(self, *args, **kwargs):
self.root.maxsize(*args, **kwargs)
def minsize(self, *args, **kwargs):
self.root.minsize(*args, **kwargs)
def state(self, *args, **kwargs):
self.root.state(*args, **kwargs)
def report_callback_exception(self, *args, **kwargs):
self.root.report_callback_exception(*args, **kwargs)
class ResizableWindow:
def __init__(self, frame, betterroot):
# Makes the frame resizable like a window
self.frame = frame
self.geometry = betterroot.geometry
self.betterroot = betterroot
self.sensitivity = 10
# Variables for resizing:
self.started_resizing = False
self.quadrant_resizing = None
self.disable_north_west_resizing = False
self.resizable_horizontal = True
self.resizable_vertical = True
self.frame.bind("<Enter>", self.change_cursor_resizing)
self.frame.bind("<Motion>", self.change_cursor_resizing)
frame.bind("<Button-1>", self.mouse_press)
frame.bind("<B1-Motion>", self.mouse_motion)
frame.bind("<ButtonRelease-1>", self.mouse_release)
self.started_resizing = False
def mouse_motion(self, event):
if self.started_resizing:
new_params = [self.current_width, self.current_height,
self.currentx, self.currenty]
if "e" in self.quadrant_resizing:
self.update_resizing_params(new_params, self.resize_east())
if "n" in self.quadrant_resizing:
self.update_resizing_params(new_params, self.resize_north())
if "s" in self.quadrant_resizing:
self.update_resizing_params(new_params, self.resize_south())
if "w" in self.quadrant_resizing:
self.update_resizing_params(new_params, self.resize_west())
self.geometry("%ix%i+%i+%i" % tuple(new_params))
def mouse_release(self, event):
self.started_resizing = False
def mouse_press(self, event):
if self.betterroot.is_full_screen:
return None
# Resizing the window:
if event.widget == self.frame:
self.current_width = self.betterroot.root.winfo_width()
self.current_height = self.betterroot.root.winfo_height()
self.currentx = self.betterroot.root.winfo_rootx()
self.currenty = self.betterroot.root.winfo_rooty()
quadrant_resizing = self.get_quadrant_resizing()
if len(quadrant_resizing) > 0:
self.started_resizing = True
self.quadrant_resizing = quadrant_resizing
# For resizing:
def change_cursor_resizing(self, event):
if self.betterroot.is_full_screen:
self.frame.config(cursor="arrow")
return None
if self.started_resizing:
return None
quadrant_resizing = self.get_quadrant_resizing()
if quadrant_resizing == "":
# Reset the cursor back to "arrow"
self.frame.config(cursor="arrow")
elif (quadrant_resizing == "ne") or (quadrant_resizing == "sw"):
if USING_WINDOWS:
# Available on Windows
self.frame.config(cursor="size_ne_sw")
else:
# Available on Linux
if quadrant_resizing == "nw":
self.frame.config(cursor="bottom_left_corner")
else:
self.frame.config(cursor="top_right_corner")
elif (quadrant_resizing == "nw") or (quadrant_resizing == "se"):
if USING_WINDOWS:
# Available on Windows
self.frame.config(cursor="size_nw_se")
else:
# Available on Linux
if quadrant_resizing == "nw":
self.frame.config(cursor="top_left_corner")
else:
self.frame.config(cursor="bottom_right_corner")
elif (quadrant_resizing == "n") or (quadrant_resizing == "s"):
# Available on Windows/Linux
self.frame.config(cursor="sb_v_double_arrow")
elif (quadrant_resizing == "e") or (quadrant_resizing == "w"):
# Available on Windows/Linux
self.frame.config(cursor="sb_h_double_arrow")
def get_quadrant_resizing(self):
x, y = self.betterroot.root.winfo_pointerx(), self.betterroot.root.winfo_pointery()
width, height = self.betterroot.root.winfo_width(), self.betterroot.root.winfo_height()
x -= self.betterroot.root.winfo_rootx()
y -= self.betterroot.root.winfo_rooty()
quadrant_resizing = ""
if self.resizable_vertical:
if y + self.sensitivity > height:
quadrant_resizing += "s"
if not self.disable_north_west_resizing:
if y < self.sensitivity:
quadrant_resizing += "n"
if self.resizable_horizontal:
if x + self.sensitivity > width:
quadrant_resizing += "e"
if not self.disable_north_west_resizing:
if x < self.sensitivity:
quadrant_resizing += "w"
return quadrant_resizing
def resize_east(self):
x = self.betterroot.root.winfo_pointerx()
new_width = x - self.currentx
if new_width < 240:
new_width = 240
return new_width, None, None, None
def resize_south(self):
y = self.betterroot.root.winfo_pointery()
new_height = y - self.currenty
if new_height < 80:
new_height = 80
return None, new_height, None, None
def resize_north(self):
y = self.betterroot.root.winfo_pointery()
dy = self.currenty - y
if dy < 80 - self.current_height:
dy = 80 - self.current_height
new_height = self.current_height + dy
return None, new_height, None, self.currenty - dy
def resize_west(self):
x = self.betterroot.root.winfo_pointerx()
dx = self.currentx - x
if dx < 240 - self.current_width:
dx = 240 - self.current_width
new_width = self.current_width + dx
return new_width, None, self.currentx - dx, None
def update_resizing_params(self, _list, _tuple):
for i in range(len(_tuple)):
element = _tuple[i]
if element is not None:
_list[i] = element
class DraggableWindow:
def __init__(self, frame, betterroot):
# Makes the frame draggable like a window
self.frame = frame
self.geometry = betterroot.geometry
self.betterroot = betterroot
self.dragging = False
self._offsetx = 0
self._offsety = 0
self.frame.bind_all("<Button-1>", self.clickwin)
self.frame.bind_all("<B1-Motion>", self.dragwin)
self.frame.bind_all("<ButtonRelease-1>", self.stopdragwin)
def stopdragwin(self, event):
self.dragging = False
def dragwin(self, event):
if self.dragging:
x = self.frame.winfo_pointerx() - self._offsetx
y = self.frame.winfo_pointery() - self._offsety
self.geometry("+%i+%i" % (x, y))
def clickwin(self, event):
if self.betterroot.is_full_screen:
return None
if not self.betterroot.check_parent_titlebar(event):
return None
self.dragging = True
self._offsetx = event.widget.winfo_rootx() -\
self.betterroot.root.winfo_rootx() + event.x
self._offsety = event.widget.winfo_rooty() -\
self.betterroot.root.winfo_rooty() + event.y
and use this to try it out:
def questionmark_pressed():
print("\"?\" was pressed")
def three_lines_pressed():
print("\"\u2263\" was pressed")
root = BetterTk()
# Adding a custom button:
root.custom_buttons = {"name": "?",
"function": questionmark_pressed,
"column": 0}
# Adding another custom button:
root.custom_buttons = {"name": "\u2263",
"function": three_lines_pressed,
"column": 2}
root.geometry("400x400")
# root.minimise_button.hide()
root.mainloop()
I removed the title bar from the tk.Tk
by using .overrideredirect(True)
. After that I just created my own title bar and placed it at the top. With this method you can add as many buttons as you want. I also made the title bar draggable so that you can move the window arround.
Edit: You can find the latest version here. Also please report all bugs that you find here. This code is part of my bigger project that I will keep updating.