diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..5e39422 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,70 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ main ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ main ] + schedule: + - cron: '31 0 * * 4' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://git.io/codeql-language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.gitignore b/.gitignore index 2bf1ac8..c21fe43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +*.o + # Vim swap files *swp *~ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..6823857 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,6 @@ +version: 2 + +python: + install: + - requirements: docs/requirements.txt + diff --git a/.travis.yml b/.travis.yml index 400bd81..b05ac86 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,26 +1,30 @@ -sudo: false +os: linux language: python +env: + global: + - CC_TEST_REPORTER_ID=40cb00907f7a10e04868e856570bb997ab9c42fd3b63d980f2b2269433195fdf python: - - "3.4" - - "3.5" - "3.6" - "3.7" -before_install: - - sudo apt-get -qq update + - "3.8" + - "3.9" +before_script: + - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter + - chmod +x ./cc-test-reporter + - ./cc-test-reporter before-build +addons: + apt: + packages: + libdbus-1-dev + libgit2-dev + libvirt-dev + taskwarrior install: - - sudo apt-get install python-dbus - - pip install -U coverage==4.3 pytest pytest-mock - - pip install codeclimate-test-reporter - - pip install i3-py Pillow Babel DateTime python-dateutil - - pip install docker feedparser i3ipc - - pip install netifaces power - - pip install psutil pytz - - pip install requests simplejson - - pip install suntime - - pip install tzlocal + - pip install -U coverage pytest pytest-mock freezegun + - pip install 'pygit2<1' 'libvirt-python<6.3' 'feedparser<6' || true + - pip install $(cat requirements/modules/*.txt | cut -d ' ' -f 1 | sort -u) script: - coverage run --source=. -m pytest tests -v - - CODECLIMATE_REPO_TOKEN=40cb00907f7a10e04868e856570bb997ab9c42fd3b63d980f2b2269433195fdf codeclimate-test-reporter -addons: - code_climate: - repo_token: 40cb00907f7a10e04868e856570bb997ab9c42fd3b63d980f2b2269433195fdf +after_script: + - coverage xml + - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT diff --git a/README.md b/README.md index 7c26ddd..ba6261f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # bumblebee-status -[![Build Status](https://travis-ci.org/tobi-wan-kenobi/bumblebee-status.svg?branch=main)](https://travis-ci.org/tobi-wan-kenobi/bumblebee-status) +[![Build Status](https://app.travis-ci.com/tobi-wan-kenobi/bumblebee-status.svg?branch=main)](https://app.travis-ci.com/tobi-wan-kenobi/bumblebee-status) [![Documentation Status](https://readthedocs.org/projects/bumblebee-status/badge/?version=main)](https://bumblebee-status.readthedocs.io/en/main/?badge=main) ![AUR version (release)](https://img.shields.io/aur/version/bumblebee-status) ![AUR version (git)](https://img.shields.io/aur/version/bumblebee-status-git) @@ -8,6 +8,8 @@ [![Code Climate](https://codeclimate.com/github/tobi-wan-kenobi/bumblebee-status/badges/gpa.svg)](https://codeclimate.com/github/tobi-wan-kenobi/bumblebee-status) [![Test Coverage](https://codeclimate.com/github/tobi-wan-kenobi/bumblebee-status/badges/coverage.svg)](https://codeclimate.com/github/tobi-wan-kenobi/bumblebee-status/coverage) [![Issue Count](https://codeclimate.com/github/tobi-wan-kenobi/bumblebee-status/badges/issue_count.svg)](https://codeclimate.com/github/tobi-wan-kenobi/bumblebee-status) +[![CodeQL](https://github.com/tobi-wan-kenobi/bumblebee-status/actions/workflows/codeql-analysis.yml/badge.svg?branch=main)](https://github.com/tobi-wan-kenobi/bumblebee-status/actions/workflows/codeql-analysis.yml) +![License](https://img.shields.io/github/license/tobi-wan-kenobi/bumblebee-status) **Many, many thanks to all contributors! I am still amazed by and deeply grateful for how many PRs this project gets.** @@ -28,16 +30,14 @@ Thanks a lot! Required i3wm version: 4.12+ (in earlier versions, blocks won't have background colors) -Supported Python versions: 3.4, 3.5, 3.6, 3.7, 3.8 +Supported Python versions: 3.4, 3.5, 3.6, 3.7, 3.8, 3.9 Supported FontAwesome version: 4 (free version of 5 doesn't include some of the icons) --- -**NOTE** +***NOTE*** -The default branch for this project is `main` - I'm keeping `master` around for backwards compatibility (I do not want to break anybody's setup), but the default branch is now `main`! - -If you are curious why: [ZDNet:github-master-alternative](https://www.zdnet.com/article/github-to-replace-master-with-alternative-term-to-avoid-slavery-references/) +The default branch for this project is `main`. If you are curious why: [ZDNet:github-master-alternative](https://www.zdnet.com/article/github-to-replace-master-with-alternative-term-to-avoid-slavery-references/) --- @@ -76,10 +76,16 @@ makepkg -sicr pip install --user bumblebee-status ``` +There is also a SlackBuild available here: [slackbuilds:bumblebee-status](http://slackbuilds.org/repository/14.2/desktop/bumblebee-status/) - many thanks to [@Tonus1](https://github.com/Tonus1)! + +An ebuild, for Gentoo Linux, is available on [gallifrey overlay](https://github.com/fedeliallalinea/gallifrey/tree/master/x11-misc/bumblebee-status). Instructions for adding the overlay can be found [here](https://github.com/fedeliallalinea/gallifrey/blob/master/README.md). + # Dependencies [Available modules](https://bumblebee-status.readthedocs.io/en/main/modules.html) lists the dependencies (Python modules and external executables) for each module. If you are not using a module, you don't need the dependencies. +Some themes (e.g. all ‘powerline’ themes) require Font Awesome http://fontawesome.io/ and a powerline-compatible font (powerline-fonts) https://github.com/powerline/fonts + # Usage ## Normal usage In your i3wm configuration, modify the *status_command* for your i3bar like this: diff --git a/bin/get-kbd-layout b/bin/get-kbd-layout new file mode 100755 index 0000000..7ce6ccf Binary files /dev/null and b/bin/get-kbd-layout differ diff --git a/bumblebee-ctl b/bumblebee-ctl index b1c0f37..435162d 100755 --- a/bumblebee-ctl +++ b/bumblebee-ctl @@ -12,6 +12,7 @@ button = { "right-mouse": 3, "wheel-up": 4, "wheel-down": 5, + "update": -1, } @@ -20,7 +21,7 @@ def main(): parser.add_argument( "-b", "--button", - choices=["left-mouse", "right-mouse", "middle-mouse", "wheel-up", "wheel-down"], + choices=["left-mouse", "right-mouse", "middle-mouse", "wheel-up", "wheel-down", "update"], help="button to emulate", default="left-mouse", ) diff --git a/bumblebee-status b/bumblebee-status index dda14e4..698bcf8 100755 --- a/bumblebee-status +++ b/bumblebee-status @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import os import sys @@ -6,7 +6,6 @@ import json import time import signal import socket -import select import logging import threading @@ -39,41 +38,40 @@ class CommandSocket(object): self.__socket.close() os.unlink(self.__name) +def process_event(event_line, config, update_lock): + modules = {} + try: + event = json.loads(event_line) + core.input.trigger(event) + if "name" in event: + modules[event["name"]] = True + except ValueError: + pass -def handle_input(output, update_lock): + delay = float(config.get("engine.input_delay", 0.0)) + if delay > 0: + time.sleep(delay) + if update_lock.acquire(blocking=False) == True: + core.event.trigger("update", modules.keys(), force=True) + core.event.trigger("draw") + update_lock.release() + +def handle_commands(config, update_lock): with CommandSocket() as cmdsocket: - poll = select.poll() - poll.register(sys.stdin.fileno(), select.POLLIN) - poll.register(cmdsocket, select.POLLIN) - while True: - events = poll.poll() + tmp, _ = cmdsocket.accept() + line = tmp.recv(4096).decode() + tmp.close() + logging.debug("socket event {}".format(line)) + process_event(line, config, update_lock) - modules = {} - for fileno, event in events: - if fileno == cmdsocket.fileno(): - tmp, _ = cmdsocket.accept() - line = tmp.recv(4096).decode() - tmp.close() - logging.debug("socket event {}".format(line)) - else: - line = "[" - while line.startswith("["): - line = sys.stdin.readline().strip(",").strip() - logging.info("input event: {}".format(line)) - try: - event = json.loads(line) - core.input.trigger(event) - if "name" in event: - modules[event["name"]] = True - except ValueError: - pass - update_lock.acquire() - core.event.trigger("update", modules.keys()) - core.event.trigger("draw") - update_lock.release() - poll.unregister(sys.stdin.fileno()) +def handle_events(config, update_lock): + while True: + line = sys.stdin.readline().strip(",").strip() + if line == "[": continue + logging.info("input event: {}".format(line)) + process_event(line, config, update_lock) def main(): @@ -100,9 +98,13 @@ def main(): core.input.register(None, core.input.WHEEL_DOWN, "i3-msg workspace next_on_output") update_lock = threading.Lock() - input_thread = threading.Thread(target=handle_input, args=(output, update_lock, )) - input_thread.daemon = True - input_thread.start() + event_thread = threading.Thread(target=handle_events, args=(config, update_lock, )) + event_thread.daemon = True + event_thread.start() + + cmd_thread = threading.Thread(target=handle_commands, args=(config, update_lock, )) + cmd_thread.daemon = True + cmd_thread.start() def sig_USR1_handler(signum,stack): if update_lock.acquire(blocking=False) == True: diff --git a/bumblebee_status/core/config.py b/bumblebee_status/core/config.py index c84d10c..f191673 100644 --- a/bumblebee_status/core/config.py +++ b/bumblebee_status/core/config.py @@ -147,6 +147,13 @@ class Config(util.store.Store): parser = argparse.ArgumentParser( description="bumblebee-status is a modular, theme-able status line generator for the i3 window manager. https://github.com/tobi-wan-kenobi/bumblebee-status/wiki" ) + parser.add_argument( + "-c", + "--config-file", + action="store", + default=None, + help="Specify a configuration file to use" + ) parser.add_argument( "-m", "--modules", nargs="+", action="append", default=[], help=MODULE_HELP ) @@ -172,6 +179,13 @@ class Config(util.store.Store): default=[], help="Specify a list of modules to hide when not in warning/error state", ) + parser.add_argument( + "-e", + "--errorhide", + nargs="+", + default=[], + help="Specify a list of modules that are hidden when in state error" + ) parser.add_argument( "-d", "--debug", action="store_true", help="Add debug fields to i3 output" ) @@ -196,13 +210,18 @@ class Config(util.store.Store): self.__args = parser.parse_args(args) - for cfg in [ - "~/.bumblebee-status.conf", - "~/.config/bumblebee-status.conf", - "~/.config/bumblebee-status/config", - ]: + if self.__args.config_file: + cfg = self.__args.config_file cfg = os.path.expanduser(cfg) self.load_config(cfg) + else: + for cfg in [ + "~/.bumblebee-status.conf", + "~/.config/bumblebee-status.conf", + "~/.config/bumblebee-status/config", + ]: + cfg = os.path.expanduser(cfg) + self.load_config(cfg) parameters = [item for sub in self.__args.parameters for item in sub] for param in parameters: @@ -302,14 +321,21 @@ class Config(util.store.Store): def iconset(self): return self.__args.iconset - """Returns which modules should be hidden if their state is not warning/critical + """Returns whether a module should be hidden if their state is not warning/critical - :return: list of modules to hide automatically - :rtype: list of strings + :return: True if module should be hidden automatically, False otherwise + :rtype: bool """ def autohide(self, name): - return name in self.__args.autohide + return name in self.__args.autohide or name in util.format.aslist(self.get("autohide", [])) + """Returns which modules should be hidden if they are in state error + + :return: returns True if name should be hidden, False otherwise + :rtype: bool + """ + def errorhide(self, name): + return name in self.__args.errorhide # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/core/event.py b/bumblebee_status/core/event.py index 8e969f0..70b6b0c 100644 --- a/bumblebee_status/core/event.py +++ b/bumblebee_status/core/event.py @@ -8,6 +8,13 @@ def register(event, callback, *args, **kwargs): __callbacks.setdefault(event, []).append(cb) +def register_exclusive(event, callback, *args, **kwargs): + cb = callback + if args or kwargs: + cb = lambda: callback(*args, **kwargs) + + __callbacks[event] = [cb] + def unregister(event): if event in __callbacks: del __callbacks[event] diff --git a/bumblebee_status/core/input.py b/bumblebee_status/core/input.py index 9dbc2a6..5752dd8 100644 --- a/bumblebee_status/core/input.py +++ b/bumblebee_status/core/input.py @@ -10,6 +10,7 @@ MIDDLE_MOUSE = 2 RIGHT_MOUSE = 3 WHEEL_UP = 4 WHEEL_DOWN = 5 +UPDATE = -1 def button_name(button): @@ -23,6 +24,8 @@ def button_name(button): return "wheel-up" if button == WHEEL_DOWN: return "wheel-down" + if button == UPDATE: + return "update" return "n/a" @@ -51,10 +54,13 @@ def register(obj, button=None, cmd=None, wait=False): event_id = __event_id(obj.id if obj is not None else "", button) logging.debug("registering callback {}".format(event_id)) core.event.unregister(event_id) # make sure there's always only one input event + if callable(cmd): - core.event.register(event_id, cmd) + core.event.register_exclusive(event_id, cmd) + elif obj and hasattr(obj, cmd) and callable(getattr(obj, cmd)): + core.event.register_exclusive(event_id, lambda event: getattr(obj, cmd)(event)) else: - core.event.register(event_id, lambda event: __execute(event, cmd, wait)) + core.event.register_exclusive(event_id, lambda event: __execute(event, cmd, wait)) def trigger(event): diff --git a/bumblebee_status/core/module.py b/bumblebee_status/core/module.py index 23ea4b5..312862d 100644 --- a/bumblebee_status/core/module.py +++ b/bumblebee_status/core/module.py @@ -17,6 +17,27 @@ except Exception as e: log = logging.getLogger(__name__) +def import_user(module_short, config, theme): + usermod = os.path.expanduser("~/.config/bumblebee-status/modules/{}.py".format(module_short)) + if os.path.exists(usermod): + if hasattr(importlib, "machinery"): + log.debug("importing {} from user via machinery".format(module_short)) + mod = importlib.machinery.SourceFileLoader("modules.{}".format(module_short), + os.path.expanduser(usermod)).load_module() + return getattr(mod, "Module")(config, theme) + else: + log.debug("importing {} from user via importlib.util".format(module_short)) + try: + spec = importlib.util.spec_from_file_location("modules.{}".format(module_short), usermod) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.Module(config, theme) + except Exception as e: + spec = importlib.util.find_spec("modules.{}".format(module_short), usermod) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.Module(config, theme) + raise ImportError("not found") """Loads a module by name @@ -33,20 +54,25 @@ def load(module_name, config=core.config.Config([]), theme=None): error = None module_short, alias = (module_name.split(":") + [module_name])[0:2] config.set("__alias__", alias) - for namespace in ["core", "contrib"]: + + try: + mod = importlib.import_module("modules.core.{}".format(module_short)) + log.debug("importing {} from core".format(module_short)) + return getattr(mod, "Module")(config, theme) + except ImportError as e: try: - mod = importlib.import_module( - "modules.{}.{}".format(namespace, module_short) - ) - log.debug( - "importing {} from {}.{}".format(module_short, namespace, module_short) - ) + log.warning("failed to import {} from core: {}".format(module_short, e)) + mod = importlib.import_module("modules.contrib.{}".format(module_short)) + log.debug("importing {} from contrib".format(module_short)) return getattr(mod, "Module")(config, theme) except ImportError as e: - log.debug("failed to import {}: {}".format(module_name, e)) - error = e - log.fatal("failed to import {}: {}".format(module_name, error)) - return Error(config=config, module=module_name, error=error) + try: + log.warning("failed to import {} from system: {}".format(module_short, e)) + return import_user(module_short, config, theme) + except ImportError as e: + log.fatal("import failed: {}".format(e)) + log.fatal("failed to import {}".format(module_short)) + return Error(config=config, module=module_name, error="unable to load module") class Module(core.input.Object): @@ -69,6 +95,8 @@ class Module(core.input.Object): self.alias = self.__config.get("__alias__", None) self.id = self.alias if self.alias else self.name self.next_update = None + self.minimized = False + self.minimized = self.parameter("start-minimized", False) self.theme = theme @@ -100,6 +128,8 @@ class Module(core.input.Object): for prefix in [self.name, self.module_name, self.alias]: value = self.__config.get("{}.{}".format(prefix, key), value) + if self.minimized: + value = self.__config.get("{}.minimized.{}".format(prefix, key), value) return value """Set a parameter for this module @@ -123,7 +153,7 @@ class Module(core.input.Object): def update_wrapper(self): if self.background == True: - if self.__thread and self.__thread.isAlive(): + if self.__thread and self.__thread.is_alive(): return # skip this update interval self.__thread = threading.Thread(target=self.internal_update, args=(True,)) self.__thread.start() @@ -170,9 +200,9 @@ class Module(core.input.Object): :rtype: bumblebee_status.widget.Widget """ - def add_widget(self, full_text="", name=None): + def add_widget(self, full_text="", name=None, hidden=False): widget_id = "{}::{}".format(self.name, len(self.widgets())) - widget = core.widget.Widget(full_text=full_text, name=name, widget_id=widget_id) + widget = core.widget.Widget(full_text=full_text, name=name, widget_id=widget_id, hidden=hidden) self.widgets().append(widget) widget.module = self return widget diff --git a/bumblebee_status/core/output.py b/bumblebee_status/core/output.py index 687cafd..cee579f 100644 --- a/bumblebee_status/core/output.py +++ b/bumblebee_status/core/output.py @@ -57,6 +57,9 @@ class block(object): def set(self, key, value): self.__attributes[key] = value + def get(self, key, default=None): + return self.__attributes.get(key, default) + def is_pango(self, attr): if isinstance(attr, dict) and "pango" in attr: return True @@ -91,9 +94,17 @@ class block(object): assign(self.__attributes, result, "background", "bg") if "full_text" in self.__attributes: + prefix = self.__pad(self.pangoize(self.__attributes.get("prefix"))) + suffix = self.__pad(self.pangoize(self.__attributes.get("suffix"))) + self.set("_prefix", prefix) + self.set("_suffix", suffix) + self.set("_raw", self.get("full_text")) result["full_text"] = self.pangoize(result["full_text"]) result["full_text"] = self.__format(self.__attributes["full_text"]) + if "min-width" in self.__attributes and "padding" in self.__attributes: + self.set("min-width", self.__format(self.get("min-width"))) + for k in [ "name", "instance", @@ -123,11 +134,8 @@ class block(object): def __format(self, text): if text is None: return None - prefix = self.__pad(self.pangoize(self.__attributes.get("prefix"))) - suffix = self.__pad(self.pangoize(self.__attributes.get("suffix"))) - self.set("_prefix", prefix) - self.set("_suffix", suffix) - self.set("_raw", text) + prefix = self.get("_prefix") + suffix = self.get("_suffix") return "{}{}{}".format(prefix, text, suffix) @@ -158,6 +166,12 @@ class i3(object): def toggle_minimize(self, event): widget_id = event["instance"] + for module in self.__modules: + if module.widget(widget_id=widget_id) and util.format.asbool(module.parameter("minimize", False)) == True: + # this module can customly minimize + module.minimized = not module.minimized + return + if widget_id in self.__content: self.__content[widget_id]["minimized"] = not self.__content[widget_id]["minimized"] @@ -208,14 +222,22 @@ class i3(object): def blocks(self, module): blocks = [] + if module.minimized: + blocks.extend(self.separator_block(module, module.widgets()[0])) + blocks.append(self.__content_block(module, module.widgets()[0])) + return blocks for widget in module.widgets(): if widget.module and self.__config.autohide(widget.module.name): if not any( - state in widget.state() for state in ["warning", "critical"] + state in widget.state() for state in ["warning", "critical", "no-autohide"] ): continue if module.hidden(): continue + if widget.hidden: + continue + if "critical" in widget.state() and self.__config.errorhide(widget.module.name): + continue blocks.extend(self.separator_block(module, widget)) blocks.append(self.__content_block(module, widget)) core.event.trigger("next-widget") diff --git a/bumblebee_status/core/theme.py b/bumblebee_status/core/theme.py index b52c465..4de58d3 100644 --- a/bumblebee_status/core/theme.py +++ b/bumblebee_status/core/theme.py @@ -7,6 +7,7 @@ import glob import core.event import util.algorithm +import util.xresources log = logging.getLogger(__name__) @@ -16,6 +17,7 @@ PATHS = [ os.path.join(THEME_BASE_DIR, "../../themes"), os.path.expanduser("~/.config/bumblebee-status/themes"), os.path.expanduser("~/.local/share/bumblebee-status/themes"), # PIP + "/usr/share/bumblebee-status/themes", ] @@ -89,13 +91,21 @@ class Theme(object): try: if isinstance(name, dict): return name + + result = {} if name.lower() == "wal": wal = self.__load_json("~/.cache/wal/colors.json") - result = {} for field in ["special", "colors"]: for key in wal.get(field, {}): result[key] = wal[field][key] - return result + if name.lower() == "xresources": + for key in ("background", "foreground"): + result[key] = xresources.query(key) + for i in range(16): + key = color + str(i) + result[key] = xresources.query(key) + + return result except Exception as e: log.error("failed to load colors: {}", e) diff --git a/bumblebee_status/core/widget.py b/bumblebee_status/core/widget.py index b1c3b4e..5d823ea 100644 --- a/bumblebee_status/core/widget.py +++ b/bumblebee_status/core/widget.py @@ -10,12 +10,13 @@ log = logging.getLogger(__name__) class Widget(util.store.Store, core.input.Object): - def __init__(self, full_text="", name=None, widget_id=None): + def __init__(self, full_text="", name=None, widget_id=None, hidden=False): super(Widget, self).__init__() self.__full_text = full_text self.module = None self.name = name self.id = widget_id or self.id + self.hidden = hidden @property def module(self): diff --git a/bumblebee_status/modules/contrib/apt.py b/bumblebee_status/modules/contrib/apt.py index 2a41aea..575968f 100644 --- a/bumblebee_status/modules/contrib/apt.py +++ b/bumblebee_status/modules/contrib/apt.py @@ -14,6 +14,7 @@ import threading import core.module import core.widget import core.decorators +import core.input import util.cli @@ -56,6 +57,8 @@ class Module(core.module.Module): def __init__(self, config, theme): super().__init__(config, theme, core.widget.Widget(self.updates)) self.__thread = None + core.input.register(self, button=core.input.RIGHT_MOUSE, + cmd=self.updates) def updates(self, widget): if widget.get("error"): @@ -65,7 +68,7 @@ class Module(core.module.Module): ) def update(self): - if self.__thread and self.__thread.isAlive(): + if self.__thread and self.__thread.is_alive(): return self.__thread = threading.Thread(target=get_apt_check_info, args=(self,)) diff --git a/bumblebee_status/modules/contrib/arandr.py b/bumblebee_status/modules/contrib/arandr.py index 7af565d..b207524 100644 --- a/bumblebee_status/modules/contrib/arandr.py +++ b/bumblebee_status/modules/contrib/arandr.py @@ -54,7 +54,7 @@ class Module(core.module.Module): def activate_layout(layout_path): log.debug("activating layout") log.debug(layout_path) - execute(layout_path) + execute(layout_path, ignore_errors=True) def popup(self, widget): """Create Popup that allows the user to control their displays in one @@ -64,7 +64,7 @@ class Module(core.module.Module): menu = popup.menu() menu.add_menuitem( "arandr", - callback=partial(execute, self.manager) + callback=partial(execute, self.manager, ignore_errors=True) ) menu.add_separator() @@ -105,11 +105,12 @@ class Module(core.module.Module): if count_on == 1: log.info("attempted to turn off last display") return - execute("{} --output {} --off".format(self.toggle_cmd, display)) + execute("{} --output {} --off".format(self.toggle_cmd, display), ignore_errors=True) else: log.debug("toggling on {}".format(display)) execute( - "{} --output {} --auto".format(self.toggle_cmd, display) + "{} --output {} --auto".format(self.toggle_cmd, display), + ignore_errors=True ) @staticmethod @@ -120,7 +121,7 @@ class Module(core.module.Module): connected). """ displays = {} - for line in execute("xrandr -q").split("\n"): + for line in execute("xrandr -q", ignore_errors=True).split("\n"): if "connected" not in line: continue is_on = bool(re.search(r"\d+x\d+\+(\d+)\+\d+", line)) @@ -136,16 +137,19 @@ class Module(core.module.Module): def _get_layouts(): """Loads and parses the arandr screen layout scripts.""" layouts = {} - for filename in os.listdir(__screenlayout_dir__): - if fnmatch.fnmatch(filename, '*.sh'): - fullpath = os.path.join(__screenlayout_dir__, filename) - with open(fullpath, "r") as file: - for line in file: - s_line = line.strip() - if "xrandr" not in s_line: - continue - displays_in_file = Module._parse_layout(line) - layouts[filename] = displays_in_file + try: + for filename in os.listdir(__screenlayout_dir__): + if fnmatch.fnmatch(filename, '*.sh'): + fullpath = os.path.join(__screenlayout_dir__, filename) + with open(fullpath, "r") as file: + for line in file: + s_line = line.strip() + if "xrandr" not in s_line: + continue + displays_in_file = Module._parse_layout(line) + layouts[filename] = displays_in_file + except Exception as e: + log.error(str(e)) return layouts @staticmethod diff --git a/bumblebee_status/modules/contrib/arch-update.py b/bumblebee_status/modules/contrib/arch-update.py index c93545b..ed9ae58 100644 --- a/bumblebee_status/modules/contrib/arch-update.py +++ b/bumblebee_status/modules/contrib/arch-update.py @@ -40,7 +40,7 @@ class Module(core.module.Module): ) if code == 0: - self.__packages = len(result.split("\n")) + self.__packages = len(result.strip().split("\n")) elif code == 2: self.__packages = 0 else: diff --git a/bumblebee_status/modules/contrib/arch_update.py b/bumblebee_status/modules/contrib/arch_update.py new file mode 120000 index 0000000..57fd99f --- /dev/null +++ b/bumblebee_status/modules/contrib/arch_update.py @@ -0,0 +1 @@ +arch-update.py \ No newline at end of file diff --git a/bumblebee_status/modules/contrib/battery_upower.py b/bumblebee_status/modules/contrib/battery_upower.py new file mode 120000 index 0000000..4a7bb68 --- /dev/null +++ b/bumblebee_status/modules/contrib/battery_upower.py @@ -0,0 +1 @@ +battery-upower.py \ No newline at end of file diff --git a/bumblebee_status/modules/contrib/bluetooth.py b/bumblebee_status/modules/contrib/bluetooth.py index b565494..481ae88 100644 --- a/bumblebee_status/modules/contrib/bluetooth.py +++ b/bumblebee_status/modules/contrib/bluetooth.py @@ -106,7 +106,7 @@ class Module(core.module.Module): ) logging.debug("bt: toggling bluetooth") - util.cli.execute(cmd) + util.cli.execute(cmd, ignore_errors=True) def state(self, widget): """Get current state.""" diff --git a/bumblebee_status/modules/contrib/bluetooth2.py b/bumblebee_status/modules/contrib/bluetooth2.py index b8fac09..22eae88 100644 --- a/bumblebee_status/modules/contrib/bluetooth2.py +++ b/bumblebee_status/modules/contrib/bluetooth2.py @@ -69,7 +69,7 @@ class Module(core.module.Module): ) logging.debug("bt: toggling bluetooth") - core.util.execute(cmd) + util.cli.execute(cmd, ignore_errors=True) def state(self, widget): """Get current state.""" diff --git a/bumblebee_status/modules/contrib/deadbeef.py b/bumblebee_status/modules/contrib/deadbeef.py index 948fdc5..0bbba41 100644 --- a/bumblebee_status/modules/contrib/deadbeef.py +++ b/bumblebee_status/modules/contrib/deadbeef.py @@ -5,8 +5,6 @@ some media control bindings. Left click toggles pause, scroll up skips the current song, scroll down returns to the previous song. -Requires the following library: - * subprocess Parameters: * deadbeef.format: Format string (defaults to '{artist} - {title}') Available values are: {artist}, {title}, {album}, {length}, diff --git a/bumblebee_status/modules/contrib/dunstctl.py b/bumblebee_status/modules/contrib/dunstctl.py new file mode 100644 index 0000000..f082f1b --- /dev/null +++ b/bumblebee_status/modules/contrib/dunstctl.py @@ -0,0 +1,42 @@ +# pylint: disable=C0111,R0903 + +"""Toggle dunst notifications using dunstctl. + +When notifications are paused using this module dunst doesn't get killed and +you'll keep getting notifications on the background that will be displayed when +unpausing. This is specially useful if you're using dunst's scripting +(https://wiki.archlinux.org/index.php/Dunst#Scripting), which requires dunst to +be running. Scripts will be executed when dunst gets unpaused. + +Requires: + * dunst v1.5.0+ + +contributed by `cristianmiranda `_ - many thanks! +contributed by `joachimmathes `_ - many thanks! +""" + +import core.module +import core.widget +import core.input +import util.cli + + +class Module(core.module.Module): + def __init__(self, config, theme): + super().__init__(config, theme, core.widget.Widget("")) + core.input.register(self, button=core.input.LEFT_MOUSE, cmd=self.toggle_state) + self.__states = {"unknown": ["unknown", "critical"], + "true": ["muted", "warning"], + "false": ["unmuted"]} + + def toggle_state(self, event): + util.cli.execute("dunstctl set-paused toggle", ignore_errors=True) + + def state(self, widget): + return self.__states[self.__is_dunst_paused()] + + def __is_dunst_paused(self): + result = util.cli.execute("dunstctl is-paused", + return_exitcode=True, + ignore_errors=True) + return result[1].rstrip() if result[0] == 0 else "unknown" diff --git a/bumblebee_status/modules/contrib/emerge_status.py b/bumblebee_status/modules/contrib/emerge_status.py new file mode 100644 index 0000000..3758585 --- /dev/null +++ b/bumblebee_status/modules/contrib/emerge_status.py @@ -0,0 +1,113 @@ +"""Display information about the currently running emerge process. + +Requires the following executable: + * emerge + +Parameters: + * emerge_status.format: Format string (defaults to '{current}/{total} {action} {category}/{pkg}') + +This code is based on emerge_status module from p3status [1] original created by AnwariasEu. + +[1] https://github.com/ultrabug/py3status/blob/master/py3status/modules/emerge_status.py +""" + +import re +import copy + +import core.module +import core.widget +import core.decorators + +import util.cli +import util.format + + +class Module(core.module.Module): + @core.decorators.every(seconds=10) + def __init__(self, config, theme): + super().__init__(config, theme, []) + self.__format = self.parameter( + "format", "{current}/{total} {action} {category}/{pkg}" + ) + self.__ret_default = { + "action": "", + "category": "", + "current": 0, + "pkg": "", + "total": 0, + } + + def update(self): + response = {} + ret = copy.deepcopy(self.__ret_default) + if self.__emerge_running(): + ret = self.__get_progress() + + widget = self.widget("status") + if not widget: + widget = self.add_widget(name="status") + + if ret["total"] == 0: + widget.full_text("emrg calculating...") + else: + widget.full_text( + " ".join( + self.__format.format( + current=ret["current"], + total=ret["total"], + action=ret["action"], + category=ret["category"], + pkg=ret["pkg"], + ).split() + ) + ) + else: + self.clear_widgets() + + def __emerge_running(self): + """ + Check if emerge is running. + Returns true if at least one instance of emerge is running. + """ + try: + util.cli.execute("pgrep emerge") + return True + except Exception: + return False + + def __get_progress(self): + """ + Get current progress of emerge. + Returns a dict containing current and total value. + """ + input_data = [] + ret = {} + + # traverse emerge.log from bottom up to get latest information + last_lines = util.cli.execute("tail -50 /var/log/emerge.log") + input_data = last_lines.split("\n") + input_data.reverse() + + for line in input_data: + if "*** terminating." in line: + # copy content of ret_default, not only the references + ret = copy.deepcopy(self.__ret_default) + break + else: + status_re = re.compile( + r"\((?P[\d]+) of (?P[\d]+)\) " + r"(?P[a-zA-Z/]+( [a-zA-Z]+)?) " + r"\((?P[\w\-]+)/(?P

