Compare commits

...

10 commits
0.0.7 ... main

Author SHA1 Message Date
Mikael Nordin
51eb844678
Update README.md 2021-01-05 23:41:23 +01:00
Mikael Nordin
2e09a4c4a9
Update README.md 2021-01-05 19:43:43 +01:00
df47274af9 Sorting functions 2021-01-05 11:38:26 +01:00
c7d9040ad9 Bump version 2021-01-05 11:09:15 +01:00
c113b04eaf Now accepts options from config file 2021-01-05 11:08:18 +01:00
89ba780e4a Refactoring and clean up 2021-01-05 10:16:33 +01:00
Mikael Nordin
f480b9bf88
Update README.md
Dependencies are noted in debian package
2021-01-04 16:35:46 +01:00
f265731ce5 Update screenshot 2021-01-04 16:32:02 +01:00
fcc799a1d4 Bump version 2021-01-04 16:21:06 +01:00
aabaeea9ce Use svg icons if available 2021-01-04 16:20:37 +01:00
7 changed files with 243 additions and 157 deletions

View file

@ -1,10 +1,5 @@
# swayswitch # swayswitch
A simple window switcher for Sway wayland compositor written in python using wxPython A simple window switcher for the [Sway](https://swaywm.org/) Wayland compositor written in python using wxPython.
## Dependencies
For Debian/Ubuntu: ```python3-wxgtk4.0```
For Fedora/RHEL: ```python3-wxpython4```
## Installation ## Installation
@ -25,12 +20,23 @@ sudo dnf install swayswitch
## Usage ## Usage
Reload config and open up window switcher with Mod4+tab. Move around the switcher using arrow-keys or Tab. Reload config and open up window switcher with Mod4+tab. Move around the switcher using arrow-keys or Tab.
Esc aborts and enter switches window. It is also possible to select window with the mouse. Configuration is installed to /etc/sway/config.d/swayswitch.conf Esc aborts and enter switches window. It is also possible to select window with the mouse.
Two keybindings work by default, Mod4+f to toggle fullscreen mode, that is if you manage to bring up the switcher while in fullscreen mode you can display the Two keybindings work by default, Mod4+f to toggle fullscreen mode, that is if you manage to bring up the switcher while in fullscreen mode you can display the
switcher window by exiting fullscreen mode. You can also exit switcher mode by pressing Mod4+q, this is usefull if you manage to get another window on to of switcher window by exiting fullscreen mode. You can also exit switcher mode by pressing Mod4+q, this is usefull if you manage to get another window on top of
the switcher window somehow. the switcher window somehow.
### Config files
SwaySwitch uses two configuration files, one is supplied with the package and is installed to /etc/sway/config.d/swayswitch.conf. It contains default keybindings for Sway.
The other one is optionally supplied by the user as $HOME/.local/swayswitch/config in toml format.
Possible options are:
```
label_len = 20
icon_size = 128
```
The example above is the default options. ```label_len``` is the total length of the text label above buttons in number of characters and ```icon_size``` is the size of the icons in pixels.
## Thanks ## Thanks
Thanks to tobiaspc for the startingpoint for this code: <https://github.com/tobiaspc/wofi-scripts> Thanks to tobiaspc for the startingpoint for this code: <https://github.com/tobiaspc/wofi-scripts>

View file

@ -1 +1 @@
0.0.7 0.1.0

14
debian/changelog vendored
View file

@ -1,3 +1,17 @@
swayswitch (0.1.0) unstable; urgency=low
* Uses optional config file
-- Micke Nordin <hej@mic.ke> Tue, 05 Jan 2021 11:09:04 +0100
swayswitch (0.0.8) unstable; urgency=low
* Use svg icons if possible
-- Micke Nordin <hej@mic.ke> Mon, 04 Jan 2021 16:20:58 +0100
swayswitch (0.0.7) unstable; urgency=low swayswitch (0.0.7) unstable; urgency=low
* Better error handling * Better error handling

2
debian/control vendored
View file

@ -10,6 +10,6 @@ Package: swayswitch
Section: utils Section: utils
Priority: optional Priority: optional
Architecture: all Architecture: all
Depends: python3-wxgtk4.0, sway Depends: python3-cairosvg, python3-toml, python3-wxgtk4.0, sway
Essential: no Essential: no
Description: SwaySwitch is a simple window switcher for Sway Description: SwaySwitch is a simple window switcher for Sway

View file

@ -2,7 +2,7 @@ Buildroot: /home/micke/sources/swayswitch-##VERSION##
Name: swayswitch Name: swayswitch
Version: ##VERSION## Version: ##VERSION##
Release: 1 Release: 1
Requires: python3-wxpython4 Requires: python3-cairosvg, python3-toml, python3-wxpython4, sway
Summary: SwaySwitch is a simple window switcher for sway Summary: SwaySwitch is a simple window switcher for sway
License: GPLv3+ License: GPLv3+
Distribution: Fedora Distribution: Fedora
@ -18,6 +18,8 @@ SwaySwitch is a simple window switcher for sway:
<https://swaywm.org> <https://swaywm.org>
%files %files
%dir "/etc/sway/config.d/"
"/etc/sway/config.d/swayswitch.conf"
"/usr/bin/swayswitch" "/usr/bin/swayswitch"
%dir "/usr/share/doc/swayswitch/" %dir "/usr/share/doc/swayswitch/"
"/usr/share/doc/swayswitch/changelog.gz" "/usr/share/doc/swayswitch/changelog.gz"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 1 MiB

View file

@ -10,13 +10,16 @@ import json
import math import math
import os import os
import subprocess import subprocess
from io import BytesIO
import cairosvg
import toml
import wx import wx
class SwaySwitch(wx.Frame): # pylint: disable=no-member class SwaySwitch(wx.Frame): # pylint: disable=no-member
"""Frame for the swayswitcher""" """Frame for the swayswitcher"""
def __init__(self, *args, **kw): # pylint: disable=unused-argument,too-many-locals def __init__(self, *args, **kw): # pylint: disable=unused-argument
"""Constructor""" """Constructor"""
wx.Frame.__init__(self, None, title="", style=wx.STAY_ON_TOP) # pylint: disable=no-member wx.Frame.__init__(self, None, title="", style=wx.STAY_ON_TOP) # pylint: disable=no-member
# create a panel in the frame # create a panel in the frame
@ -24,7 +27,29 @@ class SwaySwitch(wx.Frame): # pylint: disable=no-member
# Some xdg data # Some xdg data
self.home = os.environ.get('HOME') self.home = os.environ.get('HOME')
self.base_dirs = [] self.base_dirs = self.get_base_dirs()
# get windows from sway
windows = get_windows()
# Config defaults
self.label_len = 20
self.icon_size = 128
# Can be overwritten in config_file $HOME/.local/swayswitch/config
self.set_config_from_file()
self.icon_dirs = self.get_icon_dirs()
# and create a sizer to manage the layout of child widgets
x_and_y = int(math.sqrt(len(windows)) + 0.5)
self.sizer = wx.GridSizer(x_and_y) # pylint: disable=no-member
self.pnl.SetSizer(self.sizer)
self.set_buttons(windows)
self.sizer.Fit(self)
self.sizer.Layout()
def get_base_dirs(self):
"""We want to follow the XDG standard if possible"""
base_dirs = []
xdg_dirs = os.environ.get('XDG_DATA_DIRS') xdg_dirs = os.environ.get('XDG_DATA_DIRS')
pos_dirs = [] pos_dirs = []
if xdg_dirs: if xdg_dirs:
@ -37,49 +62,137 @@ class SwaySwitch(wx.Frame): # pylint: disable=no-member
] ]
for pos_dir in pos_dirs: for pos_dir in pos_dirs:
if os.path.exists(pos_dir): if os.path.exists(pos_dir):
self.base_dirs.append(pos_dir) base_dirs.append(pos_dir)
return base_dirs
# get windows from sway def get_desktop_file(self, command):
windows = get_windows() """From here we return first dektopfile"""
label_len = 20 desktop_file = None
for base_dir in self.base_dirs:
directory = base_dir + "/applications"
if os.path.exists(directory):
command = "grep -s -l {} {}/*.desktop".format(
command, directory)
for data in [i for i in run_command(command) if i]:
for result in data.decode().split('\n'):
desktop_file = result
break
return desktop_file
# Icon size def get_icon_bitmap(self, icon):
self.icon_size = 128 """Create a bitmap with right size from svg or png"""
if icon:
if icon.endswith(".svg"):
svgpng = cairosvg.svg2png(
bytestring=open(icon).read().encode('utf-8'))
image = wx.Image(BytesIO(svgpng), wx.BITMAP_TYPE_PNG) # pylint: disable=no-member
else:
unscaled_bitmap = wx.Bitmap(icon) # pylint: disable=no-member
image = unscaled_bitmap.ConvertToImage()
image = image.Scale(self.icon_size, self.icon_size,
wx.IMAGE_QUALITY_HIGH) # pylint: disable=no-member
bitmap = wx.Bitmap(image) # pylint: disable=no-member
else:
bitmap = wx.Bitmap() # pylint: disable=no-member
return bitmap
# and create a sizer to manage the layout of child widgets def get_icon_dirs(self):
x_and_y = int(math.sqrt(len(windows)) + 0.5) """Find icon dirs"""
sizer = wx.GridSizer(x_and_y) # pylint: disable=no-member icon_dirs = []
self.pnl.SetSizer(sizer) for base_dir in [
for window in windows: directory for directory in self.base_dirs if directory
inner_sizer = wx.BoxSizer(orient=wx.VERTICAL) # pylint: disable=no-member ]:
directory = base_dir + "/icons"
icon_dirs.append(directory)
icon_dirs.append(self.home + "/.icons")
icon_dirs.append("/usr/share/pixmaps")
return icon_dirs
def get_icon_path(self, desktop_file):
"""Glue function to get icon path"""
icon_name = get_icon_name(desktop_file)
pos_icons = self.get_pos_icons(icon_name)
icon_path = self.get_right_size_icon(pos_icons)
return icon_path
def get_label(self, window):
"""Construct the label for the window"""
try:
label = window['window_properties']['class']
except KeyError:
try: try:
label = window['window_properties']['class'] label = window['app_id']
if len(label) < self.label_len:
label = label + ':' + window['name'][:self.label_len -
len(label)]
except KeyError: except KeyError:
try: label = window['name']
label = window['app_id'] if len(label) > self.label_len:
if len(label) < label_len: label = label[:self.label_len]
label = label + ':' + window['name'][:label_len -
len(label)]
except KeyError:
label = window['name']
if len(label) > label_len:
label = label[:label_len]
label = "ws" + str(window['workspace']) + ": " + label
return label
def get_pos_icons(self, icon_name):
"""Collect possible icons, prefer svg"""
possible_icons = []
for icon_dir in [
directory for directory in self.icon_dirs if directory
]:
if os.path.exists(icon_dir):
command = "find {} -name *{}.svg".format(icon_dir, icon_name)
result = run_command(command)[0].split()
if not result:
command = "find {} -name *{}.png".format(
icon_dir, icon_name)
result = run_command(command)[0].split()
for data in result:
icon_cand = data.decode()
if icon_cand not in possible_icons:
possible_icons.append(icon_cand)
return possible_icons
def get_right_size_icon(self, possible_icons):
"""Try to find prefered size"""
icon = None
for pos_icon in possible_icons:
if pos_icon.endswith(".svg"):
icon = pos_icon
break
if not icon and (str(self.icon_size) + "x" + str(self.icon_size)
in pos_icon):
icon = pos_icon
if not icon:
try:
icon = sorted(possible_icons)[0]
except IndexError:
icon = ""
return icon
def on_key_press(self, event):
"""Intercept esc key press"""
keycode = event.GetUnicodeKey()
if keycode == wx.WXK_ESCAPE: # pylint: disable=no-member
# enter normal mode and exit
command = 'swaymsg mode "default"'
run_command(command)
self.Close(True)
else:
event.Skip(True)
def set_buttons(self, windows):
"""Loop over windows and create buttons for them"""
for window in windows:
# This is setting up an inner sizer with a static text label and an image icon # This is setting up an inner sizer with a static text label and an image icon
label = "ws" + str(window['workspace']) + ": " + label inner_sizer = wx.BoxSizer(orient=wx.VERTICAL) # pylint: disable=no-member
label = self.get_label(window)
winid = window['id'] winid = window['id']
size = wx.Window.GetFont(self).GetPointSize() * label_len # pylint: disable=no-member size = wx.Window.GetFont(self).GetPointSize() * self.label_len # pylint: disable=no-member
command = get_command(window['pid']) command = get_command(window['pid'])
desktop_file = self.get_desktop_file(command) desktop_file = self.get_desktop_file(command)
icon = self.get_icon(desktop_file) # pylint: disable=no-member icon_path = self.get_icon_path(desktop_file)
if icon: bitmap = self.get_icon_bitmap(icon_path)
unscaled_bitmap = wx.Bitmap(self.get_icon(desktop_file)) # pylint: disable=no-member
image = unscaled_bitmap.ConvertToImage()
image = image.Scale(self.icon_size, self.icon_size,
wx.IMAGE_QUALITY_HIGH) # pylint: disable=no-member
bitmap = wx.Bitmap(image) # pylint: disable=no-member
else:
bitmap = wx.Bitmap()
btn = wx.BitmapButton( # pylint: disable=no-member btn = wx.BitmapButton( # pylint: disable=no-member
self.pnl, self.pnl,
id=winid, id=winid,
@ -95,126 +208,31 @@ class SwaySwitch(wx.Frame): # pylint: disable=no-member
inner_sizer.Add(btn, 0, wx.ALIGN_CENTER) # pylint: disable=no-member inner_sizer.Add(btn, 0, wx.ALIGN_CENTER) # pylint: disable=no-member
inner_sizer.Fit(self) inner_sizer.Fit(self)
inner_sizer.Layout() inner_sizer.Layout()
sizer.Add(inner_sizer, 0, wx.ALIGN_CENTER) # pylint: disable=no-member self.sizer.Add(inner_sizer, 0, wx.ALIGN_CENTER) # pylint: disable=no-member
sizer.Fit(self)
sizer.Layout()
def get_desktop_file(self, command): def set_config_from_file(self):
"""From here we return first dektopfile""" """If user has created a config file we will use values from there"""
desktop_file = None config_file = self.home + "/.local/swayswitch/config"
for base_dir in self.base_dirs: if os.path.exists(config_file):
directory = base_dir + "/applications"
if os.path.exists(directory):
command = "grep -s -l {} {}/*.desktop".format(command, directory)
process = subprocess.Popen(command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
for data in [i for i in process.communicate() if i]:
for result in data.decode().split('\n'):
print(result)
desktop_file = result
break
return desktop_file
def get_icon(self, desktop_file):
""" Find icon from a desktop file"""
command = "grep Icon= {}".format(desktop_file)
process = subprocess.Popen(command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
for data in [i.rstrip() for i in process.communicate() if i]:
try: try:
icon_name = data.decode().split('=')[1] cfg = toml.load(config_file)
except IndexError: if cfg["label_len"]:
return "" self.label_len = cfg["label_len"]
# Find icon dirs if cfg["icon_size"]:
icon_dirs = [] self.icon_size = cfg["icon_size"]
for base_dir in [ except toml.TomlDecodeError:
directory for directory in self.base_dirs if directory #If use has formating errors we warn to stderr, but move on with defaults
]: os.write(
directory = base_dir + "/icons" 2, b"WARNING: formatting errors in " +
icon_dirs.append(directory) config_file.encode() + b"\n")
icon_dirs.append(self.home + "/.icons")
icon_dirs.append("/usr/share/pixmaps")
possible_icons = []
for icon_dir in [directory for directory in icon_dirs if directory]:
if os.path.exists(icon_dir):
command = "find {} -name *{}.png".format(icon_dir, icon_name)
process = subprocess.Popen(command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
result = process.communicate()[0].split()
for data in result:
icon_cand = data.decode()
if icon_cand not in possible_icons:
possible_icons.append(icon_cand)
# Try to find prefered size
icon = None
for pos_icon in possible_icons:
if str(self.icon_size) + "x" + str(self.icon_size) in pos_icon:
icon = pos_icon
break
if not icon:
try:
icon = sorted(possible_icons)[0]
except IndexError:
icon = ""
return icon
def on_key_press(self, event):
"""Intercept esc key press"""
keycode = event.GetUnicodeKey()
if keycode == wx.WXK_ESCAPE: # pylint: disable=no-member
# enter normal mode and exit
command = 'swaymsg mode "default"'
subprocess.Popen(command, shell=True, stdout=subprocess.PIPE)
self.Close(True)
else:
event.Skip(True)
def switch_window(self, event, winid): # pylint: disable=unused-argument def switch_window(self, event, winid): # pylint: disable=unused-argument
"""Switches the focus to the given id and enter normalmode""" """Switches the focus to the given id and enter normalmode"""
command = 'swaymsg [con_id={}] focus, mode "default"'.format(winid) command = 'swaymsg [con_id={}] focus, mode "default"'.format(winid)
subprocess.Popen(command, shell=True, stdout=subprocess.PIPE) run_command(command)
self.Close(True) self.Close(True)
def get_command(pid):
"""Returns a list of all json window objects"""
command = "ps h c -p {} -o command".format(pid)
process = subprocess.Popen(command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
result = process.communicate()[0].rstrip().decode()
return result
def get_windows():
"""Returns a list of all json window objects"""
command = "swaymsg -t get_tree"
process = subprocess.Popen(command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
data = json.loads(process.communicate()[0])
# Select outputs that are active
windows = []
for output in data['nodes']:
# The scratchpad (under __i3) is not supported
if output.get('name') != '__i3' and output.get('type') == 'output':
workspaces = output.get('nodes')
for workspace in workspaces:
if workspace.get('type') == 'workspace':
windows += extract_nodes_iterative(workspace)
return windows
def extract_nodes_iterative(workspace): def extract_nodes_iterative(workspace):
"""Extracts all windows from a sway workspace json object""" """Extracts all windows from a sway workspace json object"""
all_nodes = [] all_nodes = []
@ -242,6 +260,52 @@ def extract_nodes_iterative(workspace):
return all_nodes return all_nodes
def get_command(pid):
"""Returns a list of all json window objects"""
command = "ps h c -p {} -o command".format(pid)
result = run_command(command)[0].rstrip().decode()
return result
def get_icon_name(desktop_file):
""" Find icon from a desktop file"""
command = "grep Icon= {}".format(desktop_file)
for data in [i.rstrip() for i in run_command(command) if i]:
try:
icon_name = data.decode().split('=')[1]
return icon_name
except IndexError:
return ""
def get_windows():
"""Returns a list of all json window objects"""
command = "swaymsg -t get_tree"
data = json.loads(run_command(command)[0])
# Select outputs that are active
windows = []
for output in data['nodes']:
# The scratchpad (under __i3) is not supported
if output.get('name') != '__i3' and output.get('type') == 'output':
workspaces = output.get('nodes')
for workspace in workspaces:
if workspace.get('type') == 'workspace':
windows += extract_nodes_iterative(workspace)
return windows
def run_command(command):
"""Run a command on system and capture result"""
process = subprocess.Popen(command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
result = process.communicate()
return result
# Entry point # Entry point
if __name__ == "__main__": if __name__ == "__main__":
# When this module is run (not imported) then create the app, the # When this module is run (not imported) then create the app, the