#!/usr/bin/env python3 import os import sys import json import subprocess import stat import gi import nwg_panel.common gi.require_version('GdkPixbuf', '2.0') gi.require_version('Gtk', '3.0') gi.require_version('Gdk', '3.0') from gi.repository import Gtk, Gdk, GdkPixbuf from shutil import copyfile import nwg_panel.common try: import netifaces except ModuleNotFoundError: pass try: import psutil except: pass def eprint(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) def temp_dir(): if os.getenv("TMPDIR"): return os.getenv("TMPDIR") elif os.getenv("TEMP"): return os.getenv("TEMP") elif os.getenv("TMP"): return os.getenv("TMP") return "/tmp" def get_app_dirs(): desktop_dirs = [] home = os.getenv("HOME") xdg_data_home = os.getenv("XDG_DATA_HOME") xdg_data_dirs = os.getenv("XDG_DATA_DIRS") if os.getenv("XDG_DATA_DIRS") else "/usr/local/share/:/usr/share/" if xdg_data_home: desktop_dirs.append(os.path.join(xdg_data_home, "applications")) else: if home: desktop_dirs.append(os.path.join(home, ".local/share/applications")) for d in xdg_data_dirs.split(":"): desktop_dirs.append(os.path.join(d, "applications")) # Add flatpak dirs if not found in XDG_DATA_DIRS flatpak_dirs = [os.path.join(home, ".local/share/flatpak/exports/share/applications"), "/var/lib/flatpak/exports/share/applications"] for d in flatpak_dirs: if d not in desktop_dirs: desktop_dirs.append(d) return desktop_dirs def map_odd_desktop_files(): name2icon_dict = {} for d in nwg_panel.common.app_dirs: if os.path.exists(d): for path in os.listdir(d): if os.path.isfile(os.path.join(d, path)): if path.endswith(".desktop") and path.count(".") > 1: try: content = load_text_file(os.path.join(d, path)) except Exception as e: eprint(e) if content: for line in content.splitlines(): if line.startswith("[") and not line == "[Desktop Entry]": break if line.upper().startswith("ICON="): icon = line.split("=")[1] name2icon_dict[path] = icon break return name2icon_dict def get_icon_name(app_name): if not app_name: return "" # GIMP returns "app_id": null and for some reason "class": "Gimp-2.10" instead of just "gimp". # Until the GTK3 version is released, let's make an exception for GIMP. if "GIMP" in app_name.upper(): return "gimp" for d in nwg_panel.common.app_dirs: # This will work if the .desktop file name is app_id.desktop or wm_class.desktop path = os.path.join(d, "{}.desktop".format(app_name)) content = None if os.path.isfile(path): content = load_text_file(path) elif os.path.isfile(path.lower()): content = load_text_file(path.lower()) if content: for line in content.splitlines(): if line.upper().startswith("ICON"): return line.split("=")[1] # Search the dictionary made of .desktop files that use "reverse DNS"-style names, prepared on startup. # see: https://github.com/nwg-piotr/nwg-panel/issues/64 for key in nwg_panel.common.name2icon_dict.keys(): if app_name in key.split("."): return nwg_panel.common.name2icon_dict[key] def local_dir(): local_dir = os.path.join(os.path.join(os.getenv("HOME"), ".local/share/nwg-panel")) if not os.path.isdir(local_dir): print("Creating '{}'".format(local_dir)) os.makedirs(local_dir, exist_ok=True) return local_dir def get_config_dir(): """ Determine config dir path, create if not found, then create sub-dirs :return: config dir path """ xdg_config_home = os.getenv('XDG_CONFIG_HOME') config_home = xdg_config_home if xdg_config_home else os.path.join(os.getenv("HOME"), ".config") config_dir = os.path.join(config_home, "nwg-panel") if not os.path.isdir(config_dir): print("Creating '{}'".format(config_dir)) os.makedirs(config_dir, exist_ok=True) # Icon folders to store user-defined icon replacements folder = os.path.join(config_dir, "icons_light") if not os.path.isdir(folder): print("Creating '{}'".format(folder)) os.makedirs(folder, exist_ok=True) folder = os.path.join(config_dir, "icons_dark") if not os.path.isdir(os.path.join(folder)): print("Creating '{}'".format(folder)) os.makedirs(folder, exist_ok=True) folder = os.path.join(config_dir, "executors") if not os.path.isdir(os.path.join(folder)): print("Creating '{}'".format(folder)) os.makedirs(folder, exist_ok=True) return config_dir def copy_files(src_dir, dst_dir, restore=False): src_files = os.listdir(src_dir) for file in src_files: if os.path.isfile(os.path.join(src_dir, file)): if not os.path.isfile(os.path.join(dst_dir, file)) or restore: copyfile(os.path.join(src_dir, file), os.path.join(dst_dir, file)) print("Copying '{}'".format(os.path.join(dst_dir, file))) def copy_executors(src_dir, dst_dir): src_files = os.listdir(src_dir) for file in src_files: if os.path.isfile(os.path.join(src_dir, file)) and not os.path.isfile(os.path.join(dst_dir, file)): copyfile(os.path.join(src_dir, file), os.path.join(dst_dir, file)) print("Copying '{}', marking executable".format(os.path.join(dst_dir, file))) st = os.stat(os.path.join(dst_dir, file)) os.chmod(os.path.join(dst_dir, file), st.st_mode | stat.S_IEXEC) def load_text_file(path): try: with open(path, 'r') as file: data = file.read() return data except Exception as e: print(e) return None def load_json(path): try: with open(path, 'r') as f: return json.load(f) except Exception as e: print("Error loading json: {}".format(e)) return None def save_json(src_dict, path): with open(path, 'w') as f: json.dump(src_dict, f, indent=2) def save_string(string, file): try: file = open(file, "wt") file.write(string) file.close() except: print("Error writing file '{}'".format(file)) def load_string(path): try: with open(path, 'r') as file: data = file.read() return data except: return "" def load_autotiling(): autotiling = [] path = os.path.join(temp_dir(), "autotiling") try: for ws in load_string(path).split(","): autotiling.append(int(ws)) except: pass return autotiling def list_outputs(sway=False, tree=None, silent=False): """ Get output names and geometry from i3 tree, assign to Gdk.Display monitors. :return: {"name": str, "x": int, "y": int, "width": int, "height": int, "monitor": Gkd.Monitor} """ outputs_dict = {} if sway: if not silent: print("Running on sway") if not tree: tree = nwg_panel.common.i3.get_tree() for item in tree: if item.type == "output" and not item.name.startswith("__"): outputs_dict[item.name] = {"x": item.rect.x, "y": item.rect.y, "width": item.rect.width, "height": item.rect.height, "monitor": None} elif os.getenv('WAYLAND_DISPLAY') is not None: if not silent: print("Running on Wayland, but not sway") if nwg_panel.common.commands["wlr-randr"]: lines = subprocess.check_output("wlr-randr", shell=True).decode("utf-8").strip().splitlines() if lines: name, w, h, x, y, transform = None, None, None, None, None, None for line in lines: if not line.startswith(" "): name = line.split()[0] elif "current" in line: w_h = line.split()[0].split('x') w = int(w_h[0]) h = int(w_h[1]) elif "Transform" in line: transform = line.split()[1].strip() elif "Position" in line: x_y = line.split()[1].split(',') x = int(x_y[0]) y = int(x_y[1]) if name is not None and w is not None and h is not None and x is not None and y is not None \ and transform is not None: if transform == "normal": outputs_dict[name] = {'name': name, 'x': x, 'y': y, 'width': w, 'height': h, 'transform': transform, 'monitor': None} else: outputs_dict[name] = {'name': name, 'x': x, 'y': y, 'width': h, 'height': w, 'transform': transform, 'monitor': None} else: print("'wlr-randr' command not found, terminating") sys.exit(1) display = Gdk.Display.get_default() for i in range(display.get_n_monitors()): monitor = display.get_monitor(i) geometry = monitor.get_geometry() for key in outputs_dict: if int(outputs_dict[key]["x"]) == geometry.x and int(outputs_dict[key]["y"]) == geometry.y: outputs_dict[key]["monitor"] = monitor return outputs_dict def check_key(dictionary, key, default_value): """ Adds a key w/ default value if missing from the dictionary """ if key not in dictionary: dictionary[key] = default_value def cmd2string(cmd): try: return subprocess.check_output(cmd, shell=True).decode("utf-8").strip() except subprocess.CalledProcessError: return "" def is_command(cmd): cmd = cmd.split()[0] # strip arguments cmd = "command -v {}".format(cmd) try: is_cmd = subprocess.check_output(cmd, shell=True).decode("utf-8").strip() if is_cmd: return True except subprocess.CalledProcessError: return False def check_commands(): for key in nwg_panel.common.commands: nwg_panel.common.commands[key] = is_command(key) try: import netifaces nwg_panel.common.commands["netifaces"] = True except ModuleNotFoundError: pass def get_volume(): vol = 0 muted = False if nwg_panel.common.commands["pamixer"]: try: output = cmd2string("pamixer --get-volume") if output: vol = int(cmd2string("pamixer --get-volume")) except Exception as e: eprint(e) try: muted = subprocess.check_output("pamixer --get-mute", shell=True).decode( "utf-8").strip() == "true" except subprocess.CalledProcessError: # the command above returns the 'disabled` status w/ CalledProcessError, exit status 1 pass else: eprint("Couldn't get volume, 'pamixer' not found") return vol, muted def list_sinks(): sinks = [] if nwg_panel.common.commands["pamixer"]: try: output = cmd2string("pamixer --list-sinks") if output: lines = output.splitlines()[1:] for line in lines: details = line.split() name = details[1][1:-1] desc = " ".join(details[2:])[1:-1] sinks.append({"name": name, "desc": desc}) except Exception as e: eprint(e) else: eprint("Couldn't list sinks, 'pamixer' not found") return sinks def toggle_mute(*args): if nwg_panel.common.commands["pamixer"]: vol, muted = get_volume() if muted: subprocess.call("pamixer -u".split()) else: subprocess.call("pamixer -m".split()) else: eprint("Couldn't toggle mute, 'pamixer' not found") def set_volume(percent): if nwg_panel.common.commands["pamixer"]: subprocess.call("pamixer --set-volume {}".format(percent).split()) else: eprint("Couldn't set volume, 'pamixer' not found") def get_brightness(device=""): brightness = 0 if nwg_panel.common.commands["light"]: try: cmd = "light -G -s {}".format(device) if device else "light -G" output = cmd2string(cmd) brightness = int(round(float(output), 0)) except: pass elif nwg_panel.common.commands["brightnessctl"]: try: cmd = "brightnessctl g -d {}".format(device) if device else "brightnessctl g" output = cmd2string(cmd) b = int(output) * 100 / 255 brightness = int(round(float(b), 0)) except: pass else: eprint("Couldn't get brightness, is 'light' or 'brightnessctl' installed?") return brightness def set_brightness(percent, device=""): if percent == 0: percent = 1 if nwg_panel.common.commands["light"]: if device: subprocess.call("light -s {} -S {}".format(device, percent).split()) else: subprocess.call("light -S {}".format(percent).split()) elif nwg_panel.common.commands["brightnessctl"]: if device: subprocess.call("brightnessctl -d {} s {}%".format(device, percent).split(), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) else: subprocess.call("brightnessctl s {}%".format(percent).split(), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) else: eprint("Either 'light' or 'brightnessctl' package required") def get_battery(): percent, time, charging = 0, "", False success = False try: b = psutil.sensors_battery() if b: percent = int(round(b.percent, 0)) charging = b.power_plugged seconds = b.secsleft if seconds != psutil.POWER_TIME_UNLIMITED and seconds != psutil.POWER_TIME_UNKNOWN: time = seconds2string(seconds) else: time = "" success = True except: pass if not success and nwg_panel.common.commands["upower"]: lines = subprocess.check_output( "upower -i $(upower -e | grep devices/battery) | grep --color=never -E 'state|to\ full|to\ empty|percentage'", shell=True).decode("utf-8").strip().splitlines() for line in lines: if "state:" in line: charging = line.split(":")[1].strip() == "charging" elif "time to" in line: time = line.split(":")[1].strip() elif "percentage:" in line: try: percent = int(line.split(":")[1].strip()[:-1]) except: pass return percent, time, charging def seconds2string(seconds): minutes, sec = divmod(seconds, 60) hrs, minutes = divmod(minutes, 60) hrs = str(hrs) if len(hrs) < 2: hrs = "0{}".format(hrs) minutes = str(minutes) if len(minutes) < 2: minutes = "0{}".format(minutes) return "{}:{}".format(hrs, minutes) def get_interface(name): try: addrs = netifaces.ifaddresses(name) list = addrs[netifaces.AF_INET] return list[0]["addr"] except: return None def player_status(): status = "install playerctl" if nwg_panel.common.commands["playerctl"]: try: status = cmd2string("playerctl status 2>&1") except: pass return status def player_metadata(): data = "" try: data = cmd2string("playerctl metadata --format '{{artist}} - {{title}}'") except: pass return data def update_image(image, icon_name, icon_size, icons_path=""): # In case a full path was given if icon_name and icon_name.startswith("/"): try: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(icon_name, icon_size, icon_size) image.set_from_pixbuf(pixbuf) except: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( os.path.join(get_config_dir(), "icons_light/icon-missing.svg"), icon_size, icon_size) image.set_from_pixbuf(pixbuf) else: icon_theme = Gtk.IconTheme.get_default() if icons_path: path = "{}/{}.svg".format(icons_path, icon_name) try: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(path, icon_size, icon_size) if image: image.set_from_pixbuf(pixbuf) except: try: pixbuf = icon_theme.load_icon(icon_name, icon_size, Gtk.IconLookupFlags.FORCE_SIZE) if image: image.set_from_pixbuf(pixbuf) except: pass else: try: pixbuf = icon_theme.load_icon(icon_name, icon_size, Gtk.IconLookupFlags.FORCE_SIZE) except: try: pixbuf = icon_theme.load_icon(icon_name.lower(), icon_size, Gtk.IconLookupFlags.FORCE_SIZE) except: path = os.path.join(get_config_dir(), "icons_light/icon-missing.svg") pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(path, icon_size, icon_size) if image: image.set_from_pixbuf(pixbuf) def create_pixbuf(icon_name, icon_size, icons_path=""): # In case a full path was given if icon_name.startswith("/"): try: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( icon_name, icon_size, icon_size) return pixbuf except: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( os.path.join(get_config_dir(), "icons_light/icon-missing.svg"), icon_size, icon_size) return pixbuf icon_theme = Gtk.IconTheme.get_default() if icons_path: path = "{}/{}.svg".format(icons_path, icon_name) try: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( path, icon_size, icon_size) return pixbuf except: try: pixbuf = icon_theme.load_icon(icon_name, icon_size, Gtk.IconLookupFlags.FORCE_SIZE) return pixbuf except: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( os.path.join(get_config_dir(), "icons_light/icon-missing.svg"), icon_size, icon_size) return pixbuf else: try: pixbuf = icon_theme.load_icon(icon_name, icon_size, Gtk.IconLookupFlags.FORCE_SIZE) return pixbuf except: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( os.path.join(get_config_dir(), "icons_light/icon-missing.svg"), icon_size, icon_size) return pixbuf def bt_info(): name, powered = "", False try: info = subprocess.check_output("btmgmt info", shell=True).decode("utf-8").strip().splitlines() for line in info: if "current settings" in line: if "powered" in line: powered = True continue if "name" in line and "short" not in line: name = line.split("name")[1].strip() except: pass return name, powered def list_configs(config_dir): configs = {} # allow to store json files other than panel config files in the config directory # (prevents from crash w/ nwg-drawer>=0.1.7 and future nwg-menu versions) exclusions = [os.path.join(config_dir, "preferred-apps.json")] entries = os.listdir(config_dir) entries.sort() for entry in entries: path = os.path.join(config_dir, entry) if os.path.isfile(path) and path not in exclusions and not path.endswith(".css"): try: with open(path, 'r') as f: config = json.load(f) configs[path] = config except: pass return configs def get_cache_dir(): if os.getenv("XDG_CACHE_HOME"): return os.getenv("XDG_CACHE_HOME") elif os.getenv("HOME") and os.path.isdir(os.path.join(os.getenv("HOME"), ".cache")): return os.path.join(os.getenv("HOME"), ".cache") else: return None