Skip to content

Commit 22455ec

Browse files
Merge pull request #1048 from theymightbetim/scratchpad
feat: Scratchpad Module
2 parents 7b16e05 + 3c55123 commit 22455ec

File tree

9 files changed

+248
-1
lines changed

9 files changed

+248
-1
lines changed

.github/workflows/autotest.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
- name: Update Ubuntu
2626
run: sudo apt-get update
2727
- name: Install Ubuntu dependencies
28-
run: sudo apt-get install -y libdbus-1-dev libgit2-dev libvirt-dev taskwarrior libglib2.0-dev
28+
run: sudo apt-get install -y libdbus-1-dev libgit2-dev libvirt-dev taskwarrior libglib2.0-dev rofi
2929
- name: Install Python dependencies
3030
run: |
3131
python -m pip install --upgrade pip
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# pylint: disable=C0111,R0903
2+
3+
"""Displays a count of windows on the scratchpad, Left click to launch a rofi window picker for scratchpads
4+
5+
Requirements:
6+
* i3ipc
7+
* python-rofi
8+
9+
contributed by `theymightbetim <https://github.com/theymightbetim>`
10+
"""
11+
12+
import threading
13+
14+
try:
15+
import i3ipc
16+
except ImportError:
17+
pass
18+
19+
import core.module
20+
import core.widget
21+
import core.input
22+
23+
from util.rofi import showScratchpads
24+
25+
26+
class Module(core.module.Module):
27+
def __init__(self, config, theme):
28+
super().__init__(config, theme, core.widget.Widget(self.__getTitle))
29+
core.input.register(self, button=core.input.LEFT_MOUSE, cmd=showScratchpads)
30+
31+
self.__scratchpads = 0
32+
self.__title = f"{self.__scratchpads}"
33+
# create a connection with i3ipc
34+
self.__i3 = i3ipc.Connection()
35+
self.__pollScratchpads()
36+
# event is called both on fFocus change and title change, and on workspace change
37+
for event in ["window::move", "window::urgent"]:
38+
self.__i3.on(event, self.__pollScratchpads)
39+
# begin listening for events
40+
threading.Thread(target=self.__i3.main).start()
41+
42+
def __getTitle(self, widget):
43+
return self.__title
44+
45+
def __pollScratchpads(self, *args, **kwargs):
46+
root = self.__i3.get_tree()
47+
scratchpad = root.scratchpad()
48+
if not scratchpad:
49+
return
50+
51+
leaves = getattr(scratchpad, "floating_nodes", [])
52+
53+
count = len(leaves)
54+
55+
if count != self.__scratchpads:
56+
self.__scratchpads = count
57+
self.__title = f"{self.__scratchpads}"
58+
59+
60+
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4

bumblebee_status/util/rofi.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import i3ipc
2+
from rofi import Rofi
3+
4+
5+
def showScratchpads(self):
6+
i3 = i3ipc.Connection()
7+
scratchpad_windows = []
8+
for leaf in i3.get_tree().scratchpad().leaves():
9+
scratchpad_windows.append(leaf)
10+
11+
if len(scratchpad_windows):
12+
# sort by window's name
13+
scratchpad_windows = sorted(scratchpad_windows, key=lambda x: x.ipc_data['name'])
14+
r = Rofi()
15+
scratchpad_windows_name = list(map(lambda x: x.ipc_data['name'], scratchpad_windows))
16+
index, _ = r.select('Select Window in Scratchpad', scratchpad_windows_name)
17+
18+
# select == -1 means nothing select
19+
if index != -1:
20+
scratchpad_windows[index].command('focus')

docs/modules.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,6 +1518,19 @@ Parameters:
15181518

15191519
contributed by `lonesomebyte537 <https://github.com/lonesomebyte537>`_ - many thanks!
15201520

1521+
scratchpad
1522+
~~~~~~~~~~
1523+
1524+
Displays a count of windows on the scratchpad, Left click to launch a rofi window picker for scratchpads
1525+
1526+
Requirements:
1527+
* i3ipc
1528+
* python-rofi
1529+
1530+
contributed by `theymightbetim <https://github.com/theymightbetim>`
1531+
1532+
.. image:: ../screenshots/scratchpad.png
1533+
15211534
sensors
15221535
~~~~~~~
15231536

requirements/modules/scratchpad.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
i3ipc
2+
python-rofi

screenshots/scratchpad.png

404 Bytes
Loading
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import pytest
2+
pytest.importorskip("i3ipc")
3+
4+
def test_load_module():
5+
__import__("modules.contrib.scratchpad")

