#!/usr/bin/env python3 # vim: set ts=3 sts=3 sw=3 et: # File: src/tkstackrpms/tkstackrpms.py # Location: https://bgstack15.ddns.net/cgit/tkstackrpms/ # Author: bgstack15 # Startdate: 2024-03-28-5 14:49 # Title: Stackrpms extensions to Tcl/Tk - Python 3.x library # Purpose: My small additions to tkinter # Project: python3-tkstackrpms # History: # 2024-03-28 started as component of srb_lib # 2024-04-07 moved to its own project # Usage: # import tkstackrpms as stk # References: # logout-manager-tcl, lmlib.py # file:///usr/include/X11/keysymdef.h # http://stackoverflow.com/questions/3221956/what-is-the-simplest-way-to-make-tooltips-in-tkinter/36221216#36221216 # https://stackoverflow.com/questions/63871376/tkinter-widget-cgetvariable/63871784#63871784 # https://coderslegacy.com/python/create-a-status-bar-in-tkinter/ # https://stackoverflow.com/questions/38329996/enable-mouse-wheel-in-spinbox-tk-python # https://insolor.github.io/effbot-tkinterbook-archive/tkinter-events-and-bindings.htm # https://stackoverflow.com/questions/27533244/how-to-make-a-flashing-text-box-in-tkinter/78277443#78277443 # Improve: # Dependencies: # dep-devuan: python3-tkinter, python3-pil.imagetk # rec-devuan: python3-cairosvg import sys try: from tkinter import * except ModuleNotFoundError: print(f"Fatal! Library tkstackrpms needs tkinter and more, which are not packaged by pip. Try OS packages python3-tk, python3-pil.imagetk.",file=sys.stderr) sys.exit(1) from tkinter import Spinbox as tkSpinbox, Checkbutton as tkCheckbutton, Entry as tkEntry, Radiobutton as tkRadiobutton from pathlib import Path import glob, re, os, configparser # has to happen after tkinter try: from PIL import Image, ImageTk except ModuleNotFoundError as e: print(f"Fatal! Library tkstackrpms needs pil.imagetk which is part of pillow. Try OS package python3-pil.imagetk or pip install pillow.",file=sys.stderr) print(e,file=sys.stderr) sys.exit(1) USE_SVG = False try: from cairosvg import svg2png USE_SVG = True except: print(f"Warning: Library tkstackrpms uses cairosvg to work with SVG images. You can continue without svg support, or retry after getting OS package python3-cairosvg or pip install cairosvg.",file=sys.stderr) MAX_RECURSION = 10 # safety valve for any recursion class Tooltip: """ It creates a tooltip for a given widget as the mouse goes on it. see: http://stackoverflow.com/questions/3221956/ what-is-the-simplest-way-to-make-tooltips- in-tkinter/36221216#36221216 http://www.daniweb.com/programming/software-development/ code/484591/a-tooltip-class-for-tkinter - Originally written by vegaseat on 2014.09.09. - Modified to include a delay time by Victor Zaccardo on 2016.03.25. - Modified - to correct extreme right and extreme bottom behavior, - to stay inside the screen whenever the tooltip might go out on the top but still the screen is higher than the tooltip, - to use the more flexible mouse positioning, - to add customizable background color, padding, waittime and wraplength on creation by Alberto Vassena on 2016.11.05. Improved by Stevoisiak (https://stackoverflow.com/users/3357935/stevoisiak) and crxguy52 (https://stackoverflow.com/users/6106104/crxguy52) Tested on Ubuntu 16.04/16.10, running Python 3.5.2 """ def __init__(self, widget, *, bg='#FFFFEA', pad=(5, 3, 5, 3), text='widget info', waittime=400, wraplength=250): self.waittime = waittime # in miliseconds, originally 500 self.wraplength = wraplength # in pixels, originally 180 self.widget = widget self.text = text self.widget.bind("", self.onEnter) self.widget.bind("", self.onLeave) self.widget.bind("", self.onLeave) self.bg = bg self.pad = pad self.id = None self.tw = None def onEnter(self, event=None): self.schedule() def onLeave(self, event=None): self.unschedule() self.hide() def schedule(self): self.unschedule() self.id = self.widget.after(self.waittime, self.show) def unschedule(self): id_ = self.id self.id = None if id_: self.widget.after_cancel(id_) def show(self): def tip_pos_calculator(widget, label, *, tip_delta=(10, 5), pad=(5, 3, 5, 3)): w = widget s_width, s_height = w.winfo_screenwidth(), w.winfo_screenheight() width, height = (pad[0] + label.winfo_reqwidth() + pad[2], pad[1] + label.winfo_reqheight() + pad[3]) mouse_x, mouse_y = w.winfo_pointerxy() x1, y1 = mouse_x + tip_delta[0], mouse_y + tip_delta[1] x2, y2 = x1 + width, y1 + height x_delta = x2 - s_width if x_delta < 0: x_delta = 0 y_delta = y2 - s_height if y_delta < 0: y_delta = 0 offscreen = (x_delta, y_delta) != (0, 0) if offscreen: if x_delta: x1 = mouse_x - tip_delta[0] - width if y_delta: y1 = mouse_y - tip_delta[1] - height offscreen_again = y1 < 0 # out on the top if offscreen_again: # No further checks will be done. # TIP: # A further mod might automagically augment the # wraplength when the tooltip is too high to be # kept inside the screen. y1 = 0 return x1, y1 bg = self.bg pad = self.pad widget = self.widget # creates a toplevel window self.tw = Toplevel(widget) # Leaves only the label and removes the app window self.tw.wm_overrideredirect(True) win = Frame(self.tw, background=bg, borderwidth=0) label = Label(win, text=self.text, justify=LEFT, background=bg, relief=SOLID, borderwidth=0, wraplength=self.wraplength) label.grid(padx=(pad[0], pad[2]), pady=(pad[1], pad[3]), sticky=NSEW) win.grid() x, y = tip_pos_calculator(widget, label) self.tw.wm_geometry("+%d+%d" % (x, y)) def hide(self): tw = self.tw if tw: tw.destroy() self.tw = None def mynum(x, type = "all"): # return the complicated numerical value for the weird size options f = re.split("[^0-9]+",x) try: f0 = int(f[0]) except: f0 = 0 try: f1 = int(f[1]) except: f1 = 0 if type == "all": return f0 * 100 + f1 if len(f) >= 2 else 0 else: return f0 def image_from_svg(filename = "",size = "48"): # open svg if USE_SVG == 1: svg2png(url=filename,write_to="/tmp/lm_temp_image.png",parent_width = size,parent_height = size) photo = Image.open("/tmp/lm_temp_image.png") else: photo = Image.new("RGBA",[size,size]) return photo def sort_sizes(x): # Original reference so#46228101 value = x.split("/")[5] return mynum(value, "all") def find_best_size_match(size, thelist): # return item from sorted thelist whose split("/")[5] is the first to meet or exceed the requested size try: default = thelist[-1] except: default = None return next(( i for i in thelist if mynum(i.split("/")[5],"real") >= size ), default) def empty_photoimage(size=24): photo = Image.new("RGBA",[size,size]) return ImageTk.PhotoImage(image=photo) def get_scaled_icon(icon_name, size = 24, icon_theme = "default", fallback_icon_name = "", category = "actions"): iconfilename = None # if name is a specific filename, just use it. if Path(icon_name).is_file(): #print("This is a file:",icon_name) iconfilename = icon_name else: if icon_theme == "default": # this should not happen, because the Initialize_config should have checked gtk3 default value. icon_theme = "hicolor" # so now that icon_theme is defined, let us go find the icon that matches the requested name and size, in the actions category #print("Using icon theme",icon_theme) # category is the fd.o icon category. logout-manager-tcl hardcoded "action" iconfilename = get_filename_of_icon(name=icon_name, theme=icon_theme, size=size, category=category, allow_svg = USE_SVG) # So now we think we have derived the correct filename try: #print("Trying icon file",iconfilename) # try an svg if re.compile(r".*\.svg").match(iconfilename): #print("Trying svg...") photo = image_from_svg(filename=iconfilename, size=size) else: photo = Image.open(iconfilename) except Exception as f: print(f"Error with icon file: {f}.",file=sys.stderr) return empty_photoimage() photo.thumbnail(size=[size, size]) try: photo = ImageTk.PhotoImage(photo) except Exception as e: print("Error was ",e,file=sys.stderr) # If I ever add HiDPI support, multiple size here by the factor. So, size * 1.25 return photo def get_filename_of_icon(name, theme = "hicolor", size = 48, category = "actions", allow_svg = False): # poor man's attempt at walking through fd.o icon theme filename = None # example: Adwaita system-log-out if theme == "default" or theme is None: try: theme = get_gtk3_default_icon_theme() except: theme = "hicolor" # first, find all files underneath /usr/share/icons/$THEME/$SIZE #print("Finding filename of icon, theme=",theme,"category=",category,"name=",name) # to exclude the scalable/ contents, replace dir 5 asterisk with [0-9]* results = [] base_dir = "/usr/share/icons/" file_filters = ".*" if not allow_svg: file_filters = ".{png,PNG}" # I have no idea if this is xdg icon theme compliant, but it is a valiant attempt. # 1. try (requested) req-theme, req-category, req-name first results = glob.glob(base_dir+theme+"/*/"+category+"/"+name+file_filters) # 2. try req-theme, (generic) gen-category, req-name if len(results) == 0: # no results with that category, so try all categories results = glob.glob(base_dir+theme+"/*/*/"+name+file_filters) # 3. try "gnome", req-category, req-name if len(results) == 0: results = glob.glob(base_dir+"gnome"+"/*/"+category+"/"+name+file_filters) # 4. try "gnome", gen-category, req-name if len(results) == 0: results = glob.glob(base_dir+"gnome"+"/*/*/"+name+file_filters) # 5. try "hicolor", req-category, req-name if len(results) == 0: results = glob.glob(base_dir+"hicolor"+"/*/"+category+"/"+name+file_filters) # 6. try "hicolor", gen-category, req-name if len(results) == 0: results = glob.glob(base_dir+"hicolor"+"/*/*/"+name+file_filters) # the sort arranges it so a Numix/24 dir comes before a Numix/24@2x dir results = sorted(results, key=sort_sizes) #print(results) # now find the first one that matches filename = find_best_size_match(size,results) return filename def get_gtk3_default_icon_theme(): # abstracted so it does not clutter get_scaled_icon name = "hicolor" gtk3_config_path = os.path.join(os.path.expanduser("~"),".config","gtk-3.0","settings.ini") gtk3_config = configparser.ConfigParser() gtk3_config.read(gtk3_config_path) try: if 'Settings' in gtk3_config: name = gtk3_config['Settings']['gtk-icon-theme-name'] elif 'settings' in gtk3_config: name = gtk3_config['settings']['gtk-icon-theme-name'] except: # supposed failsafe: keep name = hicolor pass #print("Found gtk3 default theme:",name) return name class StatusBar(Frame): def __init__(self, master, var = None): Frame.__init__(self, master, borderwidth = 1, background = "darkgray") if var: self.label = Label(self,textvariable=var) else: self.label = Label(self) self.label.pack(side=LEFT) #self.pack(side=BOTTOM, fill=X) self.grid(row=100,column=0,columnspan=100) def set(self, newText): self.label.config(text=newText) def clear(self): self.label.config(text="") class Spinbox(tkSpinbox): """ This enables mouse scrolling without having to add it to each instance. """ def __init__(self, *args, **kwargs): tkSpinbox.__init__(self, *args, **kwargs) self.bind('', self.mouseWheel) self.bind('', self.mouseWheel) self.bind('', self.mouseWheel) def mouseWheel(self, event): if event.num == 5 or event.delta == -120: self.invoke('buttondown') elif event.num == 4 or event.delta == 120: self.invoke('buttonup') class Checkbutton(tkCheckbutton): """ Add a specific function to these key-up/button-up sequences, without having to add it to each instance individually. """ def __init__(self, *args, **kwargs): func = None if "func" in kwargs.keys(): func = kwargs.pop("func") tkCheckbutton.__init__(self, *args, **kwargs) if func: self.bind("",func) self.bind("",func) self.bind("",func) class Entry(tkEntry): def __init__(self, *args, **kwargs): func = None if "func" in kwargs.keys(): func = kwargs.pop("func") tkEntry.__init__(self, *args, **kwargs) if func: self.bind("",func) self.bind("",func) # Do not need specific shift-tab stuff because FocusOut is possible! #self.bind("",func) #self.bind("",func) self.bind("",func) class Radiobutton(tkRadiobutton): """ Extends tkRadioButton: * Store self.__var attribute that connects to the tk.IntVar() used * Moves between all associated radio buttons with up/down keys! """ @property def var(self): return self.__var @property def value(self) -> int: return self.__var.get() @value.setter def value(self, value): self.__var.set(int(value)) def __init__(self, *args, **kwargs): self.__var = tk.IntVar() if not "variable" in kwargs else kwargs["variable"] tkRadiobutton.__init__(self, *args, **{**kwargs, "variable":self.__var}) self.bind("",self.scroll_with_keys) self.bind("",self.scroll_with_keys) self.bind("",self.scroll_with_keys) self.bind("",self.scroll_with_keys) def add_item_if_var_matches(self, otherwidget, samevar, thelist): j1 = None try: j1 = otherwidget.cget("variable") except: pass if j1 == samevar: #print(f"found {otherwidget} that uses var {j1}") thelist.append(otherwidget) return thelist def list_all_child_items_that_use_var(self, thistoplevel, thisvar, thislist, depth): # safety valve if depth >= MAX_RECURSION: raise Exception("You dug too deep with recursion in list_all_child_items") for i in thistoplevel.winfo_children(): thislist = self.add_item_if_var_matches(i,thisvar,thislist) thislist = self.list_all_child_items_that_use_var(i,thisvar,thislist,depth+1) # recurse return thislist def widget_key(self,a): """ Used to sort radio options by 'value'. """ aval = None try: aval = a.cget("value") except: pass return aval def scroll_with_keys(self, arg1 = None, arg2 = None, arg3 = None): """ This depends on you setting the object.var property to match the variable. """ # prepare all items that also use this variable # nametowidget is a tk trick to get the top-level parent object of the current widget. That is, the app or top window. items = self.list_all_child_items_that_use_var(self.nametowidget("."),self.cget("variable"),[],depth=0) # sort them by value, in case they are not in order items.sort(key=self.widget_key) # we are using 1-indexing here. items.insert(0, None) #print(f"debug: now have items {items}") # prepare the operation direction = None if arg1.keysym == "Up": direction = +1 elif arg1.keysym == "Down": direction = +0 #print(f"Must go direction {direction} in list {items}, using {self.__var.get()}") # Because of the 1-indexing, by always adding one, we do scroll through the values the way you would expect. if direction is not None: self.__var.set((self.__var.get()+direction) % (len(items)-1) + 1) elif arg1.keysym in ["Home"]: self.__var.set(items[1].cget("value")) elif arg1.keysym in ["End"]: self.__var.set(items[-1].cget("value")) # and now focus on the object whose value matches for i in items: i1 = None try: i1 = i.cget("value") except: pass if i1 == self.__var.get(): i.focus() break def flash_entry(this_entry, colorlist, ms = 1000): """ Flash the offending entry box through the colorlist, one color every 'ms' milliseconds. Example: flash_entry(entry_that_was_invalid,["red","white"]) """ thiscolor = colorlist.pop(0) this_entry.config(background = thiscolor) if len(colorlist) > 0: top = this_entry._nametowidget(this_entry.winfo_parent()) top.after(ms, lambda te=this_entry, c=colorlist, ms=ms: flash_entry(te,c,ms))