diff --git a/bumblebee-status b/bumblebee-status index 28e8e88..44eae21 100755 --- a/bumblebee-status +++ b/bumblebee-status @@ -5,23 +5,34 @@ import bumblebee.theme import bumblebee.engine import bumblebee.config import bumblebee.output +import bumblebee.input def main(): config = bumblebee.config.Config(sys.argv[1:]) theme = bumblebee.theme.Theme(config.theme()) output = bumblebee.output.I3BarOutput(theme=theme) + inp = bumblebee.input.I3BarInput() engine = bumblebee.engine.Engine( config=config, output=output, + inp=inp, ) - engine.run() - -if __name__ == "__main__": try: - main() + engine.run() + except KeyboardInterrupt as error: + inp.stop() + sys.exit(0) except bumblebee.error.BaseError as error: + inp.stop() sys.stderr.write("fatal: {}\n".format(error)) sys.exit(1) + except Exception as error: + inp.stop() + sys.stderr.write("fatal: {}\n".format(error)) + sys.exit(2) + +if __name__ == "__main__": + main() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee/engine.py b/bumblebee/engine.py index 463d244..40cd152 100644 --- a/bumblebee/engine.py +++ b/bumblebee/engine.py @@ -30,6 +30,7 @@ class Module(object): self._config = config if "name" not in self._config: self._config["name"] = self.name + self.id = self._config["name"] self._widgets = [] if widgets: self._widgets = widgets if isinstance(widgets, list) else [widgets] @@ -53,12 +54,14 @@ class Engine(object): This class connects input/output, instantiates all required modules and drives the "event loop" """ - def __init__(self, config, output=None): + def __init__(self, config, output=None, inp=None): self._output = output self._config = config self._running = True self._modules = [] + self.input = inp self.load_modules(config.modules()) + self.input.start() def load_modules(self, modules): """Load specified modules and return them as list""" @@ -96,12 +99,13 @@ class Engine(object): module.update(module.widgets()) for widget in module.widgets(): widget.link_module(module) - self._output.draw(widget=widget, engine=self) + self._output.draw(widget=widget, module=module, engine=self) self._output.flush() self._output.end() if self.running(): time.sleep(1) self._output.stop() + self.input.stop() # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee/input.py b/bumblebee/input.py new file mode 100644 index 0000000..b28c237 --- /dev/null +++ b/bumblebee/input.py @@ -0,0 +1,77 @@ +"""Input classes""" + +import sys +import json +import uuid +import time +import threading +import bumblebee.util + +LEFT_MOUSE = 1 +RIGHT_MOUSE = 3 + +def read_input(inp): + """Read i3bar input and execute callbacks""" + while inp.running: + line = sys.stdin.readline().strip(",").strip() + inp.has_event = True + try: + event = json.loads(line) + inp.callback(event) + except ValueError: + pass + inp.has_event = True + inp.clean_exit = True + +class I3BarInput(object): + """Process incoming events from the i3bar""" + def __init__(self): + self.running = True + self._thread = threading.Thread(target=read_input, args=(self,)) + self._callbacks = {} + self.clean_exit = False + self.global_id = str(uuid.uuid4()) + self.need_event = False + self.has_event = False + + def start(self): + """Start asynchronous input processing""" + self._thread.start() + + def alive(self): + """Check whether the input processing is still active""" + return self._thread.is_alive() + + def stop(self): + """Stop asynchronous input processing""" + if self.need_event: + while not self.has_event: + time.sleep(0.1) + self.running = False + self._thread.join() + return self.clean_exit + + def register_callback(self, obj, button, cmd): + """Register a callback function or system call""" + uid = self.global_id + if obj: + uid = obj.id + + if uid not in self._callbacks: + self._callbacks[uid] = {} + self._callbacks[uid][button] = cmd + + def callback(self, event): + """Execute callback action for an incoming event""" + cmd = self._callbacks.get(self.global_id, {}) + cmd = self._callbacks.get(event["name"], cmd) + cmd = self._callbacks.get(event["instance"], cmd) + cmd = cmd.get(event["button"], None) + if cmd is None: + return + if callable(cmd): + cmd(event) + else: + bumblebee.util.execute(cmd) + +# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee/modules/cpu.py b/bumblebee/modules/cpu.py index 2951ad8..b02b95b 100644 --- a/bumblebee/modules/cpu.py +++ b/bumblebee/modules/cpu.py @@ -11,6 +11,8 @@ class Module(bumblebee.engine.Module): bumblebee.output.Widget(full_text=self.utilization) ) self._utilization = psutil.cpu_percent(percpu=False) + engine.input.register_callback(self, button=bumblebee.input.LEFT_MOUSE, + cmd="gnome-system-monitor") def utilization(self): return "{:05.02f}%".format(self._utilization) diff --git a/bumblebee/output.py b/bumblebee/output.py index 6654cf7..e67eff6 100644 --- a/bumblebee/output.py +++ b/bumblebee/output.py @@ -4,12 +4,14 @@ import sys import json +import uuid class Widget(object): """Represents a single visible block in the status bar""" def __init__(self, full_text): self._full_text = full_text self.module = None + self.id = str(uuid.uuid4()) def link_module(self, module): """Set the module that spawned this widget @@ -43,7 +45,7 @@ class I3BarOutput(object): """Finish i3bar protocol""" sys.stdout.write("]\n") - def draw(self, widget, engine=None): + def draw(self, widget, module=None, engine=None): """Draw a single widget""" full_text = widget.full_text() padding = self._theme.padding(widget) @@ -68,6 +70,8 @@ class I3BarOutput(object): "background": self._theme.bg(widget), "separator_block_width": self._theme.separator_block_width(widget), "separator": True if separator is None else False, + "instance": widget.id, + "name": module.id, }) def begin(self): diff --git a/bumblebee/util.py b/bumblebee/util.py new file mode 100644 index 0000000..9896c3f --- /dev/null +++ b/bumblebee/util.py @@ -0,0 +1,21 @@ +import shlex +import subprocess + +try: + from exceptions import RuntimeError +except ImportError: + # Python3 doesn't require this anymore + pass + +def execute(cmd, wait=True): + args = shlex.split(cmd) + proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + if wait: + out, _ = proc.communicate() + if proc.returncode != 0: + raise RuntimeError("{} exited with {}".format(cmd, proc.returncode)) + return out.decode("utf-8") + return None + +# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/tests/test_engine.py b/tests/test_engine.py index f48feb4..069a71d 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -6,11 +6,11 @@ from bumblebee.error import ModuleLoadError from bumblebee.engine import Engine from bumblebee.config import Config -from tests.util import MockOutput +from tests.util import MockOutput, MockInput class TestEngine(unittest.TestCase): def setUp(self): - self.engine = Engine(config=Config(), output=MockOutput()) + self.engine = Engine(config=Config(), output=MockOutput(), inp=MockInput()) self.singleWidgetModule = [{"module": "test", "name": "a"}] self.testModule = "test" self.invalidModule = "no-such-module" diff --git a/tests/test_i3barinput.py b/tests/test_i3barinput.py new file mode 100644 index 0000000..7841e1a --- /dev/null +++ b/tests/test_i3barinput.py @@ -0,0 +1,120 @@ +# pylint: disable=C0103,C0111 + +import unittest +import json +import subprocess +import mock + +import bumblebee.input +from bumblebee.input import I3BarInput +from tests.util import MockWidget, MockModule + +class TestI3BarInput(unittest.TestCase): + def setUp(self): + self.inp = I3BarInput() + self.inp.need_event = True + self.anyModule = MockModule() + self.anyWidget = MockWidget("test") + self.anyModule.id = "test-module" + self._called = 0 + + def callback(self, event): + self._called += 1 + + @mock.patch("sys.stdin") + def test_basic_read_event(self, mock_input): + mock_input.readline.return_value = "" + self.inp.start() + self.inp.stop() + mock_input.readline.assert_any_call() + + @mock.patch("sys.stdin") + def test_ignore_invalid_data(self, mock_input): + mock_input.readline.return_value = "garbage" + self.inp.start() + self.assertEquals(self.inp.alive(), True) + self.assertEquals(self.inp.stop(), True) + mock_input.readline.assert_any_call() + + @mock.patch("sys.stdin") + def test_ignore_invalid_event(self, mock_input): + mock_input.readline.return_value = json.dumps({ + "name": None, + "instance": None, + "button": None, + }) + self.inp.start() + self.assertEquals(self.inp.alive(), True) + self.assertEquals(self.inp.stop(), True) + mock_input.readline.assert_any_call() + + @mock.patch("sys.stdin") + def test_global_callback(self, mock_input): + mock_input.readline.return_value = json.dumps({ + "name": "somename", + "instance": "someinstance", + "button": bumblebee.input.LEFT_MOUSE, + }) + self.inp.register_callback(None, button=1, cmd=self.callback) + self.inp.start() + self.assertEquals(self.inp.stop(), True) + mock_input.readline.assert_any_call() + self.assertTrue(self._called > 0) + + @mock.patch("sys.stdin") + def test_global_callback_button_missmatch(self, mock_input): + mock_input.readline.return_value = json.dumps({ + "name": "somename", + "instance": "someinstance", + "button": bumblebee.input.RIGHT_MOUSE, + }) + self.inp.register_callback(None, button=1, cmd=self.callback) + self.inp.start() + self.assertEquals(self.inp.stop(), True) + mock_input.readline.assert_any_call() + self.assertTrue(self._called == 0) + + @mock.patch("sys.stdin") + def test_module_callback(self, mock_input): + mock_input.readline.return_value = json.dumps({ + "name": self.anyModule.id, + "instance": None, + "button": bumblebee.input.LEFT_MOUSE, + }) + self.inp.register_callback(self.anyModule, button=1, cmd=self.callback) + self.inp.start() + self.assertEquals(self.inp.stop(), True) + mock_input.readline.assert_any_call() + self.assertTrue(self._called > 0) + + @mock.patch("sys.stdin") + def test_widget_callback(self, mock_input): + mock_input.readline.return_value = json.dumps({ + "name": "test", + "instance": self.anyWidget.id, + "button": bumblebee.input.LEFT_MOUSE, + }) + self.inp.register_callback(self.anyWidget, button=1, cmd=self.callback) + self.inp.start() + self.assertEquals(self.inp.stop(), True) + mock_input.readline.assert_any_call() + self.assertTrue(self._called > 0) + + @mock.patch("subprocess.Popen") + @mock.patch("sys.stdin") + def test_widget_cmd_callback(self, mock_input, mock_output): + mock_input.readline.return_value = json.dumps({ + "name": "test", + "instance": self.anyWidget.id, + "button": bumblebee.input.LEFT_MOUSE, + }) + self.inp.register_callback(self.anyWidget, button=1, cmd="echo") + self.inp.start() + self.assertEquals(self.inp.stop(), True) + mock_input.readline.assert_any_call() + mock_output.assert_called_with(["echo"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT + ) + +# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/tests/test_i3baroutput.py b/tests/test_i3baroutput.py index 60ed671..f4c4cca 100644 --- a/tests/test_i3baroutput.py +++ b/tests/test_i3baroutput.py @@ -9,8 +9,7 @@ except ImportError: from io import StringIO from bumblebee.output import I3BarOutput -from tests.util import MockWidget -from tests.util import MockTheme +from tests.util import MockWidget, MockTheme, MockModule class TestI3BarOutput(unittest.TestCase): def setUp(self): @@ -19,6 +18,7 @@ class TestI3BarOutput(unittest.TestCase): self.expectedStart = json.dumps({"version": 1, "click_events": True}) + "[\n" self.expectedStop = "]\n" self.someWidget = MockWidget("foo bar baz") + self.anyModule = MockModule(None, None) self.anyColor = "#ababab" self.anotherColor = "#cccccc" @@ -34,7 +34,7 @@ class TestI3BarOutput(unittest.TestCase): @mock.patch("sys.stdout", new_callable=StringIO) def test_draw_single_widget(self, stdout): - self.output.draw(self.someWidget) + self.output.draw(self.someWidget, self.anyModule) self.output.flush() result = json.loads(stdout.getvalue())[0] self.assertEquals(result["full_text"], self.someWidget.full_text()) @@ -42,7 +42,7 @@ class TestI3BarOutput(unittest.TestCase): @mock.patch("sys.stdout", new_callable=StringIO) def test_draw_multiple_widgets(self, stdout): for widget in [self.someWidget, self.someWidget]: - self.output.draw(widget) + self.output.draw(widget, self.anyModule) self.output.flush() result = json.loads(stdout.getvalue()) for res in result: @@ -61,7 +61,7 @@ class TestI3BarOutput(unittest.TestCase): @mock.patch("sys.stdout", new_callable=StringIO) def test_prefix(self, stdout): self.theme.attr_prefix = " - " - self.output.draw(self.someWidget) + self.output.draw(self.someWidget, self.anyModule) self.output.flush() result = json.loads(stdout.getvalue())[0] self.assertEquals(result["full_text"], "{}{}".format( @@ -71,7 +71,7 @@ class TestI3BarOutput(unittest.TestCase): @mock.patch("sys.stdout", new_callable=StringIO) def test_suffix(self, stdout): self.theme.attr_suffix = " - " - self.output.draw(self.someWidget) + self.output.draw(self.someWidget, self.anyModule) self.output.flush() result = json.loads(stdout.getvalue())[0] self.assertEquals(result["full_text"], "{}{}".format( @@ -82,7 +82,7 @@ class TestI3BarOutput(unittest.TestCase): def test_bothfix(self, stdout): self.theme.attr_suffix = " - " self.theme.attr_prefix = " * " - self.output.draw(self.someWidget) + self.output.draw(self.someWidget, self.anyModule) self.output.flush() result = json.loads(stdout.getvalue())[0] self.assertEquals(result["full_text"], "{}{}{}".format( @@ -95,7 +95,7 @@ class TestI3BarOutput(unittest.TestCase): def test_colors(self, stdout): self.theme.attr_fg = self.anyColor self.theme.attr_bg = self.anotherColor - self.output.draw(self.someWidget) + self.output.draw(self.someWidget, self.anyModule) self.output.flush() result = json.loads(stdout.getvalue())[0] self.assertEquals(result["color"], self.anyColor) diff --git a/tests/util.py b/tests/util.py index fcf6c9d..1c244c8 100644 --- a/tests/util.py +++ b/tests/util.py @@ -6,8 +6,19 @@ def assertWidgetAttributes(test, widget): test.assertTrue(isinstance(widget, Widget)) test.assertTrue(hasattr(widget, "full_text")) +class MockInput(object): + def start(self): + pass + + def stop(self): + pass + + def register_callback(self, obj, button, cmd): + pass + class MockEngine(object): - pass + def __init__(self): + self.input = MockInput() class MockOutput(object): def start(self): @@ -16,7 +27,7 @@ class MockOutput(object): def stop(self): pass - def draw(self, widget, engine): + def draw(self, widget, engine, module): engine.stop() def begin(self): @@ -28,12 +39,17 @@ class MockOutput(object): def end(self): pass +class MockModule(object): + def __init__(self, engine=None, config=None): + self.id = None + class MockWidget(Widget): def __init__(self, text): super(MockWidget, self).__init__(text) self._text = text self.module = None self.attr_state = "state-default" + self.id = "none" def state(self): return self.attr_state