Compare commits

..

No commits in common. "main" and "0.0.3" have entirely different histories.
main ... 0.0.3

8 changed files with 69 additions and 311 deletions

View file

@ -1,5 +1,10 @@
# swayswitch
A simple window switcher for the [Sway](https://swaywm.org/) Wayland compositor written in python using wxPython.
A simple windowswitcher written in python using wxPython
## Dependencies
For Debian/Ubuntu: ```python3-wxgtk4.0```
For Fedora/RHEL: ```python3-wxpython4```
## Installation
@ -19,23 +24,8 @@ sudo dnf install swayswitch
```
## Usage
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.
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 top of
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.
Reload config and open up window switcher with $mod+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
## Thanks
Thanks to tobiaspc for the startingpoint for this code: <https://github.com/tobiaspc/wofi-scripts>

View file

@ -1 +1 @@
0.1.0
0.0.2

49
debian/changelog vendored
View file

@ -1,52 +1,3 @@
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
* Better error handling
-- Micke Nordin <hej@mic.ke> Mon, 04 Jan 2021 14:49:22 +0100
swayswitch (0.0.6) unstable; urgency=low
* Handle missing icons
-- Micke Nordin <hej@mic.ke> Mon, 04 Jan 2021 14:22:59 +0100
swayswitch (0.0.5) unstable; urgency=low
* Add support for icons
-- Micke Nordin <hej@mic.ke> Mon, 04 Jan 2021 14:00:09 +0100
swayswitch (0.0.4) unstable; urgency=low
* Add keybinding for exiting switcher mode
-- Micke Nordin <hej@mic.ke> Mon, 28 Dec 2020 11:49:12 +0100
swayswitch (0.0.3) unstable; urgency=low
* Don't overwrite conffiles
-- Micke Nordin <hej@mic.ke> Sun, 27 Dec 2020 16:21:14 +0100
swayswitch (0.0.2) unstable; urgency=low
* Add ability to toggle fullscreen mode when in switcher mode

2
debian/control vendored
View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 844 KiB

View file

@ -8,12 +8,8 @@
import json
import math
import os
import subprocess
from io import BytesIO
import cairosvg
import toml
import wx
@ -25,214 +21,84 @@ class SwaySwitch(wx.Frame): # pylint: disable=no-member
# create a panel in the frame
self.pnl = wx.Panel(self) # pylint: disable=no-member
# Some xdg data
self.home = os.environ.get('HOME')
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()
label_len = 20
# 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')
pos_dirs = []
if xdg_dirs:
pos_dirs = xdg_dirs.split(":")
if not pos_dirs:
pos_dirs = [
"/usr/share", "/usr/local/share", self.home + "/.local/share",
self.home + "/.local/share/flatpak/exports/share",
"/var/lib/flatpak/exports/share"
]
for pos_dir in pos_dirs:
if os.path.exists(pos_dir):
base_dirs.append(pos_dir)
return base_dirs
def get_desktop_file(self, command):
"""From here we return first dektopfile"""
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
def get_icon_bitmap(self, icon):
"""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
def get_icon_dirs(self):
"""Find icon dirs"""
icon_dirs = []
for base_dir in [
directory for directory in self.base_dirs if directory
]:
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:
sizer = wx.GridSizer(x_and_y) # pylint: disable=no-member
self.pnl.SetSizer(sizer)
for window in windows:
try:
label = window['app_id']
if len(label) < self.label_len:
label = label + ':' + window['name'][:self.label_len -
len(label)]
label = window['window_properties']['class']
except KeyError:
label = window['name']
if len(label) > self.label_len:
label = label[:self.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
try:
label = window['app_id']
if len(label) < 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']) + ":\n" + label
winid = window['id']
size = wx.Window.GetFont(self).GetPointSize() * label_len # pylint: disable=no-member
btn = wx.Button( # pylint: disable=no-member
parent=self.pnl,
id=winid,
label=label,
size=wx.Size(size, size)) # pylint: disable=no-member
btn.Bind(
wx.EVT_BUTTON,
lambda event, mwinid=winid: self.switch_window(event, mwinid))
sizer.Add(btn, 0, wx.ALIGN_CENTER) # pylint: disable=no-member
# Set up esc keybinding
self.Bind(wx.EVT_CHAR_HOOK, lambda event: self.on_key_press(event)) # pylint: disable=unnecessary-lambda
sizer.Fit(self)
sizer.Layout()
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
"""enter normal mode and exit"""
command = 'swaymsg mode "default"'
run_command(command)
subprocess.Popen(command, shell=True, stdout=subprocess.PIPE)
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
inner_sizer = wx.BoxSizer(orient=wx.VERTICAL) # pylint: disable=no-member
label = self.get_label(window)
winid = window['id']
size = wx.Window.GetFont(self).GetPointSize() * self.label_len # pylint: disable=no-member
command = get_command(window['pid'])
desktop_file = self.get_desktop_file(command)
icon_path = self.get_icon_path(desktop_file)
bitmap = self.get_icon_bitmap(icon_path)
btn = wx.BitmapButton( # pylint: disable=no-member
self.pnl,
id=winid,
bitmap=bitmap,
size=wx.Size(size, size)) # pylint: disable=no-member
btn.Bind(
wx.EVT_BUTTON,
lambda event, mwinid=winid: self.switch_window(event, mwinid))
# Set up esc keybinding
self.Bind(wx.EVT_CHAR_HOOK, lambda event: self.on_key_press(event)) # pylint: disable=unnecessary-lambda
statictext = wx.StaticText(self.pnl, -1, label) # pylint: disable=no-member
inner_sizer.Add(statictext, 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.Layout()
self.sizer.Add(inner_sizer, 0, wx.ALIGN_CENTER) # pylint: disable=no-member
def set_config_from_file(self):
"""If user has created a config file we will use values from there"""
config_file = self.home + "/.local/swayswitch/config"
if os.path.exists(config_file):
try:
cfg = toml.load(config_file)
if cfg["label_len"]:
self.label_len = cfg["label_len"]
if cfg["icon_size"]:
self.icon_size = cfg["icon_size"]
except toml.TomlDecodeError:
#If use has formating errors we warn to stderr, but move on with defaults
os.write(
2, b"WARNING: formatting errors in " +
config_file.encode() + b"\n")
def switch_window(self, event, winid): # pylint: disable=unused-argument
"""Switches the focus to the given id and enter normalmode"""
command = 'swaymsg [con_id={}] focus, mode "default"'.format(winid)
run_command(command)
subprocess.Popen(command, shell=True, stdout=subprocess.PIPE)
self.Close(True)
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):
"""Extracts all windows from a sway workspace json object"""
all_nodes = []
@ -260,52 +126,6 @@ def extract_nodes_iterative(workspace):
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
if __name__ == "__main__":
# When this module is run (not imported) then create the app, the

View file

@ -1,6 +1,5 @@
mode "switcher" {
bindsym Mod4+f fullscreen
bindsym Mod4+q mode "default"
bindsym $mod+f fullscreen
}
bindsym Mod4+Tab exec /usr/bin/swayswitch, mode "switcher"
bindsym $mod+Tab exec /usr/bin/swayswitch, mode "switcher"