tests/util/test_rofi.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
from bumblebee_status.util.rofi import showScratchpads
2+
from rofi import Rofi
3+
from unittest.mock import patch, MagicMock
4+
import i3ipc
5+
import unittest
6+
7+
class TestRofi(unittest.TestCase):
8+
9+
def showScratchpads(self):
10+
i3 = i3ipc.Connection()
11+
scratchpad_windows = []
12+
for leaf in i3.get_tree().scratchpad().leaves():
13+
scratchpad_windows.append(leaf)
14+
15+
if len(scratchpad_windows):
16+
# sort by window's name
17+
scratchpad_windows = sorted(scratchpad_windows, key=lambda x: x.ipc_data['name'])
18+
r = Rofi()
19+
scratchpad_windows_name = list(map(lambda x: x.ipc_data['name'], scratchpad_windows))
20+
index, _ = r.select('Select Window in Scratchpad', scratchpad_windows_name)
21+
22+
# select == -1 means nothing select
23+
if index != -1:
24+
scratchpad_windows[index].command('focus')
25+
26+
27+
def test_showScratchpads(self):
28+
"""
29+
Test showScratchpads when scratchpad windows exist but no window is selected.
30+
31+
This test verifies that when there are scratchpad windows available,
32+
but the user doesn't select any window (Rofi returns -1), no focus
33+
command is issued.
34+
"""
35+
with patch('i3ipc.Connection') as mock_i3_connection, \
36+
patch('rofi.Rofi') as mock_rofi:
37+
38+
# Mock i3ipc.Connection
39+
mock_i3 = MagicMock()
40+
mock_i3_connection.return_value = mock_i3
41+
mock_tree = MagicMock()
42+
mock_i3.get_tree.return_value = mock_tree
43+
mock_scratchpad = MagicMock()
44+
mock_tree.scratchpad.return_value = mock_scratchpad
45+
46+
# Create mock scratchpad windows
47+
mock_window1 = MagicMock()
48+
mock_window1.ipc_data = {'name': 'Window 1'}
49+
mock_window2 = MagicMock()
50+
mock_window2.ipc_data = {'name': 'Window 2'}
51+
mock_scratchpad.leaves.return_value = [mock_window1, mock_window2]
52+
53+
# Mock Rofi
54+
mock_rofi_instance = MagicMock()
55+
mock_rofi.return_value = mock_rofi_instance
56+
mock_rofi_instance.select.return_value = (-1, None) # Simulate no selection
57+
58+
# Call the method under test
59+
showScratchpads(None)
60+
61+
# Verify that no focus command was issued
62+
mock_window1.command.assert_not_called()
63+
mock_window2.command.assert_not_called()
64+
65+
def test_showScratchpads_no_scratchpad_windows(self):
66+
"""
67+
Test the behavior of showScratchpads when there are no scratchpad windows.
68+
This tests the edge case where the scratchpad_windows list is empty.
69+
"""
70+
rofi = TestRofi()
71+
72+
# Mock i3ipc.Connection to return an empty scratchpad
73+
class MockI3:
74+
def get_tree(self):
75+
return self
76+
def scratchpad(self):
77+
return self
78+
def leaves(self):
79+
return []
80+
81+
i3ipc.Connection = lambda: MockI3()
82+
83+
# Call the method
84+
result = rofi.showScratchpads()
85+
86+
# Assert that the method returns None (implicit return)
87+
assert result is None
88+
89+
def test_showScratchpads_user_cancels_selection(self):
90+
"""
91+
Test the behavior of showScratchpads when the user cancels the window selection.
92+
This tests the edge case where the Rofi selection returns -1.
93+
"""
94+
rofi = TestRofi()
95+
96+
# Mock i3ipc.Connection to return some scratchpad windows
97+
class MockWindow:
98+
def __init__(self, name):
99+
self.ipc_data = {'name': name}
100+
101+
class MockI3:
102+
def get_tree(self):
103+
return self
104+
def scratchpad(self):
105+
return self
106+
def leaves(self):
107+
return [MockWindow("Window1"), MockWindow("Window2")]
108+
109+
i3ipc.Connection = lambda: MockI3()
110+
111+
# Mock Rofi to simulate user cancellation
112+
class MockRofi:
113+
def select(self, prompt, options):
114+
return -1, None
115+
116+
Rofi = MockRofi
117+
118+
# Call the method
119+
result = rofi.showScratchpads()
120+
121+
# Assert that the method returns None (implicit return)
122+
assert result is None
123+
124+
def test_showScratchpads_when_no_scratchpad_windows(self):
125+
"""
126+
Test showScratchpads when there are no scratchpad windows.
127+
This test verifies that the method handles the case where
128+
no scratchpad windows are found correctly.
129+
"""
130+
with patch('i3ipc.Connection') as mock_connection:
131+
mock_i3 = MagicMock()
132+
mock_connection.return_value = mock_i3
133+
mock_tree = MagicMock()
134+
mock_i3.get_tree.return_value = mock_tree
135+
mock_scratchpad = MagicMock()
136+
mock_tree.scratchpad.return_value = mock_scratchpad
137+
mock_scratchpad.leaves.return_value = []
138+
139+
showScratchpads(self)
140+
141+
mock_connection.assert_called_once()
142+
mock_i3.get_tree.assert_called_once()
143+
mock_tree.scratchpad.assert_called_once()
144+
mock_scratchpad.leaves.assert_called_once()

themes/icons/awesome-fonts.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,5 +745,8 @@
745745
},
746746
"oled_offset": {
747747
"padding": ""
748+
},
749+
"scratchpad": {
750+
"prefix": "\uf2d1"
748751
}
749752
}

0 commit comments

Comments
 (0)