Last active
May 19, 2024 19:56
-
-
Save theY4Kman/35acc5815740eb9df2809a1dbc8c8625 to your computer and use it in GitHub Desktop.
Xfce4 panel plugin enabling easy switching between display profiles
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
pygobject | |
dbus-python | |
python-xlib |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
button { | |
background: rgba(0,0,0,0); | |
padding: 0 1px; | |
border-width: 0 1px; | |
border-color: #202020; | |
border-radius: 0; | |
} | |
button:hover { | |
background: rgba(0,0,0,0.2) | |
} | |
button.active { | |
background: rgba(0,0,0,0.5) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# NOTE: this file should go in either $HOME/.local/share/xfce4/panel/plugins, | |
# or $PREFIX/share/xfce4/panel/plugins (where $PREFIX is often /usr) | |
[Xfce Panel] | |
Type=X-XFCE-PanelPlugin | |
Encoding=UTF-8 | |
Name=Display Switcher | |
Comment=Switch between display profiles from the panel | |
Icon=xfce-display-external | |
X-XFCE-Unique=true | |
X-XFCE-Exec=/path/to/xfce4_dswitch.py | |
X-XFCE-Internal=false | |
X-XFCE-API=2.0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="UTF-8"?> | |
<!-- Generated with glade 3.22.2 --> | |
<interface> | |
<requires lib="gtk+" version="3.20"/> | |
<object class="GtkWindow"> | |
<property name="can_focus">False</property> | |
<child type="titlebar"> | |
<placeholder/> | |
</child> | |
<child> | |
<object class="GtkOverlay" id="container"> | |
<property name="height_request">36</property> | |
<property name="visible">True</property> | |
<property name="can_focus">False</property> | |
<property name="events">GDK_BUTTON_PRESS_MASK | GDK_STRUCTURE_MASK</property> | |
<property name="hexpand">True</property> | |
<child> | |
<placeholder/> | |
</child> | |
<child type="overlay"> | |
<object class="GtkButton" id="active-profile"> | |
<property name="visible">True</property> | |
<property name="can_focus">True</property> | |
<property name="receives_default">True</property> | |
<property name="events">GDK_BUTTON_PRESS_MASK | GDK_STRUCTURE_MASK</property> | |
<property name="hexpand">True</property> | |
<property name="vexpand">True</property> | |
<property name="border_width">0</property> | |
<property name="relief">none</property> | |
<signal name="button-press-event" handler="on_panel_button_pressed" swapped="no"/> | |
<child> | |
<placeholder/> | |
</child> | |
</object> | |
<packing> | |
<property name="index">1</property> | |
</packing> | |
</child> | |
</object> | |
</child> | |
</object> | |
</interface> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/path/to/your/virtualenv/bin/python | |
import hashlib | |
import inspect | |
import logging | |
import os | |
import subprocess | |
import time | |
from argparse import ArgumentParser | |
from dataclasses import dataclass, field, make_dataclass | |
from pathlib import Path | |
from typing import Any, Dict, List, Optional, Tuple, Union | |
import dbus | |
import dbus.mainloop.glib | |
import Xlib.display | |
from Xlib.ext import randr | |
import gi | |
gi.require_version('Gtk', '3.0') | |
from gi.repository import Gtk, Gdk, Gio, GLib, GObject | |
logging.basicConfig(level=os.getenv('DSWITCH_LOG_LEVEL', 'INFO').upper()) | |
logger = logging.getLogger(__name__) | |
DEBUG = bool(os.getenv('DSWITCH_REMOTE_DEBUG')) | |
if DEBUG: | |
try: | |
import pydevd_pycharm | |
except ImportError: | |
logger.error('Unable to import pydevd_pycharm to start remote debugging') | |
else: | |
port = int(os.getenv('DSWITCH_REMOTE_DEBUG_PORT', 57023)) | |
try: | |
pydevd_pycharm.settrace('localhost', port=port, stdoutToServer=True, stderrToServer=True, suspend=False) | |
except ConnectionRefusedError: | |
logger.error('Unable to connect to remote debugging server, port %s', port) | |
SCRIPT_PATH = Path(__file__) | |
SCRIPT_DIR = SCRIPT_PATH.parent.absolute() | |
GLADE_PATH = SCRIPT_DIR / 'xfce4-dswitch.glade' | |
CSS_PATH = SCRIPT_DIR / 'xfce4-dswitch.css' | |
class Xfce4DSwitchPlug: | |
plugin_id: int | |
socket_id: int | |
profiles: 'DisplayProfileRegistry' | |
def __init__(self, plugin_id: int, socket_id: int): | |
self.plugin_id = plugin_id | |
self.socket_id = socket_id | |
self.profiles = DisplayProfileRegistry() | |
self.plug: Gtk.Plug = Gtk.Plug.new(self.socket_id) | |
self.plug.connect('destroy', self.on_destroy) | |
self.bus = dbus.SessionBus() | |
self.xfconf = dbus.Interface( | |
object=self.bus.get_object('org.xfce.Xfconf', '/org/xfce/Xfconf'), | |
dbus_interface='org.xfce.Xfconf', | |
) | |
self.xfconf.connect_to_signal('PropertyChanged', self.on_xfconf_property_changed) | |
self.double_click_timer = None | |
self.was_double_clicked = None | |
self.builder = None | |
self.active_profile: Optional[Gtk.Button] = None | |
self.menu: Optional[Gtk.Menu] = None | |
self.build_ui() | |
self.css_provider = None | |
self.init_styles() | |
self.plug.show_all() | |
self.reload_display_profiles() | |
self.refresh_ui() | |
self.monitors = [] | |
self.monitor_ui_source_changes() | |
def monitor_ui_source_changes(self): | |
watches = [ | |
(GLADE_PATH, self.rebuild_ui), | |
(CSS_PATH, self.reload_styles), | |
] | |
for path, callback in watches: | |
gio_file = Gio.File.new_for_path(str(path)) | |
monitor = gio_file.monitor_file(Gio.FileMonitorFlags.NONE, None) | |
monitor.connect('changed', self.file_changed_handler(callback)) | |
self.monitors.append(monitor) | |
def file_changed_handler(self, callback): | |
def on_file_changed(monitor, gfile, o, event): | |
if event == Gio.FileMonitorEvent.CHANGES_DONE_HINT: | |
callback() | |
return on_file_changed | |
def build_ui(self): | |
self.builder = Gtk.Builder() | |
self.builder.add_objects_from_file(str(GLADE_PATH), ('container',)) | |
# XXX: for some reason, connect_signals(self) is not working | |
self.builder.connect_signals({ | |
name: method | |
for name, method in inspect.getmembers(self, inspect.ismethod) | |
if name.startswith('on_') | |
}) | |
toplevel_objects = [ | |
'container', | |
] | |
for object_id in toplevel_objects: | |
widget: Gtk.Widget = self.builder.get_object(object_id) | |
self.plug.add(widget) | |
self.store = self.builder.get_object('profiles') | |
self.active_profile = self.builder.get_object('active-profile') | |
def rebuild_ui(self): | |
logger.debug('Rebuilding UI ...') | |
for widget in self.plug.get_children(): | |
widget.destroy() | |
self.build_ui() | |
self.refresh_ui() | |
self.plug.show_all() | |
def init_styles(self): | |
self.css_provider = Gtk.CssProvider() | |
style_context = Gtk.StyleContext() | |
screen = Gdk.Screen.get_default() | |
style_context.add_provider_for_screen(screen, self.css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) | |
self.load_styles() | |
def load_styles(self): | |
with CSS_PATH.open('rb') as fp: | |
self.css_provider.load_from_data(fp.read()) | |
def reload_styles(self): | |
logger.debug('Reloading styles ...') | |
self.load_styles() | |
def reload_display_profiles(self): | |
self.profiles = get_display_profiles(xfconf=self.xfconf) | |
def refresh_ui(self): | |
if self.menu: | |
self.menu.destroy() | |
self.menu = Gtk.Menu.new() | |
self.menu.attach_to_widget(self.active_profile) | |
self.menu.connect('show', self.on_menu_shown) | |
self.menu.connect('hide', self.on_menu_hidden) | |
for profile in self.profiles.values(): | |
if not profile.is_valid: | |
continue | |
menu_item = Gtk.CheckMenuItem.new_with_label(profile.name) | |
menu_item.set_active(profile.is_active) | |
menu_item.connect('toggled', self.on_menu_item_selected, profile) | |
self.menu.append(menu_item) | |
menu_item.show() | |
if profile.is_active: | |
self.active_profile.set_label(profile.name) | |
def on_menu_item_selected(self, menu_item: Gtk.CheckMenuItem, profile: 'DisplayProfile'): | |
is_active = self.activate_profile(profile) | |
menu_item.set_active(is_active) | |
def activate_profile(self, profile: Union[str, 'DisplayProfile']): | |
if isinstance(profile, str): | |
profile_id = profile | |
assert profile_id in self.profiles, \ | |
f'Profile id={profile_id} not found in profiles registry!' | |
profile = self.profiles[profile_id] | |
if not profile.is_active: | |
profile.apply() | |
return True | |
def on_panel_button_pressed(self, active_profile, event: Gdk.EventButton): | |
"""Perform some custom mouse button handling, as the defaults aren't quite right | |
The popover menu from the combobox steals all mouse events when open; this allows | |
it to hide the menu if the user clicks anywhere outside it. | |
Unfortunately, though, this means if the user double-clicks on the Active Profile | |
button, only the first button press event ever reaches the button — the second | |
event is eaten by the combobox popover menu. | |
To mitigate this, we don't open the popover menu immediately — instead, we start | |
a short timer on the first button press; if a second button press is encountered | |
before that timer fires, we count it as a double-click, and don't open the | |
combobox popover menu. | |
The stream of events for a single-click ending in popover open looks like: | |
T button-pressed(button=PRIMARY, type=BUTTON_PRESS) | |
↪ start double_click_timer | |
T+250 double_click_timer fired | |
↪ combobox.popup() | |
The stream of events for a double-click ending in xfce4-display-settings: | |
T button-pressed(button=PRIMARY, type=BUTTON_PRESS) | |
↪ start double_click_timer | |
T+130 button-pressed(button=PRIMARY, type=BUTTON_PRESS) | |
button-pressed(button=PRIMARY, type=DOUBLE_BUTTON_PRESS) | |
↪ cancel double_click_timer | |
↪ start xfce4-display-settings | |
In the case of double-click, we receive the first regular BUTTON_PRESS, | |
then the BUTTON_PRESS for the second click, and finally, the | |
DOUBLE_BUTTON_PRESS as a separate event. | |
""" | |
if event.button == Gdk.BUTTON_PRIMARY: | |
if event.type == Gdk.EventType.BUTTON_PRESS: | |
if DEBUG and event.state & Gdk.ModifierType.CONTROL_MASK: | |
logger.debug('Ctrl-clicked — breakpoint may be set here') | |
if hasattr(self, 'on_ctrl_clicked') and callable(self.on_ctrl_clicked): | |
self.on_ctrl_clicked() | |
elif self.double_click_timer is None: | |
self.double_click_timer = GLib.timeout_add( | |
interval=150, # milliseconds | |
function=self.on_panel_button_clicked_after_double_click_period, | |
priority=GLib.PRIORITY_HIGH, | |
) | |
elif event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS: | |
if self.double_click_timer is not None: | |
GLib.source_remove(self.double_click_timer) | |
self.double_click_timer = None | |
subprocess.Popen('xfce4-display-settings', preexec_fn=os.setsid) | |
def on_panel_button_clicked_after_double_click_period(self): | |
self.menu.popup(None, None, self.position_menu, None, Gdk.BUTTON_PRIMARY, int(time.time())) | |
self.double_click_timer = None | |
# False indicates this timeout should not be repeated | |
return False | |
def position_menu(self, menu: Gtk.Menu, x: int, y: int, *args) -> Tuple[int, int, bool]: | |
_, x, y = self.active_profile.get_window().get_origin() | |
width = menu.get_allocated_width() | |
x -= width | |
return x, y, True | |
def on_menu_shown(self, menu: Gtk.Menu, *args): | |
context = self.active_profile.get_style_context() | |
context.add_class('active') | |
def on_menu_hidden(self, menu: Gtk.Menu, *args): | |
context = self.active_profile.get_style_context() | |
context.remove_class('active') | |
def on_query_tooltip(self, *args): | |
active_profile = self.profiles.get_active_profile() | |
if active_profile: | |
return active_profile.name | |
def on_xfconf_property_changed(self, channel: str, prop: str, value: Any): | |
if channel == 'displays': | |
self.on_displays_property_changed(prop, value) | |
def on_displays_property_changed(self, prop: str, value: Any): | |
# TODO: limit refreshes based on relevant properties being changed | |
self.reload_display_profiles() | |
self.refresh_ui() | |
def on_destroy(self, widget, data=None): | |
Gtk.main_quit() | |
def is_compositing_enabled(xfconf: dbus.Interface) -> bool: | |
return xfconf.GetProperty('xfwm4', '/general/use_compositing') | |
def set_compositing_enabled(xfconf: dbus.Interface, enabled: bool): | |
return xfconf.SetProperty('xfwm4', '/general/use_compositing', enabled) | |
@dataclass | |
class MonitorPosition: | |
X: int = None | |
Y: int = None | |
@dataclass | |
class Monitor: | |
id: str | |
name: str | |
Active: bool = None | |
EDID: str = None | |
Position: MonitorPosition = field(default_factory=MonitorPosition) | |
Primary: bool = None | |
Reflection: str = None | |
RefreshRate: float = None | |
Resolution: str = None | |
Rotation: int = None | |
@dataclass | |
class DisplayProfile: | |
id: str | |
name: str | |
xfconf: dbus.Interface | |
is_active: bool = False | |
is_valid: bool = None | |
monitors: Dict[str, Monitor] = field(default_factory=dict) | |
def apply(self): | |
"""Apply the configuration in this display profile | |
""" | |
# I think what xfce4-display-settings may do is temporarily create a profile | |
# called Fallback, then show a dialog asking the user to confirm their change. | |
# If they do not within X seconds, it applies this Fallback profile. | |
# | |
# However, I'm unclear as to whether this profile is an Xfconf profile, | |
# or some notion of an xfce_randr profile... | |
# | |
was_compositing_enabled = is_compositing_enabled(self.xfconf) | |
# TODO: 0. Check GetProperty('displays', '/Schemes/Apply') has value?! | |
self.xfconf.SetProperty('displays', '/Schemes/Apply', self.id) | |
self.xfconf.SetProperty('displays', '/ActiveProfile', self.id) | |
if was_compositing_enabled: | |
# The desktop seems to become slow and less responsive after changing | |
# display profiles. But if compositing is toggled off and on again, | |
# things work just fine. So that's what we do here. | |
set_compositing_enabled(self.xfconf, False) | |
set_compositing_enabled(self.xfconf, True) | |
class DisplayProfileRegistry(Dict[str, DisplayProfile]): | |
def get_by_name(self, name: str) -> Optional[DisplayProfile]: | |
matching_profiles = self.get_all_by_name(name) | |
if len(matching_profiles) == 1: | |
return matching_profiles[0] | |
elif len(matching_profiles) > 1: | |
raise ValueError(f'Found {len(matching_profiles)} profiles with the name {name!r}!') | |
def get_all_by_name(self, name: str) -> List[DisplayProfile]: | |
return [ | |
profile | |
for profile in self.values() | |
if profile.name == name | |
] | |
def get_active_profile(self) -> Optional[DisplayProfile]: | |
return next((profile for profile in self.values() if profile.is_active), None) | |
XFCONF_DISPLAYS_IGNORED_KEYS = { | |
'Notify', | |
'Default', | |
'Schemes', | |
'AutoEnableProfiles', | |
'IdentifyPopups', | |
} | |
def get_display_profiles(xfconf: dbus.Interface) -> DisplayProfileRegistry: | |
# | |
# xfconf will return ALL properties, recursively. Apparently, it does | |
# not support only retrieving immediate children. | |
# | |
# Ref: https://github.com/xfce-mirror/xfce4-settings/blob/6113e0a8602a21219d4a2987c8d8705716af88e7/common/display-profiles.c#L101-L103 | |
# | |
all_properties = xfconf.GetAllProperties('displays', '/') | |
### | |
# Here, we split the keys by their delimiters, so we can iterate through | |
# the top-level keys first. | |
# | |
# These keys look like: | |
# /5d5d69a7080e402870791259659e737363b6e14c/DP-5/Active | |
# /Default/DP-5/EDID | |
# /56ab49a5c68f11cc45c651da6a3f339eff2853f8/DP-3 | |
# | |
props_by_path = [ | |
(prop.lstrip('/').split('/'), value) | |
for prop, value in all_properties.items() | |
] | |
props_by_path.sort() | |
active_profile_id = None | |
profiles = {} | |
for path, value in props_by_path: | |
if len(path) == 1: | |
key, = path | |
if key == 'ActiveProfile': | |
active_profile_id = value | |
continue | |
# These are unrelated settings, and not display profiles | |
# Ref: https://github.com/xfce-mirror/xfce4-settings/blob/6113e0a8602a21219d4a2987c8d8705716af88e7/common/display-profiles.c#L150-L154 | |
if key in XFCONF_DISPLAYS_IGNORED_KEYS or value in XFCONF_DISPLAYS_IGNORED_KEYS: | |
continue | |
profile_id = key | |
profile_name = value | |
profiles[profile_id] = DisplayProfile( | |
id=profile_id, | |
name=profile_name, | |
xfconf=xfconf, | |
) | |
elif len(path) == 2: | |
profile_id, monitor_id = path | |
if profile_id not in profiles: | |
continue | |
monitor_name = value | |
profile = profiles[profile_id] | |
profile.monitors[monitor_id] = Monitor(id=monitor_id, name=monitor_name) | |
elif len(path) >= 3: | |
profile_id, monitor_id, *attr_path = path | |
if profile_id not in profiles: | |
continue | |
profile = profiles[profile_id] | |
if monitor_id not in profile.monitors: | |
continue | |
monitor = profile.monitors[monitor_id] | |
root = monitor | |
for key in attr_path[:-1]: | |
if not hasattr(root, key): | |
setattr(root, key, make_dataclass(key, [])) | |
root = getattr(root, key) | |
key = attr_path[-1] | |
setattr(root, key, value) | |
if active_profile_id in profiles: | |
profiles[active_profile_id].is_active = True | |
### | |
# Filter out any profiles without monitors — they're undoubtedly unrelated xfconf settings, | |
# and not actual display profiles. | |
# | |
profiles: Dict[str, DisplayProfile] = { | |
profile_id: profile | |
for profile_id, profile in profiles.items() | |
if profile.monitors | |
} | |
### | |
# Now, fill in is_valid details, using EDID checksums of active monitors. | |
# is_valid is True iff the EDID checksums in a display profile match ALL | |
# the active monitors' EDID checksums. | |
# | |
active_edid_checksums = set(get_active_edid_checksums().values()) | |
for profile in profiles.values(): | |
monitors = profile.monitors.values() | |
profile_edid_checksums = {monitor.EDID for monitor in monitors} | |
profile.is_valid = ( | |
(profile_edid_checksums == active_edid_checksums) | |
and len(profile.monitors) == len(active_edid_checksums) | |
) | |
return DisplayProfileRegistry(profiles) | |
def get_active_edids() -> Dict[str, bytes]: | |
"""Return all the currently-configured display EDIDs | |
Ref: https://gist.github.com/courtarro/3adec649c086eea1bb18919d6269d544#file-get_displays-py-L143-L166 | |
""" | |
display = Xlib.display.Display() | |
root = display.screen().root | |
resources = root.xrandr_get_screen_resources()._data | |
edids = {} | |
for output in resources['outputs']: | |
info = display.xrandr_get_output_info(output, resources['config_timestamp'])._data | |
name = info['name'] | |
props = display.xrandr_list_output_properties(output)._data | |
for atom in props['atoms']: | |
atom_name = display.get_atom_name(atom) | |
if atom_name == randr.PROPERTY_RANDR_EDID: | |
raw_edid = display.xrandr_get_output_property(output, atom, 0, 0, 1000)._data['value'] | |
edids[name] = bytes(raw_edid) | |
break | |
return edids | |
def get_active_edid_checksums() -> Dict[str, str]: | |
"""Return the SHA-1 hash of each active EDID | |
This SHA-1 checksum is used by xfce4-display-settings to identify monitors. | |
Ref: https://github.com/xfce-mirror/xfce4-settings/blob/6113e0a8602a21219d4a2987c8d8705716af88e7/common/xfce-randr.c#L52-L53 | |
""" | |
return { | |
# | |
# EDID versions 1.0 (1994) to 1.4 (2006) used 128-byte structures. | |
# Later versions supported larger structures, but xfce4-display-settings | |
# seems to only use the 128-byte structure for its checksums. | |
# | |
# Ref: https://en.wikipedia.org/wiki/Extended_Display_Identification_Data#Background | |
# | |
name: hashlib.sha1(edid[:128]).hexdigest() | |
for name, edid in get_active_edids().items() | |
} | |
def main(): | |
parser = ArgumentParser() | |
parser.add_argument('idk') # there's an empty string as the first arg | |
parser.add_argument('plugin_id', type=int) | |
parser.add_argument('socket_id', type=int) | |
parser.add_argument('plugin_module') | |
parser.add_argument('plugin_name') | |
parser.add_argument('plugin_description') | |
parser.add_argument('idk_suffix') # and another empty string at the tail | |
opts = parser.parse_args() | |
# Tell dbus to use the GObject main loop | |
# (a main loop is necessary, because we have things listening on dbus) | |
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) | |
dswitch = Xfce4DSwitchPlug(plugin_id=opts.plugin_id, socket_id=opts.socket_id) | |
# Run the GObject main loop | |
Gtk.main() | |
if __name__ == '__main__': | |
main() |
Heh, yeah, this gist is definitely more in "reference format" than anything. It appears as though you'd need to do something like:
- Unpack this gist somewhere, either by git cloning it, or downloading the .zip
- Open a terminal inside this folder
- Choose to either create a virtualenv, or use your system Python. Note the full path to the python executable, with
which python
- Install the requirements, using
pip install -r requirements.txt
(and running as root, i.e.sudo pip install -r requirements.txt
if using system Python) - Change the hashbang line (the first line) in
xfce4_dswitch.py
from#!/path/to/your/virtualenv/bin/python
to#!/path/to/your/python
(from step 2) - Edit the
X-XFCE-Exec
line in xfce4-dswitch.desktop with the path toxfce_dswitch.py
(wherever you unpacked it) - Finally, we register our plugin with xfce by placing the desktop file in the right location:
sudo ln -s "$PWD/xfce-dswitch.desktop" /usr/share/xfce4/panel/plugins
Now, when you restart the xfce4 panel (using either the xfce4-panel -r
command, or logging out&in, or restarting), the plugin should show up in the Add New items list.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
How can I install your plugin ?