Compare commits

...

25 commits
0.0.3 ... 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
6c80fd7836 Bump version 2021-01-04 14:49:30 +01:00
257307fbc1 Better error handling 2021-01-04 14:48:50 +01:00
c44dea5228 Bump version 2021-01-04 14:23:07 +01:00
1a50032339 Merge branch 'main' of https://github.com/mickenordin/swayswitch into main 2021-01-04 14:22:32 +01:00
2cc964aa07 Handle non existing icon 2021-01-04 14:22:12 +01:00
Mikael Nordin
4edc1a3103
Update README.md 2021-01-04 14:14:35 +01:00
Mikael Nordin
ca14e335fc
Update README.md 2021-01-04 14:14:20 +01:00
Mikael Nordin
d82b3d254e
Update README.md
Update about default keybindings
2021-01-04 14:12:22 +01:00
a29a4b8290 Update screenshot 2021-01-04 14:07:28 +01:00
817c016d17 Bump version 2021-01-04 14:00:15 +01:00
19eae57536 Add support for icon and switch to Mod4 by default 2021-01-04 13:57:11 +01:00
a3bd8ad3d0 Bump version 2020-12-28 11:49:23 +01:00
dc69905254 Update reame 2020-12-28 11:48:39 +01:00
4e3007aed9 Make it possible to exit switcher mode in switcher mode 2020-12-28 11:45:40 +01:00
749ddf2eef Bump version 2020-12-27 16:21:21 +01:00
8 changed files with 311 additions and 69 deletions

View file

@ -1,10 +1,5 @@
# swayswitch # swayswitch
A simple windowswitcher 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
@ -24,8 +19,23 @@ sudo dnf install swayswitch
``` ```
## Usage ## Usage
Reload config and open up window switcher with $mod+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
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.
## 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.2 0.1.0

49
debian/changelog vendored
View file

@ -1,3 +1,52 @@
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 swayswitch (0.0.2) unstable; urgency=low
* Add ability to toggle fullscreen mode when in switcher mode * Add ability to toggle fullscreen mode when in switcher mode

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: 844 KiB

After

Width:  |  Height:  |  Size: 1 MiB

View file

@ -8,8 +8,12 @@
import json import json
import math import math
import os
import subprocess import subprocess
from io import BytesIO
import cairosvg
import toml
import wx import wx
@ -21,84 +25,214 @@ class SwaySwitch(wx.Frame): # pylint: disable=no-member
# create a panel in the frame # create a panel in the frame
self.pnl = wx.Panel(self) # pylint: disable=no-member 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 # get windows from sway
windows = get_windows() windows = get_windows()
label_len = 20
# 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 # and create a sizer to manage the layout of child widgets
x_and_y = int(math.sqrt(len(windows)) + 0.5) x_and_y = int(math.sqrt(len(windows)) + 0.5)
sizer = wx.GridSizer(x_and_y) # pylint: disable=no-member self.sizer = wx.GridSizer(x_and_y) # pylint: disable=no-member
self.pnl.SetSizer(sizer) self.pnl.SetSizer(self.sizer)
for window in windows: 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:
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)] label = "ws" + str(window['workspace']) + ": " + label
except KeyError: return label
label = window['name']
if len(label) > label_len: def get_pos_icons(self, icon_name):
label = label[:label_len] """Collect possible icons, prefer svg"""
label = "ws" + str(window['workspace']) + ":\n" + label possible_icons = []
winid = window['id'] for icon_dir in [
size = wx.Window.GetFont(self).GetPointSize() * label_len # pylint: disable=no-member directory for directory in self.icon_dirs if directory
btn = wx.Button( # pylint: disable=no-member ]:
parent=self.pnl, if os.path.exists(icon_dir):
id=winid, command = "find {} -name *{}.svg".format(icon_dir, icon_name)
label=label, result = run_command(command)[0].split()
size=wx.Size(size, size)) # pylint: disable=no-member if not result:
btn.Bind( command = "find {} -name *{}.png".format(
wx.EVT_BUTTON, icon_dir, icon_name)
lambda event, mwinid=winid: self.switch_window(event, mwinid)) result = run_command(command)[0].split()
sizer.Add(btn, 0, wx.ALIGN_CENTER) # pylint: disable=no-member for data in result:
# Set up esc keybinding icon_cand = data.decode()
self.Bind(wx.EVT_CHAR_HOOK, lambda event: self.on_key_press(event)) # pylint: disable=unnecessary-lambda if icon_cand not in possible_icons:
sizer.Fit(self) possible_icons.append(icon_cand)
sizer.Layout() 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): def on_key_press(self, event):
"""Intercept esc key press""" """Intercept esc key press"""
keycode = event.GetUnicodeKey() keycode = event.GetUnicodeKey()
if keycode == wx.WXK_ESCAPE: # pylint: disable=no-member if keycode == wx.WXK_ESCAPE: # pylint: disable=no-member
"""enter normal mode and exit""" # enter normal mode and exit
command = 'swaymsg mode "default"' command = 'swaymsg mode "default"'
subprocess.Popen(command, shell=True, stdout=subprocess.PIPE) run_command(command)
self.Close(True) self.Close(True)
else: else:
event.Skip(True) 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 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_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 = []
@ -126,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

View file

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