[\w.]+)" + ) + res = status_re.search(line) + if res is not None: + ret["action"] = res.group("a").lower() + ret["category"] = res.group("ca") + ret["current"] = res.group("cu") + ret["pkg"] = res.group("p") + ret["total"] = res.group("t") + break + return ret + + +# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/modules/contrib/hddtemp.py b/bumblebee_status/modules/contrib/hddtemp.py index a039166..6e269cb 100644 --- a/bumblebee_status/modules/contrib/hddtemp.py +++ b/bumblebee_status/modules/contrib/hddtemp.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -"""Fetch hard drive temeperature data from a hddtemp daemon +"""Fetch hard drive temperature data from a hddtemp daemon that runs on localhost and default port (7634) contributed by `somospocos `_ - many thanks! diff --git a/bumblebee_status/modules/contrib/layout-xkbswitch.py b/bumblebee_status/modules/contrib/layout-xkbswitch.py index 767deb9..a749522 100644 --- a/bumblebee_status/modules/contrib/layout-xkbswitch.py +++ b/bumblebee_status/modules/contrib/layout-xkbswitch.py @@ -19,13 +19,13 @@ class Module(core.module.Module): def __init__(self, config, theme): super().__init__(config, theme, core.widget.Widget(self.current_layout)) - core.input.register(self, button=core.input.LEFT_MOUSE, cmd=self.__next_keymap) + core.input.register(self, button=core.input.LEFT_MOUSE, cmd=self.next_keymap) self.__current_layout = self.__get_current_layout() def current_layout(self, _): return self.__current_layout - def __next_keymap(self, event): + def next_keymap(self, event): util.cli.execute("xkb-switch -n", ignore_errors=True) def __get_current_layout(self): diff --git a/bumblebee_status/modules/contrib/layout_xkbswitch.py b/bumblebee_status/modules/contrib/layout_xkbswitch.py new file mode 120000 index 0000000..e7d6b94 --- /dev/null +++ b/bumblebee_status/modules/contrib/layout_xkbswitch.py @@ -0,0 +1 @@ +layout-xkbswitch.py \ No newline at end of file diff --git a/bumblebee_status/modules/contrib/network.py b/bumblebee_status/modules/contrib/network.py new file mode 100644 index 0000000..a91c947 --- /dev/null +++ b/bumblebee_status/modules/contrib/network.py @@ -0,0 +1,128 @@ +""" +A module to show the currently active network connection (ethernet or wifi) and connection strength if the connection is wireless. + +Requires the Python netifaces package and iw installed on Linux. + +A simpler take on nic and network_traffic. No extra config necessary! + +""" + + +import util.cli +import util.format + +import core.module +import core.widget +import core.input + +import netifaces +import socket + + +class Module(core.module.Module): + @core.decorators.every(seconds=5) + def __init__(self, config, theme): + super().__init__(config, theme, core.widget.Widget(self.network)) + self.__is_wireless = False + self.__is_connected = False + self.__interface = None + self.__message = None + self.__signal = -110 + + # Get network information to display to the user + def network(self, widgets): + # Determine whether there is an internet connection + self.__is_connected = self.__attempt_connection() + + # Attempt to extract a valid network interface device + try: + self.__interface = netifaces.gateways()["default"][netifaces.AF_INET][1] + except Exception: + self.__interface = None + + # Check to see if the interface (if connected to the internet) is wireless + if self.__is_connected and self.__interface: + self.__is_wireless = self.__interface_is_wireless(self.__interface) + + # setup message to send to the user + if not self.__is_connected or not self.__interface: + self.__message = "No connection" + elif not self.__is_wireless: + # Assuming that if user is connected via non-wireless means that it will be ethernet + self.__signal = -30 + self.__message = "Ethernet" + else: + # We have a wireless connection + iw_dat = util.cli.execute("iwgetid") + has_ssid = "ESSID" in iw_dat + signal = self.__compute_signal(self.__interface) + + # If signal is None, that means that we can't compute the default interface's signal strength + self.__signal = ( + util.format.asint(signal, minimum=-110, maximum=-30) if signal else None + ) + + ssid = ( + iw_dat[iw_dat.index(":") + 1 :].replace('"', "").strip() + if has_ssid + else "Unknown" + ) + self.__message = self.__generate_wireles_message(ssid, self.__signal) + + return self.__message + + # State determined by signal strength + def state(self, widget): + if self.__compute_strength(self.__signal) < 50: + return "critical" + if self.__compute_strength(self.__signal) < 75: + return "warning" + + return None + + # manually done for better granularity / ease of parsing strength data + def __generate_wireles_message(self, ssid, signal): + computed_strength = self.__compute_strength(signal) + strength_str = str(computed_strength) if computed_strength else "?" + + return "{} {}%".format(ssid, strength_str) + + def __compute_strength(self, signal): + return int(100 * ((signal + 100) / 70.0)) if signal else None + + # get signal strength in decibels/milliwat + def __compute_signal(self, interface): + # Get connection strength + cmd = "iwconfig {}".format(interface) + config_dat = " ".join(util.cli.execute(cmd).split()) + config_tokens = config_dat.replace("=", " ").split() + + # handle weird output + try: + signal = config_tokens[config_tokens.index("level") + 1] + except Exception: + signal = None + + return signal + + def __attempt_connection(self): + can_connect = False + try: + socket.create_connection(("1.1.1.1", 53)) + can_connect = True + except Exception: + can_connect = False + + return can_connect + + def __interface_is_wireless(self, interface): + is_wireless = False + try: + with open("/proc/net/wireless", "r") as f: + is_wireless = interface in f.read() + f.close() + except Exception: + is_wireless = False + + return is_wireless + diff --git a/bumblebee_status/modules/contrib/network_traffic.py b/bumblebee_status/modules/contrib/network_traffic.py index f9c8c98..ad5781f 100644 --- a/bumblebee_status/modules/contrib/network_traffic.py +++ b/bumblebee_status/modules/contrib/network_traffic.py @@ -97,9 +97,6 @@ class BandwidthInfo(object): """Return default active network adapter""" gateway = netifaces.gateways()["default"] - if not gateway: - raise "No default gateway found" - return gateway[netifaces.AF_INET][1] @classmethod diff --git a/bumblebee_status/modules/contrib/nvidiagpu.py b/bumblebee_status/modules/contrib/nvidiagpu.py index 4aa9de9..3b929a2 100644 --- a/bumblebee_status/modules/contrib/nvidiagpu.py +++ b/bumblebee_status/modules/contrib/nvidiagpu.py @@ -4,11 +4,15 @@ Parameters: * nvidiagpu.format: Format string (defaults to '{name}: {temp}°C %{usedmem}/{totalmem} MiB') - Available values are: {name} {temp} {mem_used} {mem_total} {fanspeed} {clock_gpu} {clock_mem} + Available values are: {name} {temp} {mem_used} {mem_total} {fanspeed} {clock_gpu} {clock_mem} {gpu_usage_pct} {mem_usage_pct} {mem_io_pct} Requires nvidia-smi contributed by `RileyRedpath `_ - many thanks! + +Note: mem_io_pct is (from `man nvidia-smi`): +> Percent of time over the past sample period during which global (device) +> memory was being read or written. """ import core.module @@ -41,6 +45,9 @@ class Module(core.module.Module): clockMem = "" clockGpu = "" fanspeed = "" + gpuUsagePct = "" + memIoPct = "" + memUsage = "not found" for item in sp.split("\n"): try: key, val = item.split(":") @@ -61,10 +68,18 @@ class Module(core.module.Module): name = val elif key == "Fan Speed": fanspeed = val.split(" ")[0] + elif title == "Utilization": + if key == "Gpu": + gpuUsagePct = val.split(" ")[0] + elif key == "Memory": + memIoPct = val.split(" ")[0] except: title = item.strip() + if totalMem and usedMem: + memUsage = int(int(usedMem) / int(totalMem) * 100) + str_format = self.parameter( "format", "{name}: {temp}°C {mem_used}/{mem_total} MiB" ) @@ -76,6 +91,9 @@ class Module(core.module.Module): clock_gpu=clockGpu, clock_mem=clockMem, fanspeed=fanspeed, + gpu_usage_pct=gpuUsagePct, + mem_io_pct=memIoPct, + mem_usage_pct=memUsage, ) diff --git a/bumblebee_status/modules/contrib/octoprint.py b/bumblebee_status/modules/contrib/octoprint.py index a324af2..a7ed8ac 100644 --- a/bumblebee_status/modules/contrib/octoprint.py +++ b/bumblebee_status/modules/contrib/octoprint.py @@ -85,8 +85,15 @@ class Module(core.module.Module): core.input.register(self, button=core.input.LEFT_MOUSE, cmd=self.__show_popup) def octoprint_status(self, widget): - if self.__octoprint_state == "Offline" or self.__octoprint_state == "Unknown": - return self.__octoprint_state + if ( + self.__octoprint_state.startswith("Offline") + or self.__octoprint_state == "Unknown" + ): + return ( + (self.__octoprint_state[:25] + "...") + if len(self.__octoprint_state) > 25 + else self.__octoprint_state + ) return ( self.__octoprint_state + " | B: " diff --git a/bumblebee_status/modules/contrib/optman.py b/bumblebee_status/modules/contrib/optman.py new file mode 100644 index 0000000..337003c --- /dev/null +++ b/bumblebee_status/modules/contrib/optman.py @@ -0,0 +1,30 @@ +"""Displays currently active gpu by optimus-manager +Requires the following packages: + + * optimus-manager + +""" + +import core.module +import core.widget + +import util.cli + +class Module(core.module.Module): + def __init__(self, config, theme): + super().__init__(config, theme, core.widget.Widget(self.output)) + self.__gpumode = "" + + def output(self, _): + return "GPU: {}".format(self.__gpumode) + + def update(self): + cmd = "optimus-manager --print-mode" + output = util.cli.execute(cmd).strip() + + if "intel" in output: + self.__gpumode = "Intel" + elif "nvidia" in output: + self.__gpumode = "Nvidia" + elif "amd" in output: + self.__gpumode = "AMD" diff --git a/bumblebee_status/modules/contrib/persian_date.py b/bumblebee_status/modules/contrib/persian_date.py new file mode 100644 index 0000000..6e3eded --- /dev/null +++ b/bumblebee_status/modules/contrib/persian_date.py @@ -0,0 +1,45 @@ +# pylint: disable=C0111,R0903 + +"""Displays the current date and time in Persian(Jalali) Calendar. + +Requires the following python packages: + * jdatetime + +Parameters: + * datetime.format: strftime()-compatible formatting string. default: "%A %d %B" e.g., "جمعه ۱۳ اسفند" + * datetime.locale: locale to use. default: "fa_IR" +""" + +from __future__ import absolute_import +import jdatetime +import locale + +import core.module +import core.widget +import core.input + + +class Module(core.module.Module): + def __init__(self, config, theme): + super().__init__(config, theme, core.widget.Widget(self.full_text)) + + l = ("fa_IR", "UTF-8") + lcl = self.parameter("locale", ".".join(l)) + try: + locale.setlocale(locale.LC_ALL, lcl.split(".")) + except Exception as e: + locale.setlocale(locale.LC_ALL, ("fa_IR", "UTF-8")) + + def default_format(self): + return "%A %d %B" + + def full_text(self, widget): + enc = locale.getpreferredencoding() + fmt = self.parameter("format", self.default_format()) + retval = jdatetime.datetime.now().strftime(fmt) + if hasattr(retval, "decode"): + return retval.decode(enc) + return retval + + +# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/modules/contrib/playerctl.py b/bumblebee_status/modules/contrib/playerctl.py index b145500..56af426 100755 --- a/bumblebee_status/modules/contrib/playerctl.py +++ b/bumblebee_status/modules/contrib/playerctl.py @@ -5,57 +5,116 @@ Requires the following executable: * playerctl -contributed by `smitajit `_ - many thanks! +Parameters: + * playerctl.format: Format string (defaults to '{{artist}} - {{title}} {{duration(position)}}/{{duration(mpris:length)}}'). + The format string is passed to 'playerctl -f' as an argument. Read `the README `_ for more information. + * playerctl.layout: Comma-separated list to change order of widgets (defaults to song, previous, pause, next) + Widget names are: playerctl.song, playerctl.prev, playerctl.pause, playerctl.next + * playerctl.args: The arguments added to playerctl. + You can check 'playerctl --help' or `its README `_. For example, it could be '-p vlc,%any'. +Parameters are inspired by the `spotify` module, many thanks to its developers! + +contributed by `smitajit `_ - many thanks! """ import core.module import core.widget import core.input import util.cli +import util.format + +import logging class Module(core.module.Module): - def __init__(self,config , theme): - widgets = [ - core.widget.Widget(name="playerctl.prev"), - core.widget.Widget(name="playerctl.main", full_text=self.description), - core.widget.Widget(name="playerctl.next"), - ] - super(Module, self).__init__(config, theme , widgets) + def __init__(self, config, theme): + super(Module, self).__init__(config, theme, []) - core.input.register(widgets[0], button=core.input.LEFT_MOUSE, - cmd="playerctl previous") - core.input.register(widgets[1], button=core.input.LEFT_MOUSE, - cmd="playerctl play-pause") - core.input.register(widgets[2], button=core.input.LEFT_MOUSE, - cmd="playerctl next") + self.background = True - self._status = None - self._tags = None + self.__layout = util.format.aslist( + self.parameter( + "layout", "playerctl.prev, playerctl.song, playerctl.pause, playerctl.next" + ) + ) - def description(self, widget): - return self._tags if self._tags else "..." + self.__cmd = "playerctl " + self.parameter("args", "") + " " + self.__format = self.parameter("format", "{{artist}} - {{title}} {{duration(position)}}/{{duration(mpris:length)}}") + + widget_map = {} + for widget_name in self.__layout: + widget = self.add_widget(name=widget_name) + if widget_name == "playerctl.prev": + widget_map[widget] = { + "button": core.input.LEFT_MOUSE, + "cmd": self.__cmd + "previous", + } + elif widget_name == "playerctl.pause": + widget_map[widget] = { + "button": core.input.LEFT_MOUSE, + "cmd": self.__cmd + "play-pause", + } + elif widget_name == "playerctl.next": + widget_map[widget] = { + "button": core.input.LEFT_MOUSE, + "cmd": self.__cmd + "next", + } + elif widget_name == "playerctl.song": + widget_map[widget] = [ + { + "button": core.input.LEFT_MOUSE, + "cmd": self.__cmd + "play-pause", + }, { + "button": core.input.WHEEL_UP, + "cmd": self.__cmd + "next", + }, { + "button": core.input.WHEEL_DOWN, + "cmd": self.__cmd + "previous", + } + ] + else: + raise KeyError( + "The playerctl module does not have a {widget_name!r} widget".format( + widget_name=widget_name + ) + ) + + for widget, callback_options in widget_map.items(): + if isinstance(callback_options, dict): + core.input.register(widget, **callback_options) def update(self): - self._load_song() - - def state(self, widget): - if widget.name == "playerctl.prev": - return "prev" - if widget.name == "playerctl.next": - return "next" - return self._status - - def _load_song(self): - info = "" try: - status = util.cli.execute("playerctl status").lower() - info = util.cli.execute("playerctl metadata xesam:title") - except : - self._status = None - self._tags = None - return - self._status = status.split("\n")[0].lower() - self._tags = info.split("\n")[0][:20] + playback_status = str(util.cli.execute(self.__cmd + "status 2>&1 || true", shell = True)).strip() + if playback_status == "No players found": + playback_status = None + except Exception as e: + logging.exception(e) + playback_status = None + for widget in self.widgets(): + if playback_status: + if widget.name == "playerctl.pause": + if playback_status == "Playing": + widget.set("state", "playing") + elif playback_status == "Paused": + widget.set("state", "paused") + elif playback_status == "Stopped": + widget.set("state", "stopped") + else: + widget.set("state", "") + elif widget.name == "playerctl.next": + widget.set("state", "next") + elif widget.name == "playerctl.prev": + widget.set("state", "prev") + elif widget.name == "playerctl.song": + widget.full_text(self.__get_song()) + else: + widget.set("state", "") + widget.full_text(" ") -# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 + def __get_song(self): + try: + return str(util.cli.execute(self.__cmd + "metadata -f '" + self.__format + "'")).strip() + except Exception as e: + logging.exception(e) + return " " diff --git a/bumblebee_status/modules/contrib/progress.py b/bumblebee_status/modules/contrib/progress.py index a1938d2..7e148b3 100644 --- a/bumblebee_status/modules/contrib/progress.py +++ b/bumblebee_status/modules/contrib/progress.py @@ -101,7 +101,7 @@ class Module(core.module.Module): def state(self, widget): if self.__active: - return "copying" + return ["copying", "no-autohide"] return "pending" diff --git a/bumblebee_status/modules/contrib/publicip.py b/bumblebee_status/modules/contrib/publicip.py index 17a23e3..a74d708 100644 --- a/bumblebee_status/modules/contrib/publicip.py +++ b/bumblebee_status/modules/contrib/publicip.py @@ -16,13 +16,13 @@ class Module(core.module.Module): self.__ip = "" def public_ip(self, widget): - return self.__ip + return self.__ip or "n/a" def update(self): try: self.__ip = util.location.public_ip() except Exception: - self.__ip = "n/a" + self.__ip = None # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/modules/contrib/rofication.py b/bumblebee_status/modules/contrib/rofication.py new file mode 100644 index 0000000..79e6afd --- /dev/null +++ b/bumblebee_status/modules/contrib/rofication.py @@ -0,0 +1,48 @@ +"""Rofication indicator + + https://github.com/DaveDavenport/Rofication + simple module to show an icon + the number of notifications stored in rofication + module will have normal highlighting if there are zero notifications, + "warning" highlighting if there are nonzero notifications, + "critical" highlighting if there are any critical notifications +""" + +import core.module +import core.widget +import core.decorators + +import sys +import socket + +class Module(core.module.Module): + @core.decorators.every(seconds=5) + def __init__(self, config, theme): + super().__init__(config, theme, core.widget.Widget(self.full_text)) + self.__critical = False + self.__numnotifications = 0 + + + def full_text(self, widgets): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.connect("/tmp/rofi_notification_daemon") + # below code will fetch two numbers in a list, e.g. ['22', '1'] + # first is total number of notifications, second is number of critical notifications + client.sendall(bytes("num", "utf-8")) + val = client.recv(512) + val = val.decode("utf-8") + l = val.split('\n',2) + self.__numnotifications = int(l[0]) + self.__critical = bool(int(l[1])) + return self.__numnotifications + + def state(self, widget): + # rofication doesn't really support the idea of seen vs unseen notifications + # marking a message as "seen" actually just sets its urgency to normal + # so, doing highlighting if any notifications are present + if self.__critical: + return ["critical"] + elif self.__numnotifications: + return ["warning"] + return [] + +# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/modules/contrib/rotation.py b/bumblebee_status/modules/contrib/rotation.py index 13f656e..d05c463 100644 --- a/bumblebee_status/modules/contrib/rotation.py +++ b/bumblebee_status/modules/contrib/rotation.py @@ -31,14 +31,13 @@ class Module(core.module.Module): orientation = curr_orient break - widget = self.widget(display) + widget = self.widget(name=display) if not widget: widget = self.add_widget(full_text=display, name=display) core.input.register( widget, button=core.input.LEFT_MOUSE, cmd=self.__toggle ) widget.set("orientation", orientation) - widgets.append(widget) def state(self, widget): return widget.get("orientation", "normal") diff --git a/bumblebee_status/modules/contrib/rss.py b/bumblebee_status/modules/contrib/rss.py index 7b8c032..7824e2e 100644 --- a/bumblebee_status/modules/contrib/rss.py +++ b/bumblebee_status/modules/contrib/rss.py @@ -55,7 +55,7 @@ class Module(core.module.Module): self._state = [] - self._newspaper_filename = tempfile.mktemp(".html") + self._newspaper_file = tempfile.NamedTemporaryFile(mode="w", suffix=".html") self._last_refresh = 0 self._last_update = 0 @@ -308,10 +308,11 @@ class Module(core.module.Module): while newspaper_items: content += self._create_news_section(newspaper_items) - open(self._newspaper_filename, "w").write( + self._newspaper_file.write( HTML_TEMPLATE.replace("[[CONTENT]]", content) ) - webbrowser.open("file://" + self._newspaper_filename) + self._newspaper_file.flush() + webbrowser.open("file://" + self._newspaper_file.name) self._update_history("newspaper") self._save_history() diff --git a/bumblebee_status/modules/contrib/sensors.py b/bumblebee_status/modules/contrib/sensors.py index 68b792a..7d42c83 100644 --- a/bumblebee_status/modules/contrib/sensors.py +++ b/bumblebee_status/modules/contrib/sensors.py @@ -4,6 +4,7 @@ """Displays sensor temperature Parameters: + * sensors.use_sensors: whether to use the sensors command * sensors.path: path to temperature file (default /sys/class/thermal/thermal_zone0/temp). * sensors.json: if set to 'true', interpret sensors.path as JSON 'path' in the output of 'sensors -j' (i.e. //.../), for example, path could @@ -18,6 +19,7 @@ contributed by `mijoharas `_ - many thanks! """ import re +import os import json import logging @@ -46,22 +48,25 @@ class Module(core.module.Module): self._json = util.format.asbool(self.parameter("json", False)) self._freq = util.format.asbool(self.parameter("show_freq", True)) core.input.register(self, button=core.input.LEFT_MOUSE, cmd="xsensors") - self.determine_method() + self.use_sensors = self.determine_method() def determine_method(self): + if util.format.asbool(self.parameter("use_sensors")) == True: + return True + if util.format.asbool(self.parameter("use_sensors")) == False: + return False if self.parameter("path") != None and self._json == False: - self.use_sensors = False # use thermal zone - else: - # try to use output of sensors -u - try: - output = util.cli.execute("sensors -u") - self.use_sensors = True - log.debug("Sensors command available") - except FileNotFoundError as e: - log.info( - "Sensors command not available, using /sys/class/thermal/thermal_zone*/" - ) - self.use_sensors = False + return False + # try to use output of sensors -u + try: + _ = util.cli.execute("sensors -u") + log.debug("Sensors command available") + return True + except FileNotFoundError as e: + log.info( + "Sensors command not available, using /sys/class/thermal/thermal_zone*/" + ) + return False def _get_temp_from_sensors(self): if self._json == True: @@ -92,22 +97,31 @@ class Module(core.module.Module): def get_temp(self): if self.use_sensors: - temperature = self._get_temp_from_sensors() log.debug("Retrieve temperature from sensors -u") - else: - try: - temperature = open( - self.parameter("path", "/sys/class/thermal/thermal_zone0/temp") - ).read()[:2] - log.debug("retrieved temperature from /sys/class/") - # TODO: Iterate through all thermal zones to determine the correct one and use its value - # https://unix.stackexchange.com/questions/304845/discrepancy-between-number-of-cores-and-thermal-zones-in-sys-class-thermal - - except IOError: - temperature = "unknown" - log.info("Can not determine temperature, please install lm-sensors") - - return temperature + return self._get_temp_from_sensors() + try: + path = None + # use path provided by the user + if self.parameter("path") is not None: + path = self.parameter("path") + # find the thermal zone that provides cpu temperature + else: + for zone in os.listdir("/sys/class/thermal"): + if not zone.startswith("thermal_zone"): + continue + if open(f"/sys/class/thermal/{zone}/type").read().strip() != "x86_pkg_temp": + continue + path = f"/sys/class/thermal/{zone}/temp" + # use zone 0 as fallback + if path is None: + log.info("Can not determine temperature path, using thermal_zone0") + path = "/sys/class/thermal/thermal_zone0/temp" + log.debug(f"retrieving temperature from {path}") + # the values are t°C * 1000, so divide by 1000 + return str(int(open(path).read()) / 1000) + except IOError: + log.info("Can not determine temperature, please install lm-sensors") + return "unknown" def get_mhz(self): mhz = None diff --git a/bumblebee_status/modules/contrib/shell.py b/bumblebee_status/modules/contrib/shell.py index 6fd9aa2..566de42 100644 --- a/bumblebee_status/modules/contrib/shell.py +++ b/bumblebee_status/modules/contrib/shell.py @@ -47,13 +47,13 @@ class Module(core.module.Module): self.__output = "please wait..." self.__current_thread = threading.Thread() - # LMB and RMB will update output regardless of timer - core.input.register(self, button=core.input.LEFT_MOUSE, cmd=self.update) - core.input.register(self, button=core.input.RIGHT_MOUSE, cmd=self.update) + if self.parameter("scrolling.makewide") is None: + self.set("scrolling.makewide", False) def set_output(self, value): self.__output = value + @core.decorators.scrollable def get_output(self, _): return self.__output diff --git a/bumblebee_status/modules/contrib/shortcut.py b/bumblebee_status/modules/contrib/shortcut.py index 4ea0a7c..ff5b47f 100644 --- a/bumblebee_status/modules/contrib/shortcut.py +++ b/bumblebee_status/modules/contrib/shortcut.py @@ -4,12 +4,12 @@ when clicking on it. For more than one shortcut, the commands and labels are strings separated by -a demiliter (; semicolon by default). +a delimiter (; semicolon by default). For example in order to create two shortcuts labeled A and B with commands cmdA and cmdB you could do: - ./bumblebee-status -m shortcut -p shortcut.cmd='ls;ps' shortcut.label='A;B' + ./bumblebee-status -m shortcut -p shortcut.cmd='firefox https://www.google.com;google-chrome https://google.com' shortcut.label='Google (Firefox);Google (Chrome)' Parameters: * shortcut.cmds : List of commands to execute diff --git a/bumblebee_status/modules/contrib/smartstatus.py b/bumblebee_status/modules/contrib/smartstatus.py index b5f3037..81060da 100644 --- a/bumblebee_status/modules/contrib/smartstatus.py +++ b/bumblebee_status/modules/contrib/smartstatus.py @@ -10,7 +10,7 @@ Requires the following executables: * smartctl Parameters: - * smartstatus.display: how to display (defaults to 'combined', other choices: 'seperate' or 'singles') + * smartstatus.display: how to display (defaults to 'combined', other choices: 'combined_singles', 'seperate' or 'singles') * smartstatus.drives: in the case of singles which drives to display, separated comma list value, multiple accepted (defaults to 'sda', example:'sda,sdc') * smartstatus.show_names: boolean in the form of "True" or "False" to show the name of the drives in the form of sda, sbd, combined or none at all. """ @@ -38,7 +38,7 @@ class Module(core.module.Module): self.create_widgets() def create_widgets(self): - if self.display == "combined": + if self.display == "combined" or self.display == "combined_singles": widget = self.add_widget() widget.set("device", "combined") widget.set("assessment", self.combined()) @@ -81,6 +81,8 @@ class Module(core.module.Module): def combined(self): for device in self.devices: + if self.display == "combined_singles" and device not in self.drives: + continue result = self.smart(device) if result == "Fail": return "Fail" diff --git a/bumblebee_status/modules/contrib/solaar.py b/bumblebee_status/modules/contrib/solaar.py new file mode 100644 index 0000000..b7396f3 --- /dev/null +++ b/bumblebee_status/modules/contrib/solaar.py @@ -0,0 +1,58 @@ +"""Shows status and load percentage of logitech's unifying device + +Requires the following executable: + * solaar (from community) + +contributed by `cambid `_ - many thanks! +""" + +import logging + +import core.module +import core.widget +import core.decorators + +import util.cli + + +class Module(core.module.Module): + @core.decorators.every(seconds=30) + def __init__(self, config, theme): + super().__init__(config, theme, core.widget.Widget(self.utilization)) + self.__battery = self.parameter("device", "") + self.background = True + self.__battery_status = "" + self.__error = False + if self.__battery != "": + self.__cmd = f"solaar show '{self.__battery}'" + else: + self.__cmd = "solaar show" + + @property + def __format(self): + return self.parameter("format", "{}") + + def utilization(self, widget): + return self.__format.format(self.__battery_status) + + def update(self): + self.__error = False + code, result = util.cli.execute( + self.__cmd, ignore_errors=True, return_exitcode=True + ) + + if code == 0: + for line in result.split('\n'): + if line.count('Battery') > 0: + self.__battery_status = line.split(':')[1].strip() + else: + self.__error = True + logging.error(f"solaar exited with {code}: {result}") + + def state(self, widget): + if self.__error: + return "warning" + return "okay" + + +# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/modules/contrib/spaceapi.py b/bumblebee_status/modules/contrib/spaceapi.py index d29c081..a6c2772 100644 --- a/bumblebee_status/modules/contrib/spaceapi.py +++ b/bumblebee_status/modules/contrib/spaceapi.py @@ -9,7 +9,6 @@ an example. Requires the following libraries: * requests - * regex Parameters: * spaceapi.url: String representation of the api endpoint diff --git a/bumblebee_status/modules/contrib/spotify.py b/bumblebee_status/modules/contrib/spotify.py index 85b8c31..1c7f7e2 100644 --- a/bumblebee_status/modules/contrib/spotify.py +++ b/bumblebee_status/modules/contrib/spotify.py @@ -8,6 +8,10 @@ Parameters: Available values are: {album}, {title}, {artist}, {trackNumber} * spotify.layout: Comma-separated list to change order of widgets (defaults to song, previous, pause, next) Widget names are: spotify.song, spotify.prev, spotify.pause, spotify.next + * spotify.concise_controls: When enabled, allows spotify to be controlled from just the spotify.song widget. + Concise controls are: Left Click: Toggle Pause; Wheel Up: Next; Wheel Down; Previous. + * spotify.bus_name: String (defaults to `spotify`) + Available values: spotify, spotifyd contributed by `yvesh `_ - many thanks! @@ -25,32 +29,97 @@ import core.input import core.decorators import util.format +import logging class Module(core.module.Module): def __init__(self, config, theme): super().__init__(config, theme, []) + self.background = True + + self.__bus_name = self.parameter("bus_name", "spotify") + self.__layout = util.format.aslist( self.parameter( "layout", "spotify.song,spotify.prev,spotify.pause,spotify.next", ) ) + self.__bus = dbus.SessionBus() self.__song = "" self.__pause = "" self.__format = self.parameter("format", "{artist} - {title}") - self.__cmd = "dbus-send --session --type=method_call --dest=org.mpris.MediaPlayer2.spotify \ + if self.__bus_name == "spotifyd": + self.__cmd = "dbus-send --session --type=method_call --dest=org.mpris.MediaPlayer2.spotifyd \ /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player." + else: + self.__cmd = "dbus-send --session --type=method_call --dest=org.mpris.MediaPlayer2.spotify \ + /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player." + + widget_map = {} + for widget_name in self.__layout: + widget = self.add_widget(name=widget_name) + if widget_name == "spotify.prev": + widget_map[widget] = { + "button": core.input.LEFT_MOUSE, + "cmd": self.__cmd + "Previous", + } + widget.set("state", "prev") + elif widget_name == "spotify.pause": + widget_map[widget] = { + "button": core.input.LEFT_MOUSE, + "cmd": self.__cmd + "PlayPause", + } + elif widget_name == "spotify.next": + widget_map[widget] = { + "button": core.input.LEFT_MOUSE, + "cmd": self.__cmd + "Next", + } + widget.set("state", "next") + elif widget_name == "spotify.song": + if util.format.asbool(self.parameter("concise_controls", "false")): + widget_map[widget] = [ + { + "button": core.input.LEFT_MOUSE, + "cmd": self.__cmd + "PlayPause", + }, { + "button": core.input.WHEEL_UP, + "cmd": self.__cmd + "Next", + }, { + "button": core.input.WHEEL_DOWN, + "cmd": self.__cmd + "Previous", + } + ] + else: + raise KeyError( + "The spotify module does not have a {widget_name!r} widget".format( + widget_name=widget_name + ) + ) + # is there any reason the inputs can't be directly registered above? + for widget, callback_options in widget_map.items(): + if isinstance(callback_options, dict): + core.input.register(widget, **callback_options) + + elif isinstance(callback_options, list): # used by concise_controls + for opts in callback_options: + core.input.register(widget, **opts) + def hidden(self): return self.string_song == "" def __get_song(self): - bus = dbus.SessionBus() - spotify = bus.get_object( - "org.mpris.MediaPlayer2.spotify", "/org/mpris/MediaPlayer2" - ) + bus = self.__bus + if self.__bus_name == "spotifyd": + spotify = bus.get_object( + "org.mpris.MediaPlayer2.spotifyd", "/org/mpris/MediaPlayer2" + ) + else: + spotify = bus.get_object( + "org.mpris.MediaPlayer2.spotify", "/org/mpris/MediaPlayer2" + ) spotify_iface = dbus.Interface(spotify, "org.freedesktop.DBus.Properties") props = spotify_iface.Get("org.mpris.MediaPlayer2.Player", "Metadata") self.__song = self.__format.format( @@ -62,29 +131,22 @@ class Module(core.module.Module): def update(self): try: - self.clear_widgets() self.__get_song() - widget_map = {} - for widget_name in self.__layout: - widget = self.add_widget(name=widget_name) - if widget_name == "spotify.prev": - widget_map[widget] = { - "button": core.input.LEFT_MOUSE, - "cmd": self.__cmd + "Previous", - } - widget.set("state", "prev") - elif widget_name == "spotify.pause": - widget_map[widget] = { - "button": core.input.LEFT_MOUSE, - "cmd": self.__cmd + "PlayPause", - } + if self.__bus_name == "spotifyd": + bus = self.__bus.get_object( + "org.mpris.MediaPlayer2.spotifyd", "/org/mpris/MediaPlayer2" + ) + else: + bus = self.__bus.get_object( + "org.mpris.MediaPlayer2.spotify", "/org/mpris/MediaPlayer2" + ) + + for widget in self.widgets(): + if widget.name == "spotify.pause": playback_status = str( dbus.Interface( - dbus.SessionBus().get_object( - "org.mpris.MediaPlayer2.spotify", - "/org/mpris/MediaPlayer2", - ), + bus, "org.freedesktop.DBus.Properties", ).Get("org.mpris.MediaPlayer2.Player", "PlaybackStatus") ) @@ -92,25 +154,11 @@ class Module(core.module.Module): widget.set("state", "playing") else: widget.set("state", "paused") - elif widget_name == "spotify.next": - widget_map[widget] = { - "button": core.input.LEFT_MOUSE, - "cmd": self.__cmd + "Next", - } - widget.set("state", "next") - elif widget_name == "spotify.song": + elif widget.name == "spotify.song": widget.set("state", "song") widget.full_text(self.__song) - else: - raise KeyError( - "The spotify module does not have a {widget_name!r} widget".format( - widget_name=widget_name - ) - ) - for widget, callback_options in widget_map.items(): - core.input.register(widget, **callback_options) - except Exception: + except Exception as e: self.__song = "" @property diff --git a/bumblebee_status/modules/contrib/stock.py b/bumblebee_status/modules/contrib/stock.py index 36afe17..224a5fb 100644 --- a/bumblebee_status/modules/contrib/stock.py +++ b/bumblebee_status/modules/contrib/stock.py @@ -3,9 +3,6 @@ """Display a stock quote from finance.yahoo.com -Requires the following python packages: - * requests - Parameters: * stock.symbols : Comma-separated list of symbols to fetch * stock.change : Should we fetch change in stock value (defaults to True) diff --git a/bumblebee_status/modules/contrib/sun.py b/bumblebee_status/modules/contrib/sun.py index 6b0734d..34a4b71 100644 --- a/bumblebee_status/modules/contrib/sun.py +++ b/bumblebee_status/modules/contrib/sun.py @@ -8,8 +8,8 @@ Requires the following python packages: * python-dateutil Parameters: - * cpu.lat : Latitude of your location - * cpu.lon : Longitude of your location + * sun.lat : Latitude of your location + * sun.lon : Longitude of your location (if none of those are set, location is determined automatically via location APIs) @@ -39,7 +39,11 @@ class Module(core.module.Module): self.__sun = None if not lat or not lon: - lat, lon = util.location.coordinates() + try: + lat, lon = util.location.coordinates() + except Exception: + pass + if lat and lon: self.__sun = Sun(float(lat), float(lon)) @@ -55,6 +59,10 @@ class Module(core.module.Module): return "n/a" def __calculate_times(self): + if not self.__sun: + self.__sunset = self.__sunrise = None + return + self.__isup = False order_matters = True diff --git a/bumblebee_status/modules/contrib/system.py b/bumblebee_status/modules/contrib/system.py index e96ee3f..cc46ef8 100644 --- a/bumblebee_status/modules/contrib/system.py +++ b/bumblebee_status/modules/contrib/system.py @@ -21,6 +21,9 @@ Parameters: * system.suspend: specify a command for suspending (defaults to 'i3exit suspend') * system.hibernate: specify a command for hibernating (defaults to 'i3exit hibernate') +Requirements: + tkinter (python3-tk package on debian based systems either you can install it as python package) + contributed by `bbernhard `_ - many thanks! """ diff --git a/bumblebee_status/modules/contrib/thunderbird.py b/bumblebee_status/modules/contrib/thunderbird.py new file mode 100644 index 0000000..5240e6b --- /dev/null +++ b/bumblebee_status/modules/contrib/thunderbird.py @@ -0,0 +1,89 @@ +# pylint: disable=C0111,R0903 + +""" +Displays the unread emails count for one or more Thunderbird inboxes + +Parameters: + * thunderbird.home: Absolute path of your .thunderbird directory (e.g.: /home/pi/.thunderbird) + * thunderbird.inboxes: Comma separated values for all MSF inboxes and their parent directory (account) (e.g.: imap.gmail.com/INBOX.msf,outlook.office365.com/Work.msf) + +Tips: + * You can run the following command in order to list all your Thunderbird inboxes + + find ~/.thunderbird -name '*.msf' | awk -F '/' '{print $(NF-1)"/"$(NF)}' + +contributed by `cristianmiranda `_ - many thanks! +""" + +import core.module +import core.widget +import core.decorators +import core.input + +import util.cli + + +class Module(core.module.Module): + @core.decorators.every(minutes=1) + def __init__(self, config, theme): + super().__init__(config, theme, core.widget.Widget(self.thunderbird)) + + self.__total = 0 + self.__label = "" + self.__inboxes = [] + + self.__home = self.parameter("home", "") + inboxes = self.parameter("inboxes", "") + if inboxes: + self.__inboxes = util.format.aslist(inboxes) + + def thunderbird(self, _): + return str(self.__label) + + def update(self): + try: + self.__total = 0 + self.__label = "" + + stream = self.__getThunderbirdStream() + unread = self.__getUnreadMessagesByInbox(stream) + + counts = [] + for inbox in self.__inboxes: + count = unread[inbox] + self.__total += int(count) + counts.append(count) + + self.__label = "/".join(counts) + + except Exception as err: + self.__label = err + + def __getThunderbirdStream(self): + cmd = ( + "find " + + self.__home + + " -name '*.msf' -exec grep -REo 'A2=[0-9]' {} + | grep" + ) + for inbox in self.__inboxes: + cmd += " -e {}".format(inbox) + cmd += "| awk -F / '{print $(NF-1)\"/\"$(NF)}'" + + return util.cli.execute(cmd, shell=True).strip().split("\n") + + def __getUnreadMessagesByInbox(self, stream): + unread = {} + for line in stream: + entry = line.split(":A2=") + inbox = entry[0] + count = entry[1] + unread[inbox] = count + + return unread + + def state(self, widget): + if self.__total > 0: + return ["warning"] + + +# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/modules/contrib/todo.py b/bumblebee_status/modules/contrib/todo.py index 878b63f..76e289e 100644 --- a/bumblebee_status/modules/contrib/todo.py +++ b/bumblebee_status/modules/contrib/todo.py @@ -21,9 +21,10 @@ class Module(core.module.Module): super().__init__(config, theme, core.widget.Widget(self.output)) self.__doc = os.path.expanduser(self.parameter("file", "~/Documents/todo.txt")) + self.__editor = self.parameter("editor", "xdg-open") self.__todos = self.count_items() core.input.register( - self, button=core.input.LEFT_MOUSE, cmd="xdg-open {}".format(self.__doc) + self, button=core.input.LEFT_MOUSE, cmd="{} {}".format(self.__editor, self.__doc) ) def output(self, widget): @@ -39,11 +40,12 @@ class Module(core.module.Module): def count_items(self): try: - i = -1 + i = 0 with open(self.__doc) as f: - for i, l in enumerate(f): - pass - return i + 1 + for l in f.readlines(): + if l.strip() != '': + i += 1 + return i except Exception: return 0 diff --git a/bumblebee_status/modules/contrib/todo_org.py b/bumblebee_status/modules/contrib/todo_org.py new file mode 100644 index 0000000..05fa90b --- /dev/null +++ b/bumblebee_status/modules/contrib/todo_org.py @@ -0,0 +1,57 @@ +# pylint: disable=C0111,R0903 +"""Displays the number of todo items from an org-mode file +Parameters: + * todo_org.file: File to read TODOs from (defaults to ~/org/todo.org) + * todo_org.remaining: False by default. When true, will output the number of remaining todos instead of the number completed (i.e. 1/4 means 1 of 4 todos remaining, rather than 1 of 4 todos completed) +Based on the todo module by `codingo ` +""" + +import re +import os.path + +import core.module +import core.widget +import core.input +from util.format import asbool + +class Module(core.module.Module): + def __init__(self, config, theme): + super().__init__(config, theme, core.widget.Widget(self.output)) + + self.__todo_regex = re.compile("^\\s*\\*+\\s*TODO") + self.__done_regex = re.compile("^\\s*\\*+\\s*DONE") + + self.__doc = os.path.expanduser( + self.parameter("file", "~/org/todo.org") + ) + self.__remaining = asbool(self.parameter("remaining", "False")) + self.__todo, self.__total = self.count_items() + core.input.register( + self, + button=core.input.LEFT_MOUSE, + cmd="emacs {}".format(self.__doc) + ) + + def output(self, widget): + if self.__remaining: + return "TODO: {}/{}".format(self.__todo, self.__total) + return "TODO: {}/{}".format(self.__total-self.__todo, self.__total) + + def update(self): + self.__todo, self.__total = self.count_items() + + def count_items(self): + todo, total = 0, 0 + try: + with open(self.__doc, "r") as f: + for line in f: + if self.__todo_regex.match(line.upper()) is not None: + todo += 1 + total += 1 + elif self.__done_regex.match(line.upper()) is not None: + total += 1 + return todo, total + except OSError: + return -1, -1 + +# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/modules/core/cpu.py b/bumblebee_status/modules/core/cpu.py index 59c6e71..77ac20a 100644 --- a/bumblebee_status/modules/core/cpu.py +++ b/bumblebee_status/modules/core/cpu.py @@ -12,6 +12,7 @@ Parameters: * cpu.warning : Warning threshold in % of CPU usage (defaults to 70%) * cpu.critical: Critical threshold in % of CPU usage (defaults to 80%) * cpu.format : Format string (defaults to '{:.01f}%') + * cpu.percpu : If set to true, show each individual cpu (defaults to false) """ import psutil @@ -20,12 +21,19 @@ import core.module import core.widget import core.input +import util.format + class Module(core.module.Module): def __init__(self, config, theme): - super().__init__(config, theme, core.widget.Widget(self.utilization)) - self.widget().set("theme.minwidth", self._format.format(100.0 - 10e-20)) - self._utilization = psutil.cpu_percent(percpu=False) + super().__init__(config, theme, []) + self._percpu = util.format.asbool(self.parameter("percpu", False)) + + for idx, cpu_perc in enumerate(self.cpu_utilization()): + widget = self.add_widget(name="cpu#{}".format(idx), full_text=self.utilization) + widget.set("utilization", cpu_perc) + widget.set("theme.minwidth", self._format.format(100.0 - 10e-20)) + core.input.register( self, button=core.input.LEFT_MOUSE, cmd="gnome-system-monitor" ) @@ -34,14 +42,19 @@ class Module(core.module.Module): def _format(self): return self.parameter("format", "{:.01f}%") - def utilization(self, _): - return self._format.format(self._utilization) + def utilization(self, widget): + return self._format.format(widget.get("utilization", 0.0)) + + def cpu_utilization(self): + tmp = psutil.cpu_percent(percpu=self._percpu) + return tmp if self._percpu else [tmp] def update(self): - self._utilization = psutil.cpu_percent(percpu=False) + for idx, cpu_perc in enumerate(self.cpu_utilization()): + self.widgets()[idx].set("utilization", cpu_perc) - def state(self, _): - return self.threshold_state(self._utilization, 70, 80) + def state(self, widget): + return self.threshold_state(widget.get("utilization", 0.0), 70, 80) # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/modules/core/datetime.py b/bumblebee_status/modules/core/datetime.py index f421e15..febb5fb 100644 --- a/bumblebee_status/modules/core/datetime.py +++ b/bumblebee_status/modules/core/datetime.py @@ -21,7 +21,6 @@ class Module(core.module.Module): super().__init__(config, theme, core.widget.Widget(self.full_text)) core.input.register(self, button=core.input.LEFT_MOUSE, cmd="calendar") - self._fmt = self.parameter("format", self.default_format()) l = locale.getdefaultlocale() if not l or l == (None, None): l = ("en_US", "UTF-8") @@ -36,7 +35,8 @@ class Module(core.module.Module): def full_text(self, widget): enc = locale.getpreferredencoding() - retval = datetime.datetime.now().strftime(self._fmt) + fmt = self.parameter("format", self.default_format()) + retval = datetime.datetime.now().strftime(fmt) if hasattr(retval, "decode"): return retval.decode(enc) return retval diff --git a/bumblebee_status/modules/core/disk.py b/bumblebee_status/modules/core/disk.py index 12c7a16..ff0d4f9 100644 --- a/bumblebee_status/modules/core/disk.py +++ b/bumblebee_status/modules/core/disk.py @@ -8,6 +8,7 @@ Parameters: * disk.path: Path to calculate disk usage from (defaults to /) * disk.open: Which application / file manager to launch (default xdg-open) * disk.format: Format string, tags {path}, {used}, {left}, {size} and {percent} (defaults to '{path} {used}/{size} ({percent:05.02f}%)') + * disk.system: Unit system to use - SI (KB, MB, ...) or IEC (KiB, MiB, ...) (defaults to 'IEC') """ import os @@ -25,6 +26,7 @@ class Module(core.module.Module): self._path = self.parameter("path", "/") self._format = self.parameter("format", "{used}/{size} ({percent:05.02f}%)") + self._system = self.parameter("system", "IEC") self._used = 0 self._left = 0 @@ -38,9 +40,9 @@ class Module(core.module.Module): ) def diskspace(self, widget): - used_str = util.format.byte(self._used) - size_str = util.format.byte(self._size) - left_str = util.format.byte(self._left) + used_str = util.format.byte(self._used, sys=self._system) + size_str = util.format.byte(self._size, sys=self._system) + left_str = util.format.byte(self._left, sys=self._system) percent_str = self._percent return self._format.format( diff --git a/bumblebee_status/modules/core/keys.py b/bumblebee_status/modules/core/keys.py new file mode 100644 index 0000000..a579c7d --- /dev/null +++ b/bumblebee_status/modules/core/keys.py @@ -0,0 +1,56 @@ +# pylint: disable=C0111,R0903 + +"""Shows when a key is pressed + +Parameters: + * keys.keys: Comma-separated list of keys to monitor (defaults to "") +""" + +import core.module +import core.widget +import core.decorators +import core.event + +import util.format + +from pynput.keyboard import Listener + +NAMES = { + "Key.cmd": "cmd", + "Key.ctrl": "ctrl", + "Key.shift": "shift", + "Key.alt": "alt", +} + +class Module(core.module.Module): + @core.decorators.never + def __init__(self, config, theme): + super().__init__(config, theme, []) + + self._listener = Listener(on_press=self._key_press, on_release=self._key_release) + + self._keys = util.format.aslist(self.parameter("keys", "Key.cmd,Key.ctrl,Key.alt,Key.shift")) + + for k in self._keys: + self.add_widget(name=k, full_text=self._display_name(k), hidden=True) + self._listener.start() + + def _display_name(self, key): + return NAMES.get(key, key) + + def _key_press(self, key): + key = str(key) + if not key in self._keys: return + self.widget(key).hidden = False + core.event.trigger("update", [self.id], redraw_only=False) + + def _key_release(self, key): + key = str(key) + if not key in self._keys: return + self.widget(key).hidden = True + core.event.trigger("update", [self.id], redraw_only=False) + + def state(self, widget): + return widget.name + +# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/modules/core/layout.py b/bumblebee_status/modules/core/layout.py new file mode 100644 index 0000000..3ebeeb5 --- /dev/null +++ b/bumblebee_status/modules/core/layout.py @@ -0,0 +1,39 @@ +# pylint: disable=C0111,R0903 + +"""Displays the current keyboard layout + +Parameters: + * layout.device: The device ID of the keyboard (as reported by `xinput -list`), defaults to the core device +""" + +import re + +import core.widget +import core.module + +import util.cli + +from bumblebee_status.discover import utility + +class Module(core.module.Module): + def __init__(self, config, theme): + super().__init__(config=config, theme=theme, widgets=core.widget.Widget(self.get_layout)) + + self._cmd = utility("get-kbd-layout") + keyboard = self.parameter("device", None) + if keyboard: + self._cmd += " {}".format(keyboard) + + def get_layout(self, widget): + result = util.cli.execute(self._cmd, ignore_errors=True) + + m = re.search("([a-zA-Z]+_)?([a-zA-Z]+)(\(([\w-]+)\))?", result) + + if m: + layout = m.group(2) + variant = m.group(3) + return layout if not variant else "{} {}".format(layout, variant) + + return "n/a" + +# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/modules/core/layout_xkb.py b/bumblebee_status/modules/core/layout_xkb.py new file mode 120000 index 0000000..f2e8037 --- /dev/null +++ b/bumblebee_status/modules/core/layout_xkb.py @@ -0,0 +1 @@ +layout-xkb.py \ No newline at end of file diff --git a/bumblebee_status/modules/core/load.py b/bumblebee_status/modules/core/load.py index 7800c88..ca7d26a 100644 --- a/bumblebee_status/modules/core/load.py +++ b/bumblebee_status/modules/core/load.py @@ -27,6 +27,7 @@ class Module(core.module.Module): self._cpus = multiprocessing.cpu_count() except NotImplementedError as e: self._cpus = 1 + core.input.register( self, button=core.input.LEFT_MOUSE, cmd="gnome-system-monitor" ) diff --git a/bumblebee_status/modules/core/memory.py b/bumblebee_status/modules/core/memory.py index 82de769..c9ecf44 100644 --- a/bumblebee_status/modules/core/memory.py +++ b/bumblebee_status/modules/core/memory.py @@ -41,18 +41,8 @@ class Module(core.module.Module): return self._format.format(**self._mem) def update(self): - data = {} - with open("/proc/meminfo", "r") as f: - for line in f: - tmp = re.split(r"[:\s]+", line) - value = int(tmp[1]) - if tmp[2] == "kB": - value = value * 1024 - if tmp[2] == "mB": - value = value * 1024 * 1024 - if tmp[2] == "gB": - value = value * 1024 * 1024 * 1024 - data[tmp[0]] = value + data = self.__parse_meminfo() + if "MemAvailable" in data: used = data["MemTotal"] - data["MemAvailable"] else: @@ -78,5 +68,28 @@ class Module(core.module.Module): return "warning" return None + def __parse_meminfo(self): + data = {} + with open("/proc/meminfo", "r") as f: + # https://bugs.python.org/issue32933 + for line in f.readlines(): + tmp = re.split(r"[:\s]+", line) + value = self.__parse_value(tmp) + + data[tmp[0]] = value + + return data + + def __parse_value(self, data): + value = int(data[1]) + + if data[2] == "kB": + value = value * 1024 + if data[2] == "mB": + value = value * 1024 * 1024 + if data[2] == "gB": + value = value * 1024 * 1024 * 1024 + + return value # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/modules/core/nic.py b/bumblebee_status/modules/core/nic.py index d64a14f..09fe487 100644 --- a/bumblebee_status/modules/core/nic.py +++ b/bumblebee_status/modules/core/nic.py @@ -7,12 +7,15 @@ Requires the following python module: Requires the following executable: * iw + * (until and including 2.0.5: iwgetid) Parameters: - * nic.exclude: Comma-separated list of interface prefixes to exclude (defaults to 'lo,virbr,docker,vboxnet,veth,br') + * nic.exclude: Comma-separated list of interface prefixes (supporting regular expressions) to exclude (defaults to 'lo,virbr,docker,vboxnet,veth,br,.*:avahi') * nic.include: Comma-separated list of interfaces to include * nic.states: Comma-separated list of states to show (prefix with '^' to invert - i.e. ^down -> show all devices that are not in state down) - * nic.format: Format string (defaults to '{intf} {state} {ip} {ssid}') + * nic.format: Format string (defaults to '{intf} {state} {ip} {ssid} {strength}') + * nic.strength_warning: Integer to set the threshold for warning state (defaults to 50) + * nic.strength_critical: Integer to set the threshold for critical state (defaults to 30) """ import re @@ -27,17 +30,14 @@ import util.format class Module(core.module.Module): - @core.decorators.every(seconds=10) + @core.decorators.every(seconds=5) def __init__(self, config, theme): widgets = [] super().__init__(config, theme, widgets) - self._exclude = tuple( - filter( - len, - self.parameter("exclude", "lo,virbr,docker,vboxnet,veth,br").split(","), - ) + self._exclude = util.format.aslist( + self.parameter("exclude", "lo,virbr,docker,vboxnet,veth,br,.*:avahi") ) - self._include = self.parameter("include", "").split(",") + self._include = util.format.aslist(self.parameter("include", "")) self._states = {"include": [], "exclude": []} for state in tuple( @@ -47,7 +47,15 @@ class Module(core.module.Module): self._states["exclude"].append(state[1:]) else: self._states["include"].append(state) - self._format = self.parameter("format", "{intf} {state} {ip} {ssid}") + self._format = self.parameter("format", "{intf} {state} {ip} {ssid} {strength}") + + self._strength_threshold_critical = self.parameter("strength_critical", 30) + self._strength_threshold_warning = self.parameter("strength_warning", 50) + + # Limits for the accepted dBm values of wifi strength + self.__strength_dbm_lower_bound = -110 + self.__strength_dbm_upper_bound = -30 + self.iw = shutil.which("iw") self._update_widgets(widgets) @@ -66,6 +74,14 @@ class Module(core.module.Module): iftype = "wireless" if self._iswlan(intf) else "wired" iftype = "tunnel" if self._istunnel(intf) else iftype + # "strength" is none if interface type is not wlan + strength = widget.get("strength") + if self._iswlan(intf) and strength: + if strength < self._strength_threshold_critical: + states.append("critical") + elif strength < self._strength_threshold_warning: + states.append("warning") + states.append("{}-{}".format(iftype, widget.get("state"))) return states @@ -89,11 +105,18 @@ class Module(core.module.Module): return [] return retval + def _excluded(self, intf): + for e in self._exclude: + if re.match(e, intf): + return True + return False + def _update_widgets(self, widgets): self.clear_widgets() - interfaces = [ - i for i in netifaces.interfaces() if not i.startswith(self._exclude) - ] + interfaces = [] + for i in netifaces.interfaces(): + if not self._excluded(i): + interfaces.append(i) interfaces.extend([i for i in netifaces.interfaces() if i in self._include]) for intf in interfaces: @@ -111,6 +134,9 @@ class Module(core.module.Module): ): continue + strength_dbm = self.get_strength_dbm(intf) + strength_percent = self.convert_strength_dbm_percent(strength_dbm) + widget = self.widget(intf) if not widget: widget = self.add_widget(name=intf) @@ -121,22 +147,44 @@ class Module(core.module.Module): ip=", ".join(addr), intf=intf, state=state, + strength=str(strength_percent) + "%" if strength_percent else "", ssid=self.get_ssid(intf), ).split() ) ) widget.set("intf", intf) widget.set("state", state) + widget.set("strength", strength_percent) def get_ssid(self, intf): - if self._iswlan(intf) and not self._istunnel(intf) and self.iw: - ssid = util.cli.execute("{} dev {} link".format(self.iw, intf)) - found_ssid = re.findall("SSID:\s(.+)", ssid) - if len(found_ssid) > 0: - return found_ssid[0] - else: - return "" + if not self._iswlan(intf) or self._istunnel(intf) or not self.iw: + return "" + + iw_info = util.cli.execute("{} dev {} info".format(self.iw, intf)) + for line in iw_info.split("\n"): + match = re.match(r"^\s+ssid\s(.+)$", line) + if match: + return match.group(1) + return "" + def get_strength_dbm(self, intf): + if not self._iswlan(intf) or self._istunnel(intf) or not self.iw: + return None + + with open("/proc/net/wireless", "r") as file: + for line in file: + if intf in line: + # Remove trailing . by slicing it off ;) + strength_dbm = line.split()[3][:-1] + return util.format.asint(strength_dbm, + minimum=self.__strength_dbm_lower_bound, + maximum=self.__strength_dbm_upper_bound) + + return None + + def convert_strength_dbm_percent(self, signal): + return int(100 * ((signal + 100) / 70.0)) if signal else None + # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/modules/core/redshift.py b/bumblebee_status/modules/core/redshift.py index c60ed9e..d50463f 100644 --- a/bumblebee_status/modules/core/redshift.py +++ b/bumblebee_status/modules/core/redshift.py @@ -54,7 +54,7 @@ def get_redshift_value(module): for line in res.split("\n"): line = line.lower() if "temperature" in line: - widget.set("temp", line.split(" ")[2]) + widget.set("temp", line.split(" ")[2].upper()) if "period" in line: state = line.split(" ")[1] if "day" in state: @@ -101,7 +101,7 @@ class Module(core.module.Module): return val def update(self): - if self.__thread is not None and self.__thread.isAlive(): + if self.__thread is not None and self.__thread.is_alive(): return self.__thread = threading.Thread(target=get_redshift_value, args=(self,)) self.__thread.start() diff --git a/bumblebee_status/modules/core/spacer.py b/bumblebee_status/modules/core/spacer.py index 7e4453a..e5a2d5e 100644 --- a/bumblebee_status/modules/core/spacer.py +++ b/bumblebee_status/modules/core/spacer.py @@ -9,7 +9,7 @@ Parameters: import core.module import core.widget import core.decorators - +import core.input class Module(core.module.Module): @core.decorators.every(minutes=60) @@ -20,5 +20,8 @@ class Module(core.module.Module): def text(self, _): return self.__text + def update_text(self, event): + self.__text = core.input.button_name(event["button"]) + # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/modules/core/time.py b/bumblebee_status/modules/core/time.py index 1c2c4b1..020bef1 100644 --- a/bumblebee_status/modules/core/time.py +++ b/bumblebee_status/modules/core/time.py @@ -12,7 +12,6 @@ from .datetime import Module class Module(Module): - @core.decorators.every(seconds=59) # ensures one update per minute def __init__(self, config, theme): super().__init__(config, theme) diff --git a/bumblebee_status/util/format.py b/bumblebee_status/util/format.py index 65242a3..3acf440 100644 --- a/bumblebee_status/util/format.py +++ b/bumblebee_status/util/format.py @@ -71,22 +71,33 @@ def astemperature(val, unit="metric"): return "{}°{}".format(int(val), __UNITS.get(unit.lower(), __UNITS["default"])) -def byte(val, fmt="{:.2f}"): +def byte(val, fmt="{:.2f}", sys="IEC"): """Returns a byte representation of the input value :param val: value to format, must be convertible into a float :param fmt: optional output format string, defaults to {:.2f} + :param sys: optional unit system specifier - SI (kilo, Mega, Giga, ...) or + IEC (kibi, Mebi, Gibi, ...) - defaults to IEC :return: byte representation (e.g. KiB, GiB, etc.) of the input value :rtype: string """ + if sys == "IEC": + div = 1024.0 + units = ["", "Ki", "Mi", "Gi", "Ti"] + final = "TiB" + elif sys == "SI": + div = 1000.0 + units = ["", "K", "M", "G", "T"] + final = "TB" + val = float(val) - for unit in ["", "Ki", "Mi", "Gi"]: - if val < 1024.0: + for unit in units: + if val < div: return "{}{}B".format(fmt, unit).format(val) - val /= 1024.0 - return "{}GiB".format(fmt).format(val * 1024.0) + val /= div + return "{}{}".format(fmt).format(val * div, final) __seconds_pattern = re.compile(r"(([\d\.?]+)h)?(([\d\.]+)m)?([\d\.]+)?s?") diff --git a/bumblebee_status/util/location.py b/bumblebee_status/util/location.py index 12242ea..e48b71a 100644 --- a/bumblebee_status/util/location.py +++ b/bumblebee_status/util/location.py @@ -59,11 +59,11 @@ def __load(): __next = time.time() + 60 * 30 # error - try again every 30m -def __get(name, default=None): +def __get(name): global __data if not __data or __expired(): __load() - return __data.get(name, default) + return __data[name] def reset(): diff --git a/bumblebee_status/util/popup.py b/bumblebee_status/util/popup.py index bbabe66..784a037 100644 --- a/bumblebee_status/util/popup.py +++ b/bumblebee_status/util/popup.py @@ -49,6 +49,7 @@ class menu(object): return self._menu def __on_focus_out(self, event=None): + self.running = False self._root.destroy() def __on_click(self, callback): diff --git a/bumblebee_status/util/xresources.py b/bumblebee_status/util/xresources.py new file mode 100644 index 0000000..70665a9 --- /dev/null +++ b/bumblebee_status/util/xresources.py @@ -0,0 +1,10 @@ +import subprocess +import shutil + +def query(key): + if shutil.which("xgetres"): + return subprocess.run(["xgetres", key], + capture_output=True).stdout.decode("utf-8").strip() + else: + raise Exception("xgetres must be installed for this theme") + diff --git a/docs/development/general.rst b/docs/development/general.rst index 824d1a7..0ec89a0 100644 --- a/docs/development/general.rst +++ b/docs/development/general.rst @@ -1,34 +1,15 @@ General guidelines ================== -Writing unit tests ------------------- +Not much, right now. If you have an idea and some code, just +create a PR, I will gladly review and comment (and, likely, merge) -Some general hints: +Just one minor note: ``bumblebee-status`` is mostly a one-person, +spare-time project, so please be patient when answering an issue, +question or PR takes a while. -- Tests should run with just Python Standard Library modules installed - (i.e. if there are additional requirements, the test should be skipped - if those are missing) -- Tests should run even if there is no network connectivity (please mock - urllib calls, for example) -- Tests should be stable and not require modifications every time the - tested code's implementation changes slightly (been there / done that) +Also, the (small) community that has gathered around ``bumblebee-status`` +is extremely friendly and helpful, so don't hesitate to create issues +with questions, somebody will always come up with a useful answer. -Right now, ``bumblebee-status`` is moving away from Python's -built-in ``unittest`` framework (tests located inside ``tests/``) -and towards ``pytest`` (tests located inside ``pytests/``). - -First implication: To run the new tests, you need to have ``pytest`` -installed, it is not part of the Python Standard Library. Most -distributions call the package ``python-pytest`` or ``python3-pytest`` -or something similar (or you just use ``pip install --use pytest``) - -Aside from that, you just write your tests using ``pytest`` as usual, -with one big caveat: - -**If** you create a new directory inside ``pytests/``, you need to -also create a file called ``__init__.py`` inside that, otherwise, -modules won't load correctly. - -For examples, just browse the existing code. A good, minimal sample -for unit testing ``bumblebee-status`` is ``pytests/core/test_event.py``. +:) diff --git a/docs/development/index.rst b/docs/development/index.rst index 179dee0..6881fdb 100644 --- a/docs/development/index.rst +++ b/docs/development/index.rst @@ -8,4 +8,5 @@ Developer's Guide general module theme + testing diff --git a/docs/development/module.rst b/docs/development/module.rst index 1d6e716..992ef05 100644 --- a/docs/development/module.rst +++ b/docs/development/module.rst @@ -11,10 +11,12 @@ Adding a new module to ``bumblebee-status`` is straight-forward: ``bumblebee-status`` (i.e. a module called ``bumblebee_status/modules/contrib/test.py`` will be loaded using ``bumblebee-status -m test``) +- Alternatively, you can put your module in ``~/.config/bumblebee-status/modules/`` +- The module name must follow the `Python Naming Conventions `_ - See below for how to actually write the module - Test (run ``bumblebee-status`` in the CLI) - Make sure your changes don’t break anything: ``./coverage.sh`` -- If you want to do me favour, run your module through +- If you want to do me a favour, run your module through ``black -t py34`` before submitting Pull requests @@ -22,7 +24,7 @@ Pull requests The project **gladly** accepts PRs for bugfixes, new functionality, new modules, etc. When you feel comfortable with what you’ve developed, -please just open a PR, somebody will look at it eventually :) Thanks! +please just open a PR. Somebody will look at it eventually :) Thanks! Coding guidelines ----------------- @@ -65,7 +67,7 @@ Of modules and widgets There are two important concepts for module writers: - A module is something that offers a single set of coherent functionality - A module -has 1 to n “widgets”, which translates to individual blocks in the i3bar +has 1 to n “widgets”, which translates to individual blocks in the i3bar. Very often, this is a 1:1 relationship, and a single module has a single widget. If that’s the case for you, you can stop reading now :) diff --git a/docs/development/testing.rst b/docs/development/testing.rst new file mode 100644 index 0000000..c5c9375 --- /dev/null +++ b/docs/development/testing.rst @@ -0,0 +1,33 @@ +Testing guidelines +================== + +Writing unit tests +------------------ + +Some general hints: + +- Tests should run with just Python Standard Library modules installed + (i.e. if there are additional requirements, the test should be skipped + if those are missing) +- Tests should run even if there is no network connectivity (please mock + urllib calls, for example) +- Tests should be stable and not require modifications every time the + tested code's implementation changes slightly (been there / done that) + +Right now, ``bumblebee-status`` uses the ``pytest`` framework, and its +unit tests are located inside the ``tests/`` subdirectory. + +First implication: To run the new tests, you need to have ``pytest`` +installed, it is not part of the Python Standard Library. Most +distributions call the package ``python-pytest`` or ``python3-pytest`` +or something similar (or you just use ``pip install --use pytest``) + +Aside from that, you just write your tests using ``pytest`` as usual, +with one big caveat: + +**If** you create a new directory inside ``tests/``, you need to +also create a file called ``__init__.py`` inside that, otherwise, +modules won't load correctly. + +For examples, just browse the existing code. A good, minimal sample +for unit testing ``bumblebee-status`` is ``tests/core/test_event.py``. diff --git a/docs/features.rst b/docs/features.rst index f050a2b..603c7fe 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -1,6 +1,25 @@ Advanced usage =========================== +Intervals +--------- + +Some modules define their own update intervals (e.g. most modules that query +an online service), such as to not cause a storm of "once every second" queries. + +For such modules, the "global" interval defined via the ``interval`` parameter effectively defines the +highest possible "resolution". If you have a global interval of 10s, for example, +any other module can update at 10s, 20s, 30s, etc., but not every 25s. The status +bar will internally always align to the next future time slot. + +The update interval can also be changed on a per-module basis, like +this (overriding the default module interval indicated above): + +.. code-block:: bash + + $ ./bumblebee-status -m cpu memory -p cpu.interval=5s memory.interval=1m + + Events ------ @@ -87,6 +106,19 @@ attention, it will remain hidden. Note that this parameter is specified *in addition* to ``-m`` (i.e. to autohide the CPU module, you would use ``bumblebee-status -m cpu memory traffic -a cpu``). +Scrolling widget text +----------------------- +Some widgets support scrolling for long text (e.g. most music player +widgets, rss, etc.). Those have some additional settings for customizing +the scrolling behaviour, in particular: + + - ``scrolling.width``: Desired width of the scrolling panel + - ``scrolling.makewide``: If set to true, extends texts shorter than + ``scrolling.width`` to that width + - ``scrolling.bounce``: If set to true, bounces the text when it reaches + the end, otherwise, it behaves like marquee (scroll-through) text + - ``scrolling.speed``: Defines the scroll speed, in characters per update + Additional widget theme settings -------------------------------- @@ -128,6 +160,7 @@ Configuration files have the following format: [core] modules = + autohide = theme = [module-parameters] diff --git a/docs/index.rst b/docs/index.rst index 084a8fd..f405d91 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -20,15 +20,15 @@ feature requests, etc. :) Thanks a lot! -+------------------------------------+-------------------------+ -| **Required i3wm version** | 4.12+ | -+------------------------------------+-------------------------+ -| **Supported Python versions** | 3.4, 3.5, 3.6, 3.7, 3.8 | -+------------------------------------+-------------------------+ -| **Supported FontAwesome versions** | 4 only | -+------------------------------------+-------------------------+ -| **Per-module requirements** | see :doc:`modules` | -+------------------------------------+-------------------------+ ++------------------------------------+------------------------------+ +| **Required i3wm version** | 4.12+ | ++------------------------------------+------------------------------+ +| **Supported Python versions** | 3.4, 3.5, 3.6, 3.7, 3.8, 3.9 | ++------------------------------------+------------------------------+ +| **Supported FontAwesome versions** | 4 only | ++------------------------------------+------------------------------+ +| **Per-module requirements** | see :doc:`modules` | ++------------------------------------+------------------------------+ see :doc:`FAQ` for details on this diff --git a/docs/introduction.rst b/docs/introduction.rst index 927a384..3831d4b 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -19,6 +19,9 @@ Installation # will install bumblebee-status into ~/.local/bin/bumblebee-status pip install --user bumblebee-status + +There is also a SlackBuild available here: [slackbuilds:bumblebee-status](http://slackbuilds.org/repository/14.2/desktop/bumblebee-status/) - many thanks to [@Tonus1](https://github.com/Tonus1)! + Dependencies ------------ @@ -56,12 +59,15 @@ To change the update interval, use: $ ./bumblebee-status -m -p interval= -The update interval can also be changed on a per-module basis, like -this: +The update interval is the global "refresh" interval of the modules (i.e. how often +the bar will be updated with new data). The default interval is one second. It is +possible to use suffixes such as "m" (for minutes), or "h" for hours (e.g. +``-p interval=5m`` to update once every 5 minutes. -.. code-block:: bash +Note that some modules define their own intervals (e.g. most modules that query +an online service), such as to not cause a storm of "once every second" queries. - $ ./bumblebee-status -m cpu memory -p cpu.interval=5s memory.interval=1m +For more details on that, please refer to :doc:`features`. All modules can be given “aliases” using ``:``, by which they can be parametrized, for example: diff --git a/docs/modules.rst b/docs/modules.rst index 09879f7..413f6cf 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -22,6 +22,7 @@ Parameters: * cpu.warning : Warning threshold in % of CPU usage (defaults to 70%) * cpu.critical: Critical threshold in % of CPU usage (defaults to 80%) * cpu.format : Format string (defaults to '{:.01f}%') + * cpu.percpu : If set to true, show each individual cpu (defaults to false) .. image:: ../screenshots/cpu.png @@ -63,6 +64,7 @@ Parameters: * disk.path: Path to calculate disk usage from (defaults to /) * disk.open: Which application / file manager to launch (default xdg-open) * disk.format: Format string, tags {path}, {used}, {left}, {size} and {percent} (defaults to '{path} {used}/{size} ({percent:05.02f}%)') + * disk.system: Unit system to use - SI (KB, MB, ...) or IEC (KiB, MiB, ...) (defaults to 'IEC') .. image:: ../screenshots/disk.png @@ -83,11 +85,33 @@ Requires: .. image:: ../screenshots/git.png +keys +~~~~ + +Shows when a key is pressed + +Parameters: + * keys.keys: Comma-separated list of keys to monitor (defaults to "") + layout-xkb ~~~~~~~~~~ Displays the current keyboard layout using libX11 +Requires the following library: + * libX11.so.6 +and python module: + * xkbgroup + +Parameters: + * layout-xkb.showname: Boolean that indicate whether the full name should be displayed. Defaults to false (only the symbol will be displayed) + * layout-xkb.show_variant: Boolean that indecates whether the variant name should be displayed. Defaults to true. + +layout_xkb +~~~~~~~~~~ + +Displays the current keyboard layout using libX11 + Requires the following library: * libX11.so.6 and python module: @@ -141,9 +165,10 @@ Requires the following python module: Requires the following executable: * iw + * (until and including 2.0.5: iwgetid) Parameters: - * nic.exclude: Comma-separated list of interface prefixes to exclude (defaults to 'lo,virbr,docker,vboxnet,veth,br') + * nic.exclude: Comma-separated list of interface prefixes (supporting regular expressions) to exclude (defaults to 'lo,virbr,docker,vboxnet,veth,br,.*:avahi') * nic.include: Comma-separated list of interfaces to include * nic.states: Comma-separated list of states to show (prefix with '^' to invert - i.e. ^down -> show all devices that are not in state down) * nic.format: Format string (defaults to '{intf} {state} {ip} {ssid}') @@ -278,6 +303,7 @@ Parameters: * vault.location: Location of the password store (defaults to ~/.password-store) * vault.offx: x-axis offset of popup menu (defaults to 0) * vault.offy: y-axis offset of popup menu (defaults to 0) + * vault.text: Text to display on the widget (defaults to ) Many thanks to `bbernhard `_ for the idea! @@ -294,6 +320,9 @@ Parameters: and appending a file '~/.config/i3/config.' for every screen. * xrandr.autoupdate: If set to 'false', does *not* invoke xrandr automatically. Instead, the module will only refresh when displays are enabled or disabled (defaults to true) + * xrandr.exclude: Comma-separated list of display name prefixes to exclude + * xrandr.autotoggle: Boolean flag to automatically enable new displays (defaults to false) + * xrandr.autotoggle_side: Which side to put autotoggled displays on ('right' or 'left', defaults to 'right') Requires the following python module: * (optional) i3 - if present, the need for updating the widget list is auto-detected @@ -362,6 +391,16 @@ Requires the following executable: contributed by `lucassouto `_ - many thanks! +arch_update +~~~~~~~~~~~ + +Check updates to Arch Linux. + +Requires the following executable: + * checkupdates (from pacman-contrib) + +contributed by `lucassouto `_ - many thanks! + battery ~~~~~~~ @@ -392,6 +431,18 @@ Parameters: contributed by `martindoublem `_ - many thanks! +battery_upower +~~~~~~~~~~~~~~ + +Displays battery status, remaining percentage and charging information. + +Parameters: + * battery-upower.warning : Warning threshold in % of remaining charge (defaults to 20) + * battery-upower.critical : Critical threshold in % of remaining charge (defaults to 10) + * battery-upower.showremaining : If set to true (default) shows the remaining time until the batteries are completely discharged + +contributed by `martindoublem `_ - many thanks! + bluetooth ~~~~~~~~~ @@ -579,8 +630,6 @@ some media control bindings. Left click toggles pause, scroll up skips the current song, scroll down returns to the previous song. -Requires the following library: - * subprocess Parameters: * deadbeef.format: Format string (defaults to '{artist} - {title}') Available values are: {artist}, {title}, {album}, {length}, @@ -636,9 +685,6 @@ Displays DNF package update information (///`_ - many thanks! .. image:: ../screenshots/dunst.png +dunstctl +~~~~~~~~ + +Toggle dunst notifications using dunstctl. + +When notifications are paused using this module dunst doesn't get killed and +you'll keep getting notifications on the background that will be displayed when +unpausing. This is specially useful if you're using dunst's scripting +(https://wiki.archlinux.org/index.php/Dunst#Scripting), which requires dunst to +be running. Scripts will be executed when dunst gets unpaused. + +Requires: + * dunst v1.5.0+ + +contributed by `cristianmiranda `_ - many thanks! +contributed by `joachimmathes `_ - many thanks! + +.. image:: ../screenshots/dunstctl.png + +emerge_status +~~~~~~~~~~~~~ + +Display information about the currently running emerge process. + +Requires the following executable: + * emerge + +Parameters: + * emerge_status.format: Format string (defaults to '{current}/{total} {action} {category}/{pkg}') + +This code is based on `emerge_status module from p3status `_ original created by `AnwariasEu `_. + +.. image:: ../screenshots/emerge_status.png + getcrypto ~~~~~~~~~ @@ -715,7 +795,7 @@ contributed by `TheEdgeOfRage `_ - many thanks hddtemp ~~~~~~~ -Fetch hard drive temeperature data from a hddtemp daemon +Fetch hard drive temperature data from a hddtemp daemon that runs on localhost and default port (7634) contributed by `somospocos `_ - many thanks! @@ -788,6 +868,16 @@ Requires the following executable: contributed by `somospocos `_ - many thanks! +layout_xkbswitch +~~~~~~~~~~~~~~~~ + +Displays and changes the current keyboard layout + +Requires the following executable: + * xkb-switch + +contributed by `somospocos `_ - many thanks! + libvirtvms ~~~~~~~~~~ @@ -984,6 +1074,16 @@ Displays information about the current song in vlc, audacious, bmp, xmms2, spoti Requires the following executable: * playerctl +Parameters: + * playerctl.format: Format string (defaults to '{{artist}} - {{title}} {{duration(position)}}/{{duration(mpris:length)}}'). + The format string is passed to 'playerctl -f' as an argument. Read `the README `_ for more information. + * playerctl.layout: Comma-separated list to change order of widgets (defaults to song, previous, pause, next) + Widget names are: playerctl.song, playerctl.prev, playerctl.pause, playerctl.next + * playerctl.args: The arguments added to playerctl. + You can check 'playerctl --help' or `its readme `_. For example, it could be '-p vlc,%any'. + +Parameters are inspired by the `spotify` module, many thanks to its developers! + contributed by `smitajit `_ - many thanks! .. image:: ../screenshots/playerctl.png @@ -1070,6 +1170,17 @@ publicip Displays public IP address +rofication +~~~~~~~~~~ + +Rofication indicator + +https://github.com/DaveDavenport/Rofication +simple module to show an icon + the number of notifications stored in rofication +module will have normal highlighting if there are zero notifications, + "warning" highlighting if there are nonzero notifications, + "critical" highlighting if there are any critical notifications + rotation ~~~~~~~~ @@ -1099,6 +1210,9 @@ sensors Displays sensor temperature Parameters: + * sensors.use_sensors: whether to use the 'sensors' command. + If set to 'false', the sysfs-interface at '/sys/class/thermal' is used. + If not set, 'sensors' will be used if available. * sensors.path: path to temperature file (default /sys/class/thermal/thermal_zone0/temp). * sensors.json: if set to 'true', interpret sensors.path as JSON 'path' in the output of 'sensors -j' (i.e. //.../), for example, path could @@ -1147,12 +1261,12 @@ Shows a widget per user-defined shortcut and allows to define the behaviour when clicking on it. For more than one shortcut, the commands and labels are strings separated by -a demiliter (; semicolon by default). +a delimiter (; semicolon by default). For example in order to create two shortcuts labeled A and B with commands cmdA and cmdB you could do: - ./bumblebee-status -m shortcut -p shortcut.cmd='ls;ps' shortcut.label='A;B' + ./bumblebee-status -m shortcut -p shortcut.cmd='firefox https://www.google.com;google-chrome https://google.com' shortcut.label='Google (Firefox);Google (Chrome)' Parameters: * shortcut.cmds : List of commands to execute @@ -1174,7 +1288,7 @@ Requires the following executables: * smartctl Parameters: - * smartstatus.display: how to display (defaults to 'combined', other choices: 'seperate' or 'singles') + * smartstatus.display: how to display (defaults to 'combined', other choices: 'combined_singles', 'seperate' or 'singles') * smartstatus.drives: in the case of singles which drives to display, separated comma list value, multiple accepted (defaults to 'sda', example:'sda,sdc') * smartstatus.show_names: boolean in the form of "True" or "False" to show the name of the drives in the form of sda, sbd, combined or none at all. @@ -1187,7 +1301,6 @@ an example. Requires the following libraries: * requests - * regex Parameters: * spaceapi.url: String representation of the api endpoint @@ -1218,6 +1331,10 @@ Parameters: Available values are: {album}, {title}, {artist}, {trackNumber} * spotify.layout: Comma-separated list to change order of widgets (defaults to song, previous, pause, next) Widget names are: spotify.song, spotify.prev, spotify.pause, spotify.next + * spotify.concise_controls: When enabled, allows spotify to be controlled from just the spotify.song widget. + Concise controls are: Left Click: Toggle Pause; Wheel Up: Next; Wheel Down; Previous. + * spotify.bus_name: String (defaults to `spotify`) + Available values: spotify, spotifyd contributed by `yvesh `_ - many thanks! @@ -1232,9 +1349,6 @@ stock Display a stock quote from finance.yahoo.com -Requires the following python packages: - * requests - Parameters: * stock.symbols : Comma-separated list of symbols to fetch * stock.change : Should we fetch change in stock value (defaults to True) @@ -1255,8 +1369,8 @@ Requires the following python packages: * python-dateutil Parameters: - * cpu.lat : Latitude of your location - * cpu.lon : Longitude of your location + * sun.lat : Latitude of your location + * sun.lon : Longitude of your location (if none of those are set, location is determined automatically via location APIs) @@ -1284,6 +1398,9 @@ Parameters: * system.lock: specify a command for locking the screen (defaults to 'i3exit lock') * system.suspend: specify a command for suspending (defaults to 'i3exit suspend') * system.hibernate: specify a command for hibernating (defaults to 'i3exit hibernate') + +Requirements: + tkinter (python3-tk package on debian based systems either you can install it as python package) contributed by `bbernhard `_ - many thanks! @@ -1303,6 +1420,24 @@ contributed by `chdorb `_ - many thanks! .. image:: ../screenshots/taskwarrior.png +thunderbird +~~~~~~~~~~~ + +Displays the unread emails count for one or more Thunderbird inboxes + +Parameters: + * thunderbird.home: Absolute path of your .thunderbird directory (e.g.: /home/pi/.thunderbird) + * thunderbird.inboxes: Comma separated values for all MSF inboxes and their parent directory (account) (e.g.: imap.gmail.com/INBOX.msf,outlook.office365.com/Work.msf) + +Tips: + * You can run the following command in order to list all your Thunderbird inboxes + + find ~/.thunderbird -name '*.msf' | awk -F '/' '{print $(NF-1)"/"$(NF)}' + +contributed by `cristianmiranda `_ - many thanks! + +.. image:: ../screenshots/thunderbird.png + timetz ~~~~~~ @@ -1343,6 +1478,15 @@ contributed by `codingo `_ - many thanks! .. image:: ../screenshots/todo.png +todo_org +~~~~~~~~ + +Displays the number of todo items from an org-mode file +Parameters: + * todo_org.file: File to read TODOs from (defaults to ~/org/todo.org) + * todo_org.remaining: False by default. When true, will output the number of remaining todos instead of the number completed (i.e. 1/4 means 1 of 4 todos remaining, rather than 1 of 4 todos completed) +Based on the todo module by `codingo ` + traffic ~~~~~~~ diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..93120e6 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1 @@ +docutils<0.18 diff --git a/requirements/modules/battery.txt b/requirements/modules/battery.txt new file mode 100644 index 0000000..d028b9b --- /dev/null +++ b/requirements/modules/battery.txt @@ -0,0 +1 @@ +power diff --git a/requirements/modules/battery_upower_reqs.txt b/requirements/modules/battery_upower_reqs.txt index e2182f1..a8ec395 100644 --- a/requirements/modules/battery_upower_reqs.txt +++ b/requirements/modules/battery_upower_reqs.txt @@ -1 +1,2 @@ -dbus +dbus-python +power diff --git a/requirements/modules/currency.txt b/requirements/modules/currency.txt index f229360..126f5e8 100644 --- a/requirements/modules/currency.txt +++ b/requirements/modules/currency.txt @@ -1 +1,2 @@ requests +Babel diff --git a/requirements/modules/dunst.txt b/requirements/modules/dunst.txt deleted file mode 100644 index 053749f..0000000 --- a/requirements/modules/dunst.txt +++ /dev/null @@ -1 +0,0 @@ -dunst diff --git a/requirements/modules/hddtemp.txt b/requirements/modules/hddtemp.txt deleted file mode 100644 index b4e9f6f..0000000 --- a/requirements/modules/hddtemp.txt +++ /dev/null @@ -1 +0,0 @@ -hddtemp diff --git a/requirements/modules/libvirtvms.txt b/requirements/modules/libvirtvms.txt new file mode 100644 index 0000000..ff1a357 --- /dev/null +++ b/requirements/modules/libvirtvms.txt @@ -0,0 +1 @@ +libvirt-python diff --git a/requirements/modules/octoprint.txt b/requirements/modules/octoprint.txt new file mode 100644 index 0000000..2e1fb7c --- /dev/null +++ b/requirements/modules/octoprint.txt @@ -0,0 +1,2 @@ +Pillow +simplejson diff --git a/requirements/modules/spaceapi.txt b/requirements/modules/spaceapi.txt index 3a2dc45..f229360 100644 --- a/requirements/modules/spaceapi.txt +++ b/requirements/modules/spaceapi.txt @@ -1,3 +1 @@ requests -json -time diff --git a/requirements/modules/speedtest.txt b/requirements/modules/speedtest.txt new file mode 100644 index 0000000..4b024b7 --- /dev/null +++ b/requirements/modules/speedtest.txt @@ -0,0 +1 @@ +speedtest-cli diff --git a/requirements/modules/spotify.txt b/requirements/modules/spotify.txt index e2182f1..555438c 100644 --- a/requirements/modules/spotify.txt +++ b/requirements/modules/spotify.txt @@ -1 +1 @@ -dbus +dbus-python diff --git a/requirements/modules/system.txt b/requirements/modules/system.txt index 5d6fce4..aa8980c 100644 --- a/requirements/modules/system.txt +++ b/requirements/modules/system.txt @@ -1 +1 @@ -tkinter +Pillow # placeholder for tk diff --git a/requirements/modules/yubikey.txt b/requirements/modules/yubikey.txt index 0ad4a6b..3466c66 100644 --- a/requirements/modules/yubikey.txt +++ b/requirements/modules/yubikey.txt @@ -1 +1 @@ -yubico +python-yubico diff --git a/requirements/modules/zpool.txt b/requirements/modules/zpool.txt new file mode 100644 index 0000000..49fe098 --- /dev/null +++ b/requirements/modules/zpool.txt @@ -0,0 +1 @@ +setuptools diff --git a/screenshots/dunstctl.png b/screenshots/dunstctl.png new file mode 100644 index 0000000..1aef636 Binary files /dev/null and b/screenshots/dunstctl.png differ diff --git a/screenshots/emerge_status.png b/screenshots/emerge_status.png new file mode 100644 index 0000000..1e2a93a Binary files /dev/null and b/screenshots/emerge_status.png differ diff --git a/screenshots/thunderbird.png b/screenshots/thunderbird.png new file mode 100644 index 0000000..7baffd8 Binary files /dev/null and b/screenshots/thunderbird.png differ diff --git a/setup.cfg b/setup.cfg index 9352f13..d417aa6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,6 +23,7 @@ classifiers = Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 Topic :: Software Development :: Libraries Topic :: Software Development :: Internationalization Topic :: Utilities @@ -30,8 +31,8 @@ keywords = bumblebee-status [options] include_package_data = True -allow-all-external = yes -trusted-host = +allow_all_external = yes +trusted_host = gitlab.* bitbucket.org github.com diff --git a/setup.py b/setup.py index f9f2ee4..1f6c203 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python """Setup file for bumbleestatus bar to allow pip install of full package""" # -*- coding: utf8 - *- -from setuptools import setup +from setuptools import setup, find_packages import versioneer with open("requirements/base.txt") as f: @@ -20,11 +20,9 @@ EXTRAS_REQUIREMENTS_MAP = { "cpu2": read_module("cpu2"), "currency": read_module("currency"), "docker_ps": read_module("docker_ps"), - "dunst": read_module("dunst"), "getcrypto": read_module("getcrypto"), "git": read_module("git"), "github": read_module("github"), - "hddtemp": read_module("hddtemp"), "layout-xkb": read_module("layout_xkb"), "memory": read_module("memory"), "network_traffic": read_module("network_traffic"), @@ -59,4 +57,5 @@ setup( ("share/bumblebee-status/themes/icons", glob.glob("themes/icons/*.json")), ("share/bumblebee-status/utility", glob.glob("bin/*")), ], + packages=find_packages(exclude=["tests", "tests.*"]) ) diff --git a/tests/core/test_module.py b/tests/core/test_module.py index 68415b7..4bca263 100644 --- a/tests/core/test_module.py +++ b/tests/core/test_module.py @@ -55,7 +55,8 @@ def test_importerror(mocker): module = core.module.load(module_name="test", config=config) assert module.__class__.__name__ == "Error" - assert module.widget().full_text() == "test: some-error" + assert module.widget().full_text() == "test: some-error" or \ + module.widget().full_text() == "test: unable to load module" def test_loadvalid_module(): diff --git a/tests/core/test_output.py b/tests/core/test_output.py index 4e0d905..870ded8 100644 --- a/tests/core/test_output.py +++ b/tests/core/test_output.py @@ -26,6 +26,7 @@ def module_a(mocker): widget = mocker.MagicMock() widget.full_text.return_value = "test" widget.id = "a" + widget.hidden = False return SampleModule(config=core.config.Config([]), widgets=[widget, widget, widget]) @pytest.fixture diff --git a/tests/modules/contrib/test_amixer.py b/tests/modules/contrib/test_amixer.py index b1cfbf0..4ff37a3 100644 --- a/tests/modules/contrib/test_amixer.py +++ b/tests/modules/contrib/test_amixer.py @@ -1,5 +1,159 @@ import pytest +import util.cli +import core.config +import modules.contrib.amixer + +@pytest.fixture +def module_mock(request): + def _module_mock(config = []): + return modules.contrib.amixer.Module( + config=core.config.Config(config), + theme=None + ) + + yield _module_mock + +@pytest.fixture +def amixer_mock(): + def _mock(device='Master', volume='10%', state='on'): + return """ + Simple mixer control '{device}',0 + Capabilities: pvolume pswitch pswitch-joined + Playback channels: Front Left - Front Right + Limits: Playback 0 - 65536 + Mono: + Front Left: Playback 55705 [{volume}%] [{state}] + Front Right: Playback 55705 [{volume}%] [{state}] + """.format( + device=device, + volume=volume, + state=state + ) + + return _mock + def test_load_module(): __import__("modules.contrib.amixer") +def test_initial_full_text(module_mock, amixer_mock, mocker): + module = module_mock() + assert module.widget().full_text() == 'n/a' + +def test_input_registration(mocker): + input_register = mocker.patch('core.input.register') + + module = modules.contrib.amixer.Module( + config=core.config.Config([]), + theme=None + ) + + input_register.assert_any_call( + module, + button=core.input.WHEEL_DOWN, + cmd=module.decrease_volume + ) + + input_register.assert_any_call( + module, + button=core.input.WHEEL_UP, + cmd=module.increase_volume + ) + + input_register.assert_any_call( + module, + button=core.input.LEFT_MOUSE, + cmd=module.toggle + ) + +def test_volume_update(module_mock, amixer_mock, mocker): + mocker.patch( + 'util.cli.execute', + return_value=amixer_mock(volume='25%', state='on') + ) + + module = module_mock() + widget = module.widget() + + module.update() + assert widget.full_text() == '25%' + assert module.state(widget) == ['unmuted'] + +def test_muted_update(module_mock, amixer_mock, mocker): + mocker.patch( + 'util.cli.execute', + return_value=amixer_mock(volume='50%', state='off') + ) + + module = module_mock() + widget = module.widget() + + module.update() + assert widget.full_text() == '50%' + assert module.state(widget) == ['warning', 'muted'] + +def test_exception_update(module_mock, mocker): + mocker.patch( + 'util.cli.execute', + side_effect=Exception + ) + + module = module_mock() + widget = module.widget() + + module.update() + assert widget.full_text() == 'n/a' + +def test_unavailable_amixer(module_mock, mocker): + mocker.patch('util.cli.execute', return_value='Invalid') + + module = module_mock() + widget = module.widget() + + module.update() + assert widget.full_text() == '0%' + +def test_toggle(module_mock, mocker): + command = mocker.patch('util.cli.execute') + module = module_mock() + module.toggle(False) + command.assert_called_once_with('amixer -q set Master,0 toggle') + +def test_default_volume(module_mock, mocker): + module = module_mock() + + command = mocker.patch('util.cli.execute') + module.increase_volume(False) + command.assert_called_once_with('amixer -q set Master,0 4%+') + + command = mocker.patch('util.cli.execute') + module.decrease_volume(False) + command.assert_called_once_with('amixer -q set Master,0 4%-') + +def test_custom_volume(module_mock, mocker): + module = module_mock(['-p', 'amixer.percent_change=25']) + + command = mocker.patch('util.cli.execute') + module.increase_volume(False) + command.assert_called_once_with('amixer -q set Master,0 25%+') + + command = mocker.patch('util.cli.execute') + module.decrease_volume(False) + command.assert_called_once_with('amixer -q set Master,0 25%-') + +def test_custom_device(module_mock, mocker): + mocker.patch('util.cli.execute') + module = module_mock(['-p', 'amixer.device=CustomMaster']) + + command = mocker.patch('util.cli.execute') + module.toggle(False) + command.assert_called_once_with('amixer -q set CustomMaster toggle') + + command = mocker.patch('util.cli.execute') + module.increase_volume(False) + command.assert_called_once_with('amixer -q set CustomMaster 4%+') + + command = mocker.patch('util.cli.execute') + module.decrease_volume(False) + command.assert_called_once_with('amixer -q set CustomMaster 4%-') + diff --git a/tests/modules/contrib/test_arch-update.py b/tests/modules/contrib/test_arch-update.py index 6a1c172..53c2941 100644 --- a/tests/modules/contrib/test_arch-update.py +++ b/tests/modules/contrib/test_arch-update.py @@ -1,5 +1,76 @@ import pytest +import util.cli +import core.config +import modules.contrib.arch_update + +@pytest.fixture +def module(): + module = modules.contrib.arch_update.Module( + config=core.config.Config([]), + theme=None + ) + + yield module + def test_load_module(): __import__("modules.contrib.arch-update") +def test_load_symbolic_link_module(): + __import__("modules.contrib.arch_update") + +def test_with_one_package(module, mocker): + command = mocker.patch( + 'util.cli.execute', + return_value=(0, 'bumblebee 1.0.0') + ) + + module.update() + + command.assert_called_with( + 'checkupdates', + ignore_errors=True, + return_exitcode=True + ) + + widget = module.widget() + assert widget.full_text() == 'Update Arch: 1' + assert module.state(widget) == None + assert module.hidden() == False + +def test_with_two_packages(module, mocker): + command = mocker.patch( + 'util.cli.execute', + return_value=(0, 'bumblebee 1.0.0\ni3wm 3.5.7') + ) + + module.update() + + widget = module.widget() + assert widget.full_text() == 'Update Arch: 2' + assert module.state(widget) == 'warning' + assert module.hidden() == False + +def test_with_no_packages(module, mocker): + mocker.patch('util.cli.execute', return_value=(2, '')) + + module.update() + + widget = module.widget() + assert widget.full_text() == 'Update Arch: 0' + assert module.state(widget) == None + assert module.hidden() == True + +def test_with_unknown_code(module, mocker): + mocker.patch('util.cli.execute', return_value=(99, 'error')) + logger = mocker.patch('logging.error') + + module.update() + + logger.assert_called_with('checkupdates exited with {}: {}'.format(99, 'error')) + + widget = module.widget() + assert widget.full_text() == 'Update Arch: 0' + assert module.state(widget) == 'warning' + assert module.hidden() == False + diff --git a/tests/modules/contrib/test_battery-upower.py b/tests/modules/contrib/test_battery-upower.py index cb62a16..d129679 100644 --- a/tests/modules/contrib/test_battery-upower.py +++ b/tests/modules/contrib/test_battery-upower.py @@ -5,3 +5,6 @@ pytest.importorskip("dbus") def test_load_module(): __import__("modules.contrib.battery-upower") +def test_load_symbolic_link_module(): + __import__("modules.contrib.battery_upower") + diff --git a/tests/modules/contrib/test_dunst.py b/tests/modules/contrib/test_dunst.py index 2ca2d40..75e3151 100644 --- a/tests/modules/contrib/test_dunst.py +++ b/tests/modules/contrib/test_dunst.py @@ -1,5 +1,58 @@ import pytest +import core.config +import modules.contrib.dunst + + +def build_module(): + return modules.contrib.dunst.Module( + config=core.config.Config([]), + theme=None + ) + + def test_load_module(): __import__("modules.contrib.dunst") +def test_input_registration(mocker): + input_register = mocker.patch('core.input.register') + + module = build_module() + + input_register.assert_called_with( + module, + button=core.input.LEFT_MOUSE, + cmd=module.toggle_status + ) + +def test_dunst_toggle(mocker): + start_command = mocker.patch('util.cli.execute') + + module = build_module() + start_command.assert_called_with('killall -s SIGUSR2 dunst', ignore_errors=True) + + toggle_command = mocker.patch('util.cli.execute') + module.toggle_status(None) + toggle_command.assert_called_with('killall -s SIGUSR1 dunst') + + widget = module.widget() + actual = module.state(widget) + assert actual == ['muted', 'warning'] + + module.toggle_status(None) + toggle_command.assert_called_with('killall -s SIGUSR2 dunst') + + widget = module.widget() + actual = module.state(widget) + assert actual == ['unmuted'] + +def test_dunst_toggle_exception(mocker): + module = build_module() + + toggle_command = mocker.patch('util.cli.execute', side_effect=Exception) + module.toggle_status(None) + toggle_command.assert_called_with('killall -s SIGUSR1 dunst') + + widget = module.widget() + actual = module.state(widget) + assert actual == ['unmuted'] diff --git a/tests/modules/contrib/test_dunstctl.py b/tests/modules/contrib/test_dunstctl.py new file mode 100644 index 0000000..2391f7e --- /dev/null +++ b/tests/modules/contrib/test_dunstctl.py @@ -0,0 +1,65 @@ +import pytest + +import core.config +import modules.contrib.dunstctl + +def build_module(): + return modules.contrib.dunstctl.Module( + config=core.config.Config([]), + theme=None + ) + +def test_load_module(): + __import__("modules.contrib.dunstctl") + +def test_input_registration(mocker): + input_register = mocker.patch('core.input.register') + + module = build_module() + + input_register.assert_called_with( + module, + button=core.input.LEFT_MOUSE, + cmd=module.toggle_state + ) + +def test_dunst_toggle_state(mocker): + command = mocker.patch('util.cli.execute') + + module = build_module() + + module.toggle_state(None) + command.assert_called_with('dunstctl set-paused toggle', ignore_errors=True) + +def test_dunst_running(mocker): + command = mocker.patch('util.cli.execute', return_value=(0, "false")) + + module = build_module() + module.update() + widget = module.widget() + + actual = module.state(widget) + command.assert_called_with('dunstctl is-paused', return_exitcode=True, ignore_errors=True) + assert actual == ['unmuted'] + +def test_dunst_paused(mocker): + command = mocker.patch('util.cli.execute', return_value=(0, "true")) + + module = build_module() + module.update() + widget = module.widget() + + actual = module.state(widget) + command.assert_called_with('dunstctl is-paused', return_exitcode=True, ignore_errors=True) + assert actual == ['muted', 'warning'] + +def test_dunst_off(mocker): + command = mocker.patch('util.cli.execute', return_value=(1, "dontcare")) + + module = build_module() + module.update() + widget = module.widget() + + actual = module.state(widget) + command.assert_called_with('dunstctl is-paused', return_exitcode=True, ignore_errors=True) + assert actual == ['unknown', 'critical'] diff --git a/tests/modules/contrib/test_layout-xkbswitch.py b/tests/modules/contrib/test_layout-xkbswitch.py index 08cfd96..404aa73 100644 --- a/tests/modules/contrib/test_layout-xkbswitch.py +++ b/tests/modules/contrib/test_layout-xkbswitch.py @@ -1,5 +1,58 @@ import pytest +import util.cli +import core.config +import modules.contrib.layout_xkbswitch + +def build_module(): + return modules.contrib.layout_xkbswitch.Module( + config=core.config.Config([]), + theme=None + ) + def test_load_module(): __import__("modules.contrib.layout-xkbswitch") +def test_load_symbolic_link_module(): + __import__("modules.contrib.layout_xkbswitch") + +def test_current_layout(mocker): + command = mocker.patch('util.cli.execute') + command.side_effect = ['en', 'en'] + + module = build_module() + widget = module.widget() + + module.update() + + assert widget.full_text() == 'en' + +def test_current_layout_exception(mocker): + command = mocker.patch('util.cli.execute') + command.side_effect = RuntimeError + + module = build_module() + widget = module.widget() + + module.update() + + assert widget.full_text() == ['n/a'] + +def test_input_register(mocker): + input_register = mocker.patch('core.input.register') + + module = build_module() + + input_register.assert_called_with( + module, + button=core.input.LEFT_MOUSE, + cmd=module.next_keymap + ) + +def test_next_keymap(mocker): + command = mocker.patch('util.cli.execute') + + module = build_module() + module.next_keymap(False) + + command.assert_called_with('xkb-switch -n', ignore_errors=True) diff --git a/tests/modules/contrib/test_libvirtvms.py b/tests/modules/contrib/test_libvirtvms.py index efa5880..48ba72c 100644 --- a/tests/modules/contrib/test_libvirtvms.py +++ b/tests/modules/contrib/test_libvirtvms.py @@ -1,7 +1,48 @@ +import sys import pytest +from unittest.mock import Mock -pytest.importorskip("libvirt") +import core.config + +sys.modules['libvirt'] = Mock() + +import modules.contrib.libvirtvms + +def build_module(): + return modules.contrib.libvirtvms.Module( + config=core.config.Config([]), + theme=None + ) def test_load_module(): __import__("modules.contrib.libvirtvms") +def test_input_registration(mocker): + input_register = mocker.patch('core.input.register') + + module = build_module() + + input_register.assert_called_with( + module, + button=core.input.LEFT_MOUSE, + cmd="virt-manager" + ) + +def test_status_failed(mocker): + mocker.patch('libvirt.openReadOnly', return_value=None) + + module = build_module() + status = module.status(None) + + assert status == "Failed to open connection to the hypervisor" + +def test_status(mocker): + virtMock = mocker.Mock() + virtMock.numOfDomains = mocker.Mock(return_value=10) + + mocker.patch('libvirt.openReadOnly', return_value=virtMock) + + module = build_module() + status = module.status(None) + + assert status == "VMs 10" diff --git a/tests/modules/contrib/test_network.py b/tests/modules/contrib/test_network.py new file mode 100644 index 0000000..05ec3b1 --- /dev/null +++ b/tests/modules/contrib/test_network.py @@ -0,0 +1,108 @@ +from unittest import TestCase, mock +import pytest + +import core.config +import core.widget +import modules.contrib.network + +import socket + +pytest.importorskip("netifaces") + + +def build_module(): + config = core.config.Config([]) + return modules.contrib.network.Module(config=config, theme=None) + + +def wireless_default(): + return {"default": {1: ("10.0.1.12", "wlan3")}} + + +def wired_default(): + return {"default": {18: ("10.0.1.12", "eth3")}} + + +def exec_side_effect_valid(*args, **kwargs): + if args[0] == "iwgetid": + return "ESSID: bumblefoo" + if "iwconfig" in args[0]: + return "level=-30" + return mock.DEFAULT + + +def exec_side_effect_invalid(*args, **kwargs): + return "invalid gibberish, can't parse for info" + + +class TestNetworkUnit(TestCase): + def test_load_module(self): + __import__("modules.contrib.network") + + @mock.patch("socket.create_connection") + def test_no_internet(self, socket_mock): + socket_mock.side_effect = Exception() + module = build_module() + assert module.widgets()[0].full_text() == "No connection" + + @mock.patch("util.cli.execute") + @mock.patch("netifaces.gateways") + @mock.patch("socket.create_connection") + @mock.patch("netifaces.AF_INET", 1) + @mock.patch("builtins.open", mock.mock_open(read_data="wlan3")) + def test_valid_wireless_connection(self, socket_mock, gateways_mock, execute_mock): + socket_mock.return_value = mock.MagicMock() + fake_ssid = "bumblefoo" + gateways_mock.return_value = wireless_default() + execute_mock.side_effect = exec_side_effect_valid + + module = build_module() + + assert fake_ssid in module.widgets()[0].full_text() + + @mock.patch("netifaces.gateways") + @mock.patch("socket.create_connection") + @mock.patch("netifaces.AF_INET", 18) + @mock.patch("builtins.open", mock.mock_open(read_data="wlan3")) + def test_valid_wired_connection(self, socket_mock, gateways_mock): + gateways_mock.return_value = wired_default() + socket_mock.return_value = mock.MagicMock() + + module = build_module() + + assert module.widgets()[0].full_text() == "Ethernet" + + @mock.patch("netifaces.gateways") + @mock.patch("socket.create_connection") + def test_invalid_gateways(self, socket_mock, gateways_mock): + socket_mock.return_value = mock.Mock() + gateways_mock.return_value = {"xyz": "abc"} + + module = build_module() + assert module.widgets()[0].full_text() == "No connection" + + @mock.patch("util.cli.execute") + @mock.patch("socket.create_connection") + @mock.patch("netifaces.gateways") + @mock.patch("netifaces.AF_INET", 1) + @mock.patch("builtins.open", mock.mock_open(read_data="wlan3")) + def test_invalid_execs(self, gateways_mock, socket_mock, execute_mock): + execute_mock.side_effect = exec_side_effect_invalid + socket_mock.return_value = mock.MagicMock() + gateways_mock.return_value = wireless_default() + + module = build_module() + + assert module.widgets()[0].full_text() == "Unknown ?%" + + @mock.patch("builtins.open", **{"return_value.raiseError.side_effect": Exception()}) + @mock.patch("socket.create_connection") + @mock.patch("netifaces.gateways") + @mock.patch("netifaces.AF_INET", 18) + @mock.patch("builtins.open", mock.mock_open(read_data="wlan3")) + def test_no_wireless_file(self, gateways_mock, socket_mock, mock_open): + gateways_mock.return_value = wired_default() + socket_mock.return_value = mock.MagicMock() + module = build_module() + + assert module.widgets()[0].full_text() == "Ethernet" diff --git a/tests/modules/contrib/test_network_traffic.py b/tests/modules/contrib/test_network_traffic.py index 1abae72..c18692e 100644 --- a/tests/modules/contrib/test_network_traffic.py +++ b/tests/modules/contrib/test_network_traffic.py @@ -89,3 +89,15 @@ class TestNetworkTrafficUnit(TestCase): assert download_widget(module).full_text() == '30.00MiB/s' assert upload_widget(module).full_text() == '512.00KiB/s' + + def test_widget_states(self): + module = build_module() + + assert module.state(download_widget(module)) == 'rx' + assert module.state(upload_widget(module)) == 'tx' + + def test_invalid_widget_state(self): + module = build_module() + invalid_widget = core.widget.Widget(name='invalid') + + assert module.state(invalid_widget) == None diff --git a/tests/modules/contrib/test_publicip.py b/tests/modules/contrib/test_publicip.py index 6c5a31a..870ede6 100644 --- a/tests/modules/contrib/test_publicip.py +++ b/tests/modules/contrib/test_publicip.py @@ -1,5 +1,50 @@ import pytest +from unittest import TestCase, mock -def test_load_module(): - __import__("modules.contrib.publicip") +import core.config +import core.widget +import modules.contrib.publicip + +def build_module(): + config = core.config.Config([]) + return modules.contrib.publicip.Module(config=config, theme=None) + +def widget(module): + return module.widgets()[0] + +class PublicIPTest(TestCase): + def test_load_module(self): + __import__("modules.contrib.publicip") + + @mock.patch('util.location.public_ip') + def test_public_ip(self, public_ip_mock): + public_ip_mock.return_value = '5.12.220.2' + + module = build_module() + module.update() + + assert widget(module).full_text() == '5.12.220.2' + + @mock.patch('util.location.public_ip') + def test_public_ip(self, public_ip_mock): + public_ip_mock.return_value = None + + module = build_module() + module.update() + + assert widget(module).full_text() == 'n/a' + + @mock.patch('util.location.public_ip') + def test_public_ip_with_exception(self, public_ip_mock): + public_ip_mock.side_effect = Exception + + module = build_module() + module.update() + + assert widget(module).full_text() == 'n/a' + + def test_interval_seconds(self): + module = build_module() + + assert module.parameter('interval') == 3600 diff --git a/tests/modules/contrib/test_solaar.py b/tests/modules/contrib/test_solaar.py new file mode 100644 index 0000000..9c5fe33 --- /dev/null +++ b/tests/modules/contrib/test_solaar.py @@ -0,0 +1,32 @@ +import pytest + +import util.cli +import core.config +import modules.contrib.solaar + + +@pytest.fixture +def module(): + module = modules.contrib.solaar.Module( + config=core.config.Config([]), + theme=None + ) + + yield module + + +def test_load_module(): + __import__("modules.contrib.solaar") + + +def test_with_unknown_code(module, mocker): + mocker.patch('util.cli.execute', return_value=(99, 'error')) + logger = mocker.patch('logging.error') + + module.update() + + logger.assert_called_with('solaar exited with {}: {}'.format(99, 'error')) + + widget = module.widget() + assert module.state(widget) == 'warning' + assert module.hidden() == False diff --git a/tests/modules/contrib/test_uptime.py b/tests/modules/contrib/test_uptime.py index 67c791f..18a59d4 100644 --- a/tests/modules/contrib/test_uptime.py +++ b/tests/modules/contrib/test_uptime.py @@ -1,7 +1,26 @@ import pytest +from unittest import TestCase, mock -pytest.importorskip("datetime") +import core.config +import core.widget +import modules.contrib.uptime -def test_load_module(): - __import__("modules.contrib.uptime") +def build_module(): + config = core.config.Config([]) + return modules.contrib.uptime.Module(config=config, theme=None) + +def widget(module): + return module.widgets()[0] + +class UptimeTest(TestCase): + def test_load_module(self): + __import__("modules.contrib.uptime") + + @mock.patch('builtins.open', new_callable=mock.mock_open, read_data='300000 10.45') + def test_uptime(self, uptime_mock): + module = build_module() + module.update() + + uptime_mock.assert_called_with('/proc/uptime', 'r') + assert widget(module).full_text() == '3 days, 11:20:00' diff --git a/tests/modules/core/test_cpu.py b/tests/modules/core/test_cpu.py index 9ea85ed..d1a015e 100644 --- a/tests/modules/core/test_cpu.py +++ b/tests/modules/core/test_cpu.py @@ -1,7 +1,98 @@ import pytest +from unittest import TestCase, mock + +import core.config +import core.widget +import modules.core.cpu pytest.importorskip("psutil") -def test_load_module(): - __import__("modules.core.cpu") +def build_module(percpu=False): + config = core.config.Config(["-p", "percpu={}".format(percpu)]) + config.set("cpu.percpu", percpu) + return modules.core.cpu.Module(config=config, theme=None) + +def cpu_widget(module): + return module.widgets()[0] + +class TestCPU(TestCase): + def test_load_module(self): + __import__("modules.core.cpu") + + @mock.patch('psutil.cpu_percent') + def test_cpu_percent(self, cpu_percent_mock): + cpu_percent_mock.return_value = 5 + module = build_module() + + assert cpu_widget(module).full_text() == '5.0%' + + @mock.patch('psutil.cpu_percent') + def test_cpu_percent_update(self, cpu_percent_mock): + cpu_percent_mock.return_value = 10 + module = build_module() + + assert cpu_widget(module).full_text() == '10.0%' + + cpu_percent_mock.return_value = 20 + module.update() + + assert cpu_widget(module).full_text() == '20.0%' + + @mock.patch('psutil.cpu_percent') + def test_healthy_state(self, cpu_percent_mock): + cpu_percent_mock.return_value = 50 + module = build_module() + + assert module.state(module.widget()) == None + + @mock.patch('psutil.cpu_percent') + def test_warning_state(self, cpu_percent_mock): + cpu_percent_mock.return_value = 75 + module = build_module() + + assert module.state(module.widget()) == 'warning' + + @mock.patch('psutil.cpu_percent') + def test_critical_state(self, cpu_percent_mock): + cpu_percent_mock.return_value = 82 + module = build_module() + + assert module.state(module.widget()) == 'critical' + + @mock.patch('psutil.cpu_percent') + def test_healthy_state_percpu(self, cpu_percent_mock): + cpu_percent_mock.return_value = [50,42,47] + module = build_module(percpu=True) + + for widget in module.widgets(): + assert module.state(widget) == None + + @mock.patch('psutil.cpu_percent') + def test_warning_state_percpu(self, cpu_percent_mock): + cpu_percent_mock.return_value = [50,72,47] + module = build_module(percpu=True) + + assert module.state(module.widgets()[0]) == None + assert module.state(module.widgets()[1]) == "warning" + assert module.state(module.widgets()[2]) == None + + @mock.patch('psutil.cpu_percent') + def test_warning_state_percpu(self, cpu_percent_mock): + cpu_percent_mock.return_value = [50,72,99] + module = build_module(percpu=True) + + assert module.state(module.widgets()[0]) == None + assert module.state(module.widgets()[1]) == "warning" + assert module.state(module.widgets()[2]) == "critical" + + @mock.patch('core.input.register') + def test_register_left_mouse_action(self, input_register_mock): + module = build_module() + + input_register_mock.assert_called_with( + module, + button=core.input.LEFT_MOUSE, + cmd='gnome-system-monitor' + ) + diff --git a/tests/modules/core/test_date.py b/tests/modules/core/test_date.py index b87e64b..9f5e888 100644 --- a/tests/modules/core/test_date.py +++ b/tests/modules/core/test_date.py @@ -1,5 +1,58 @@ import pytest +from unittest import TestCase, mock +from freezegun import freeze_time + +import core.config +import core.input +import core.widget +import modules.core.date + +pytest.importorskip("datetime") + +def build_module(args = []): + config = core.config.Config(args) + return modules.core.date.Module(config=config, theme=None) + +def build_widget(): + return core.widget.Widget() + +class DateTest(TestCase): + def setup_class(self): + locale_patcher = mock.patch('locale.getdefaultlocale') + locale_mock = locale_patcher.start() + locale_mock.return_value = ('en-US', 'UTF-8') + + self.widget = build_widget() + + def test_load_module(self): + __import__("modules.core.date") + + @freeze_time('2020-10-15 03:25:59') + def test_default_format(self): + module = build_module() + assert module.full_text(self.widget) == '10/15/2020' + + @freeze_time('2020-10-20 12:30:00') + def test_custom_format(self): + module = build_module(['-p', 'date.format=%d.%m.%y']) + assert module.full_text(self.widget) == '20.10.20' + + @freeze_time('2020-01-10 10:20:30') + @mock.patch('locale.getdefaultlocale') + def test_invalid_locale(self, locale_mock): + locale_mock.return_value = ('in-IN', 'UTF-0') + + module = build_module() + assert module.full_text(self.widget) == '01/10/2020' + + @mock.patch('core.input.register') + def test_register_left_mouse_action(self, input_register_mock): + module = build_module() + + input_register_mock.assert_called_with( + module, + button=core.input.LEFT_MOUSE, + cmd='calendar' + ) -def test_load_module(): - __import__("modules.core.date") diff --git a/tests/modules/core/test_datetime.py b/tests/modules/core/test_datetime.py index b8384d9..33b7d58 100644 --- a/tests/modules/core/test_datetime.py +++ b/tests/modules/core/test_datetime.py @@ -1,7 +1,57 @@ import pytest +from unittest import mock, TestCase +from freezegun import freeze_time + +import core.config +import core.input +import core.widget +import modules.core.datetime pytest.importorskip("datetime") -def test_load_module(): - __import__("modules.core.datetime") +def build_module(args = []): + config = core.config.Config(args) + return modules.core.datetime.Module(config=config, theme=None) + +def build_widget(): + return core.widget.Widget() + +class DatetimeTest(TestCase): + def setup_class(self): + locale_patcher = mock.patch('locale.getdefaultlocale') + locale_mock = locale_patcher.start() + locale_mock.return_value = ('en-US', 'UTF-8') + + self.widget = build_widget() + + def test_load_module(self): + __import__("modules.core.datetime") + + @freeze_time('2020-10-15') + def test_default_format(self): + module = build_module() + assert module.full_text(self.widget) == '10/15/2020 12:00:00 AM' + + @freeze_time('2020-10-20') + def test_custom_format(self): + module = build_module(['-p', 'datetime.format=%Y.%m.%d']) + assert module.full_text(self.widget) == '2020.10.20' + + @freeze_time('2020-01-10 10:20:30') + @mock.patch('locale.getdefaultlocale') + def test_invalid_locale(self, locale_mock): + locale_mock.return_value = ('in-IN', 'UTF-0') + + module = build_module() + assert module.full_text(self.widget) == '01/10/2020 10:20:30 AM' + + @mock.patch('core.input.register') + def test_register_left_mouse_action(self, input_register_mock): + module = build_module() + + input_register_mock.assert_called_with( + module, + button=core.input.LEFT_MOUSE, + cmd='calendar' + ) diff --git a/tests/modules/core/test_layout-xkb.py b/tests/modules/core/test_layout-xkb.py index 8eacfad..852b9da 100644 --- a/tests/modules/core/test_layout-xkb.py +++ b/tests/modules/core/test_layout-xkb.py @@ -5,3 +5,5 @@ pytest.importorskip("xkbgroup") def test_load_module(): __import__("modules.core.layout-xkb") +def test_load_symbolic_link_module(): + __import__("modules.core.layout_xkb") diff --git a/tests/modules/core/test_load.py b/tests/modules/core/test_load.py index 2210f9e..a80e6f6 100644 --- a/tests/modules/core/test_load.py +++ b/tests/modules/core/test_load.py @@ -1,7 +1,131 @@ import pytest +from unittest import TestCase, mock +import core.config +import core.widget +import modules.core.load + +pytest.importorskip("os") pytest.importorskip("multiprocessing") -def test_load_module(): - __import__("modules.core.load") +def build_module(): + config = core.config.Config([]) + return modules.core.load.Module(config=config, theme=None) + +def widget(module): + return module.widgets()[0] + +class TestLoad(TestCase): + def test_load_module(self): + __import__("modules.core.load") + + @mock.patch('multiprocessing.cpu_count') + @mock.patch('os.getloadavg') + def test_initial_values(self, load_avg_mock, cpu_count_mock): + cpu_count_mock.return_value = 1 + load_avg_mock.return_value = (0.10, 0.20, 0.30) + + module = build_module() + + assert widget(module).full_text() == '0.00/0.00/0.00' + + @mock.patch('multiprocessing.cpu_count') + @mock.patch('os.getloadavg') + def test_update_values(self, load_avg_mock, cpu_count_mock): + cpu_count_mock.return_value = 1 + load_avg_mock.return_value = (0.85, 0.95, 0.25) + + module = build_module() + module.update() + + assert widget(module).full_text() == '0.85/0.95/0.25' + + @mock.patch('multiprocessing.cpu_count') + @mock.patch('os.getloadavg') + def test_cpu_count_exception(self, load_avg_mock, cpu_count_mock): + cpu_count_mock.side_effect = NotImplementedError + load_avg_mock.return_value = (0.1, 0.2, 0.3) + + module = build_module() + module.update() + + assert widget(module).full_text() == '0.10/0.20/0.30' + + @mock.patch('multiprocessing.cpu_count') + @mock.patch('os.getloadavg') + def test_healthy_state(self, load_avg_mock, cpu_count_mock): + cpu_count_mock.return_value = 1 + load_avg_mock.return_value = (0.55, 0.95, 0.25) + + module = build_module() + module.update() + + assert module.state(widget(module)) == None + + @mock.patch('multiprocessing.cpu_count') + @mock.patch('os.getloadavg') + def test_warning_state(self, load_avg_mock, cpu_count_mock): + cpu_count_mock.return_value = 1 + load_avg_mock.return_value = (0.8, 0.85, 0.9) + + module = build_module() + module.update() + + assert module.state(widget(module)) == 'warning' + + @mock.patch('multiprocessing.cpu_count') + @mock.patch('os.getloadavg') + def test_critical_state(self, load_avg_mock, cpu_count_mock): + cpu_count_mock.return_value = 1 + load_avg_mock.return_value = (0.95, 0.85, 0.9) + + module = build_module() + module.update() + + assert module.state(widget(module)) == 'critical' + + @mock.patch('multiprocessing.cpu_count') + @mock.patch('os.getloadavg') + def test_healthy_state_with_8_cpus(self, load_avg_mock, cpu_count_mock): + cpu_count_mock.return_value = 8 + load_avg_mock.return_value = (4.42, 0.85, 0.9) + + module = build_module() + module.update() + + assert module.state(widget(module)) == None + + @mock.patch('multiprocessing.cpu_count') + @mock.patch('os.getloadavg') + def test_warning_state_with_8_cpus(self, load_avg_mock, cpu_count_mock): + cpu_count_mock.return_value = 8 + load_avg_mock.return_value = (5.65, 0.85, 0.9) + + module = build_module() + module.update() + + assert module.state(widget(module)) == 'warning' + + @mock.patch('multiprocessing.cpu_count') + @mock.patch('os.getloadavg') + def test_critical_state_with_8_cpus(self, load_avg_mock, cpu_count_mock): + cpu_count_mock.return_value = 8 + load_avg_mock.return_value = (6.45, 0.85, 0.9) + + module = build_module() + module.update() + + assert module.state(widget(module)) == 'critical' + + @mock.patch('multiprocessing.cpu_count') + @mock.patch('os.getloadavg') + @mock.patch('core.input.register') + def test_register_left_mouse_action(self, input_register_mock, load_avg_mock, cpu_count_mock): + module = build_module() + + input_register_mock.assert_called_with( + module, + button=core.input.LEFT_MOUSE, + cmd='gnome-system-monitor' + ) diff --git a/tests/modules/core/test_memory.py b/tests/modules/core/test_memory.py index 2b87b8a..4787395 100644 --- a/tests/modules/core/test_memory.py +++ b/tests/modules/core/test_memory.py @@ -1,5 +1,143 @@ import pytest +from unittest import TestCase, mock -def test_load_module(): - __import__("modules.core.memory") +import core.config +import core.widget +import modules.core.memory + +def build_module(args = []): + config = core.config.Config(args) + return modules.core.memory.Module(config=config, theme=None) + +def memory_widget(module): + return module.widgets()[0] + +def meminfo_mock( + total, + available, + unit = 'kB', + free = 0, + buffers = 0, + cached = 0, + slab = 0 +): + data = [] + states = [ + ('MemTotal', total), + ('MemAvailable', available), + ('MemFree', free), + ('Buffers', buffers), + ('Cached', cached), + ('Slab', slab) + ] + + for i, (key, value) in enumerate(states): + data.append('{}: {} {}'.format(key, value, unit)) + + return '\n'.join(data) + +class TestMemory(TestCase): + def test_load_module(self): + __import__("modules.core.memory") + + @mock.patch('builtins.open', mock.mock_open(read_data=meminfo_mock(2048, 1024))) + def test_default_healthy_state(self): + module = build_module() + module.update() + + widget = memory_widget(module) + + assert widget.full_text() == '1.00MiB/2.00MiB (50.00%)' + assert module.state(widget) == None + + @mock.patch('builtins.open', mock.mock_open(read_data=meminfo_mock(8196, 1024))) + def test_default_warning_state(self): + module = build_module() + module.update() + + widget = memory_widget(module) + + assert widget.full_text() == '7.00MiB/8.00MiB (87.51%)' + assert module.state(widget) == 'warning' + + @mock.patch('builtins.open', mock.mock_open(read_data=meminfo_mock(2048, 0))) + def test_default_critical_state(self): + module = build_module() + module.update() + + widget = memory_widget(module) + + assert widget.full_text() == '2.00MiB/2.00MiB (100.00%)' + assert module.state(widget) == 'critical' + + @mock.patch('builtins.open', mock.mock_open(read_data=meminfo_mock(4096, 3068))) + def test_custom_warning_parameter(self): + module = build_module(['-p', 'memory.warning=20']) + module.update() + + widget = memory_widget(module) + + assert widget.full_text() == '1.00MiB/4.00MiB (25.10%)' + assert module.state(widget) == 'warning' + + @mock.patch('builtins.open', mock.mock_open(read_data=meminfo_mock(8196, 4096))) + def test_custom_critical_parameter(self): + module = build_module(['-p', 'memory.critical=50']) + module.update() + + widget = memory_widget(module) + + assert widget.full_text() == '4.00MiB/8.00MiB (50.02%)' + assert module.state(widget) == 'critical' + + @mock.patch('builtins.open', mock.mock_open(read_data=meminfo_mock(2048, 1024))) + def test_usedonly_parameter(self): + module = build_module(['-p', 'memory.usedonly=true']) + module.update() + + widget = memory_widget(module) + + assert widget.full_text() == '1.00MiB' + assert module.state(widget) == None + + @mock.patch('builtins.open', mock.mock_open(read_data=meminfo_mock(2048, 1024))) + def test_format_parameter(self): + module = build_module(['-p', 'memory.format={used}.{total}']) + module.update() + + widget = memory_widget(module) + + assert widget.full_text() == '1.00MiB.2.00MiB' + assert module.state(widget) == None + + @mock.patch('builtins.open', mock.mock_open(read_data=meminfo_mock(2048, 1024))) + def test_format_parameter_with_percent(self): + module = build_module(['-p', 'memory.format={percent}%']) + module.update() + + widget = memory_widget(module) + + assert widget.full_text() == '50.0%' + assert module.state(widget) == None + + + @mock.patch('builtins.open', mock.mock_open(read_data=meminfo_mock(8196, 4096, 'mB'))) + def test_mb_unit(self): + module = build_module() + module.update() + + widget = memory_widget(module) + + assert widget.full_text() == '4.00GiB/8.00GiB (50.02%)' + assert module.state(widget) == None + + @mock.patch('builtins.open', mock.mock_open(read_data=meminfo_mock(2, 1, 'gB'))) + def test_gb_unit(self): + module = build_module() + module.update() + + widget = memory_widget(module) + + assert widget.full_text() == '1.00GiB/2.00GiB (50.00%)' + assert module.state(widget) == None diff --git a/tests/modules/core/test_time.py b/tests/modules/core/test_time.py index 603a50d..0395e82 100644 --- a/tests/modules/core/test_time.py +++ b/tests/modules/core/test_time.py @@ -1,5 +1,58 @@ import pytest +from unittest import TestCase, mock +from freezegun import freeze_time + +import core.config +import core.input +import core.widget +import modules.core.time + +pytest.importorskip("datetime") + +def build_module(args = []): + config = core.config.Config(args) + return modules.core.time.Module(config=config, theme=None) + +def build_widget(): + return core.widget.Widget() + +class TimeTest(TestCase): + def setup_class(self): + locale_patcher = mock.patch('locale.getdefaultlocale') + locale_mock = locale_patcher.start() + locale_mock.return_value = ('en-US', 'UTF-8') + + self.widget = build_widget() + + def test_load_module(self): + __import__("modules.core.time") + + @freeze_time('2020-10-15 03:25:59') + def test_default_format(self): + module = build_module() + assert module.full_text(self.widget) == '03:25:59 AM' + + @freeze_time('2020-10-20 12:30:12') + def test_custom_format(self): + module = build_module(['-p', 'time.format=%H.%M.%S']) + assert module.full_text(self.widget) == '12.30.12' + + @freeze_time('2020-01-10 10:20:30') + @mock.patch('locale.getdefaultlocale') + def test_invalid_locale(self, locale_mock): + locale_mock.return_value = ('in-IN', 'UTF-0') + + module = build_module() + assert module.full_text(self.widget) == '10:20:30 AM' + + @mock.patch('core.input.register') + def test_register_left_mouse_action(self, input_register_mock): + module = build_module() + + input_register_mock.assert_called_with( + module, + button=core.input.LEFT_MOUSE, + cmd='calendar' + ) -def test_load_module(): - __import__("modules.core.time") diff --git a/tests/util/test_format.py b/tests/util/test_format.py index 1e34dec..dc69ced 100644 --- a/tests/util/test_format.py +++ b/tests/util/test_format.py @@ -71,7 +71,7 @@ def test_byteformat(): assert byte(1024 + 512) == "1.50KiB" assert byte(1024 * 1024 * 2 + 1024 * 512) == "2.50MiB" assert byte(1024 * 1024 * 1024 * 4 + 1024 * 1024 * 512) == "4.50GiB" - assert byte(1024 * 1024 * 1024 * 1024 * 2) == "2048.00GiB" + assert byte(1024 * 1024 * 1024 * 1024 * 2) in ["2048.00GiB", "2.00TiB"] def test_duration(): diff --git a/themes/albiceleste-powerline.json b/themes/albiceleste-powerline.json new file mode 100644 index 0000000..e046882 --- /dev/null +++ b/themes/albiceleste-powerline.json @@ -0,0 +1,20 @@ +{ + "icons": [ "awesome-fonts" ], + "defaults": { + "separator-block-width": 0, + "warning": { + "fg": "#000000", + "bg": "#ffff00" + }, + "critical": { + "fg": "#000000", + "bg": "#ffff00" + + } + }, + "cycle": [ + {"fg": "#000000", "bg": "#87ceeb"}, + {"fg": "#000000", "bg": "#FFFFFF"} + ] + +} diff --git a/themes/gruvbox-powerline.json b/themes/gruvbox-powerline.json index 3481dbc..d199243 100644 --- a/themes/gruvbox-powerline.json +++ b/themes/gruvbox-powerline.json @@ -44,29 +44,44 @@ "bg": "#b8bb26" } }, - "bluetooth": { - "ON": { - "fg": "#1d2021", - "bg": "#b8bb26" - } - }, - "git": { - "modified": { "bg": "#458588" }, - "deleted": { "bg": "#9d0006" }, - "new": { "bg": "#b16286" } - }, - "pomodoro": { - "paused": { - "fg": "#1d2021", - "bg": "#d79921" - }, - "work": { - "fg": "#1d2021", - "bg": "#b8bb26" - }, - "break": { - "fg": "#002b36", - "bg": "#859900" - } - } + "bluetooth": { + "ON": { + "fg": "#1d2021", + "bg": "#b8bb26" + } + }, + "git": { + "modified": { "bg": "#458588" }, + "deleted": { "bg": "#9d0006" }, + "new": { "bg": "#b16286" } + }, + "pomodoro": { + "paused": { + "fg": "#1d2021", + "bg": "#d79921" + }, + "work": { + "fg": "#1d2021", + "bg": "#b8bb26" + }, + "break": { + "fg": "#002b36", + "bg": "#859900" + } + }, + "keys": { + "Key.cmd": { + "bg": "#8ec07c", + "full_text": "***" + }, + "Key.shift": { + "bg": "#fabd2f" + }, + "Key.ctrl": { + "bg": "#83a598" + }, + "Key.alt": { + "bg": "#f28019" + } + } } diff --git a/themes/icons/ascii.json b/themes/icons/ascii.json index 2580d32..77ef931 100644 --- a/themes/icons/ascii.json +++ b/themes/icons/ascii.json @@ -355,6 +355,17 @@ "prefix": "dunst" } }, + "dunstctl": { + "muted": { + "prefix": "dunst(muted)" + }, + "unmuted": { + "prefix": "dunst" + }, + "unknown": { + "prefix": "dunst(unknown)" + } + }, "twmn": { "muted": { "prefix": "twmn" diff --git a/themes/icons/awesome-fonts.json b/themes/icons/awesome-fonts.json index ca8d38f..f85dfd8 100644 --- a/themes/icons/awesome-fonts.json +++ b/themes/icons/awesome-fonts.json @@ -7,6 +7,7 @@ "default-separators": false }, "date": { "prefix": "" }, + "persian_date": { "prefix": "" }, "time": { "prefix": "" }, "datetime": { "prefix": "" }, "datetz": { "prefix": "" }, @@ -257,6 +258,14 @@ "muted": { "prefix": "" }, "unmuted": { "prefix": "" } }, + "dunstctl": { + "muted": { "prefix": "" }, + "unmuted": { "prefix": "" }, + "unknown": { "prefix": "" } + }, + "rofication": { + "prefix": "" + }, "twmn": { "muted": { "prefix": "" }, "unmuted": { "prefix": "" } @@ -293,5 +302,8 @@ "speedtest": { "running": { "prefix": ["\uf251", "\uf252", "\uf253"] }, "not-running": { "prefix": "\uf144" } + }, + "thunderbird": { + "prefix": "" } } diff --git a/themes/icons/ionicons.json b/themes/icons/ionicons.json index b510cc1..c9931f1 100644 --- a/themes/icons/ionicons.json +++ b/themes/icons/ionicons.json @@ -187,6 +187,11 @@ "muted": { "prefix": "\uf39a" }, "unmuted": { "prefix": "\uf39b" } }, + "dunstctl": { + "muted": { "prefix": "\uf39a" }, + "unmuted": { "prefix": "\uf39b" }, + "unknown": { "prefix": "\uf142" } + }, "twmn": { "muted": { "prefix": "\uf1f6" }, "unmuted": { "prefix": "\uf0f3" } diff --git a/themes/nord-colorful.json b/themes/nord-colorful.json new file mode 100644 index 0000000..acd334b --- /dev/null +++ b/themes/nord-colorful.json @@ -0,0 +1,59 @@ +{ + "icons": [ "awesome-fonts" ], + "colors": [{ + "red": "#BF616A", + "orange": "#D08770", + "yellow": "#EBCB8B", + "green": "#A3BE8C" + }], + "defaults": { + "separator-block-width": 0, + "warning": { + "fg": "#2E3440", + "bg": "#D08770" + }, + "critical": { + "fg": "#2E3440", + "bg": "#BF616A" + } + }, + "cycle": [ + { "fg": "#000000", "bg": "#8FBCBB"}, + { "fg": "#000000", "bg": "#A3BE8C"}, + { "fg": "#000000", "bg": "#EBCB8B"}, + { "fg": "#000000", "bg": "#BF616A"}, + { "fg": "#000000", "bg": "#B48EAD"} + ], + "dnf": { + "good": { + "fg": "#A3BE8C", + "bg": "#2E3440" + } + }, + "apt": { + "good": { + "fg": "#A3BE8C", + "bg": "#2E3440" + } + }, + "pacman": { + "good": { + "fg": "#A3BE8C", + "bg": "#2E3440" + } + }, + "pomodoro": { + "paused": { + "fg": "#2E3440", + "bg": "#D08770" + }, + "work": { + "fg": "#2E3440", + "bg": "#EBCB8B" + }, + "break": { + "fg": "#A3BE8C", + "bg": "#2E3440" + } + } +} diff --git a/themes/rastafari-powerline.json b/themes/rastafari-powerline.json new file mode 100644 index 0000000..b7c8c21 --- /dev/null +++ b/themes/rastafari-powerline.json @@ -0,0 +1,21 @@ +{ + "icons": [ "awesome-fonts" ], + "defaults": { + "separator-block-width": 0, + "warning": { + "fg": "#000000", + "bg": "#FFFFFF" + }, + "critical": { + "fg": "#000000", + "bg": "#FFFFFF" + } + }, + "cycle": [ + {"fg": "#FFFFFF", "bg": "#008000"}, + {"fg": "#000000", "bg": "#ffff00"}, + {"fg": "#000000", "bg": "#ff0000"} + + ] + +} \ No newline at end of file diff --git a/themes/rose-pine.json b/themes/rose-pine.json new file mode 100644 index 0000000..e1ce49b --- /dev/null +++ b/themes/rose-pine.json @@ -0,0 +1,54 @@ +{ + "icons": ["awesome-fonts"], + "defaults": { + "separator-block-width": 0, + "warning": { + "fg": "#232136", + "bg": "#f6c177" + }, + "critical": { + "fg": "#232136", + "bg": "#eb6f92" + } + }, + "cycle": [ + { "fg": "#232136", "bg": "#ea9a97" }, + { "fg": "#e0def4", "bg": "#393552" } + ], + "dnf": { + "good": { + "fg": "#232136", + "bg": "#9ccfd8" + } + }, + "pacman": { + "good": { + "fg": "#232136", + "bg": "#9ccfd8" + } + }, + "battery": { + "charged": { + "fg": "#232136", + "bg": "#9ccfd8" + }, + "AC": { + "fg": "#232136", + "bg": "#9ccfd8" + } + }, + "pomodoro": { + "paused": { + "fg": "#232136", + "bg": "#f6c177" + }, + "work": { + "fg": "#232136", + "bg": "#9ccfd8" + }, + "break": { + "fg": "#232136", + "bg": "#c4a7e7" + } + } +} diff --git a/themes/zengarden-powerline-light.json b/themes/zengarden-powerline-light.json new file mode 100644 index 0000000..097842a --- /dev/null +++ b/themes/zengarden-powerline-light.json @@ -0,0 +1,78 @@ +{ + "icons": [ "paxy97", "awesome-fonts" ], + "defaults": { + "warning": { + "fg": "#353839", + "bg": "#b38a32" + }, + "critical": { + "fg": "#353839", + "bg": "#d94070" + }, + "default-separators": false, + "separator-block-width": 0 + }, + "fg": "#353839", + "bg": "#c4b6a3", + "dnf": { + "good": { + "fg": "#353839", + "bg": "#378c5d" + } + }, + "apt": { + "good": { + "fg": "#353839", + "bg": "#378c5d" + } + }, + "battery": { + "charged": { + "fg": "#353839", + "bg": "#378c5d" + }, + "AC": { + "fg": "#353839", + "bg": "#378c5d" + } + }, + "bluetooth": { + "ON": { + "fg": "#353839", + "bg": "#378c5d" + } + }, + "git": { + "modified": { "bg": "#174572" }, + "deleted": { "bg": "#ba1d58" }, + "new": { "bg": "#967117" } + }, + "pomodoro": { + "paused": { + "fg": "#353839", + "bg": "#d79921" + }, + "work": { + "fg": "#353839", + "bg": "#378c5d" + }, + "break": { + "fg": "#353839", + "bg": "#378c5d" + } + }, + "keys": { + "Key.cmd": { + "bg": "#477ab7" + }, + "Key.shift": { + "bg": "#b38a32" + }, + "Key.ctrl": { + "bg": "#377c8b" + }, + "Key.alt": { + "bg": "#e05b1f" + } + } +} diff --git a/util/Makefile b/util/Makefile new file mode 100644 index 0000000..1e187c2 --- /dev/null +++ b/util/Makefile @@ -0,0 +1,13 @@ +CC=gcc +CFLAGS= + +%.o: %.c + $(CC) -c -o $@ $< $(CFLAGS) + +../bin/get-kbd-layout: layout.o + $(CC) -o $@ layout.o -lX11 + +.PHONY: clean + +clean: + rm -f *.o diff --git a/util/layout.c b/util/layout.c new file mode 100644 index 0000000..21aac1f --- /dev/null +++ b/util/layout.c @@ -0,0 +1,39 @@ +#include +#include + +#include + +void err_if(int condition, const char* msg) +{ + if (condition) { + fprintf(stderr, "fatal: %s\n", msg); + exit(1); + } +} + +int main(int argc, char** argv) +{ + Display* display = XOpenDisplay(NULL); + err_if(!display, "unable to open display"); + + int kbd = argc == 1 ? XkbUseCoreKbd : atoi(argv[1]); + + XkbStateRec state; + XkbGetState(display, kbd, &state); + + XkbDescPtr desc = XkbGetKeyboard(display, XkbAllComponentsMask, kbd); + char* symbols = XGetAtomName(display, desc->names->symbols); + printf("%s\n", symbols); + +#if 0 + char *group = XGetAtomName(display, desc->names->groups[state.group]); + XFree(group); +#endif + XFree(symbols); + XFree(desc); + + XCloseDisplay(display); + + return 0; +} +