Skip to content

Instantly share code, notes, and snippets.

@theY4Kman
Last active February 20, 2024 23:15
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save theY4Kman/35acc5815740eb9df2809a1dbc8c8625 to your computer and use it in GitHub Desktop.
Save theY4Kman/35acc5815740eb9df2809a1dbc8c8625 to your computer and use it in GitHub Desktop.
Xfce4 panel plugin enabling easy switching between display profiles
pygobject
dbus-python
python-xlib
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)
}
# 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
<?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>
#!/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()
@ericktucto
Copy link

How can I install your plugin ?

@theY4Kman
Copy link
Author

Heh, yeah, this gist is definitely more in "reference format" than anything. It appears as though you'd need to do something like:

  1. Unpack this gist somewhere, either by git cloning it, or downloading the .zip
  2. Open a terminal inside this folder
  3. Choose to either create a virtualenv, or use your system Python. Note the full path to the python executable, with which python
  4. 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)
  5. 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)
  6. Edit the X-XFCE-Exec line in xfce4-dswitch.desktop with the path to xfce_dswitch.py (wherever you unpacked it)
  7. 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.

@ericktucto
Copy link

thank you, I executed script and the plugin was installed, I don't know where is installed your plugin on my desktop
Look my /usr/share/xfce4/panel/plugins:
imagen
I'll soon re-install my desktop enviroment and I'll follow your steps

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment