diff options
author | B. Stack <bgstack15@gmail.com> | 2024-04-08 09:03:08 -0400 |
---|---|---|
committer | B. Stack <bgstack15@gmail.com> | 2024-04-08 09:03:08 -0400 |
commit | acb35c1a5bcc94d3069d99de5041ae19ca600208 (patch) | |
tree | 0126333b3e5b5dccb82c249ac491b8b22527d9e5 /src/tkstackrpms | |
download | python3-tkstackrpms-acb35c1a5bcc94d3069d99de5041ae19ca600208.tar.gz python3-tkstackrpms-acb35c1a5bcc94d3069d99de5041ae19ca600208.tar.bz2 python3-tkstackrpms-acb35c1a5bcc94d3069d99de5041ae19ca600208.zip |
initial commit
Diffstat (limited to 'src/tkstackrpms')
-rw-r--r-- | src/tkstackrpms/__init__.py | 2 | ||||
-rw-r--r-- | src/tkstackrpms/tkstackrpms.py | 468 |
2 files changed, 470 insertions, 0 deletions
diff --git a/src/tkstackrpms/__init__.py b/src/tkstackrpms/__init__.py new file mode 100644 index 0000000..80f7da6 --- /dev/null +++ b/src/tkstackrpms/__init__.py @@ -0,0 +1,2 @@ +from .tkstackrpms import * +__all__ = ["tkstackrpms"] diff --git a/src/tkstackrpms/tkstackrpms.py b/src/tkstackrpms/tkstackrpms.py new file mode 100644 index 0000000..11c7455 --- /dev/null +++ b/src/tkstackrpms/tkstackrpms.py @@ -0,0 +1,468 @@ +#!/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("<Enter>", self.onEnter) + self.widget.bind("<Leave>", self.onLeave) + self.widget.bind("<ButtonPress>", 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('<MouseWheel>', self.mouseWheel) + self.bind('<Button-4>', self.mouseWheel) + self.bind('<Button-5>', 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("<ButtonRelease-1>",func) + self.bind("<KeyRelease-space>",func) + self.bind("<KeyRelease-Return>",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("<Key-Return>",func) + self.bind("<Key-KP_Enter>",func) + # Do not need specific shift-tab stuff because FocusOut is possible! + #self.bind("<Key-Tab>",func) + #self.bind("<Shift-KeyPress-Tab>",func) + self.bind("<FocusOut>",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("<Key-Down>",self.scroll_with_keys) + self.bind("<Key-Up>",self.scroll_with_keys) + self.bind("<Key-Home>",self.scroll_with_keys) + self.bind("<Key-End>",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)) |