#!/usr/bin/env python3 import argparse import sys from dataclasses import dataclass, field from typing import NamedTuple, Any, Optional from types import ModuleType from gi.repository import GLib, Gio # type: ignore from enum import Enum, Flag argcomplete: Optional[ModuleType] = None BaseCompleter: Any try: import argcomplete from argcomplete.completers import BaseCompleter except ModuleNotFoundError: BaseCompleter = object NAME = "org.gnome.Mutter.DisplayConfig" INTERFACE = "org.gnome.Mutter.DisplayConfig" OBJECT_PATH = "/org/gnome/Mutter/DisplayConfig" class Dimension(NamedTuple): width: int height: int def __str__(self): return f"{self.width}x{self.height}" class Position(NamedTuple): x: int | None y: int | None def __str__(self): return f"({self.x}, {self.y})" class NamedEnum(Enum): def __str__(self): return next( string for enum, string in type(self).enum_names() if enum == self ) @classmethod def from_string(cls, string): return next( enum for enum, enum_string in cls.enum_names() if string == enum_string ) @classmethod def maybe_from_string(cls, string): if string: return next( enum for enum, enum_string in cls.enum_names() if string == enum_string ) else: return None class Transform(NamedEnum): NORMAL = 0 ROTATE_90 = 1 ROTATE_180 = 2 ROTATE_270 = 3 FLIPPED = 4 ROTATE_90_FLIPPED = 5 ROTATE_270_FLIPPED = 6 ROTATE_180_FLIPPED = 7 @classmethod def enum_names(cls): return [ (Transform.NORMAL, "normal"), (Transform.ROTATE_90, "90"), (Transform.ROTATE_180, "180"), (Transform.ROTATE_270, "270"), (Transform.FLIPPED, "flipped"), (Transform.ROTATE_90_FLIPPED, "flipped-90"), (Transform.ROTATE_180_FLIPPED, "flipped-180"), (Transform.ROTATE_270_FLIPPED, "flipped-270"), ] class LayoutMode(NamedEnum): LOGICAL = 1 PHYSICAL = 2 @classmethod def enum_names(cls): return [ (LayoutMode.LOGICAL, "logical"), (LayoutMode.PHYSICAL, "physical"), ] class ColorMode(NamedEnum): DEFAULT = 0 BT2100 = 1 @classmethod def enum_names(cls): return [ (ColorMode.DEFAULT, "default"), (ColorMode.BT2100, "bt2100"), ] class ConfigMethod(Enum): VERIFY = 0 TEMPORARY = 1 PERSISTENT = 2 def translate_property(name, value): enum_properties = { "layout-mode": LayoutMode, "color-mode": ColorMode, "supported-color-modes": ColorMode, } if name in enum_properties: if isinstance(value, list): return [enum_properties[name](element) for element in value] else: return enum_properties[name](value) else: return value def translate_properties(variant): return { key: translate_property(key, value) for key, value in variant.items() } def print_data(*, level: int, is_last: bool, lines: list[int], data: str): if is_last: link = "└" else: link = "├" padding = " " if level >= 0: indent = level buffer = list(f"{link:{padding}>{indent * 4}}──{data}") for line in lines: if line == level: continue index = line * 4 if line > 0: index -= 1 buffer[index] = "│" else: buffer = list(data) print("".join(buffer)) if is_last and level in lines: lines.remove(level) elif not is_last and level not in lines: lines.append(level) def print_properties(*, level, lines, properties): property_keys = list(properties.keys()) print_data( level=level, is_last=True, lines=lines, data=f"Properties: ({len(property_keys)})", ) for key in property_keys: is_last = key == property_keys[-1] value = properties[key] if isinstance(value, list): elements_string = ", ".join([str(element) for element in value]) value_string = f"[{elements_string}]" elif isinstance(value, bool): value_string = "yes" if value else "no" else: value_string = str(value) print_data( level=level + 1, is_last=is_last, lines=lines, data=f"{key} ⇒ {value_string}", ) def print_monitor_prefs( display_config, monitor, level: int, lines: list[int], is_last: bool ): print_data( level=level, is_last=is_last, lines=lines, data="Preferences:", ) print_data( level=level + 1, is_last=True, lines=lines, data="Luminances:", ) for color_mode in monitor.supported_color_modes: (output_luminance, is_unset) = display_config.get_luminance( monitor, color_mode ) is_last = color_mode == monitor.supported_color_modes[-1] is_default_string = " (default)" if is_unset else "" is_current_string = ( " (current)" if monitor.color_mode == color_mode else "" ) print_data( level=level + 2, is_last=is_last, lines=lines, data=f"{color_mode} ⇒ {output_luminance}{is_default_string}{is_current_string}", ) def strip_dbus_error_prefix(message): if message.startswith("GDBus.Error"): return message.partition(" ")[2] else: return message def transform_size(size: Dimension, transform) -> Dimension: match transform: case ( Transform.NORMAL | Transform.ROTATE_180 | Transform.FLIPPED | Transform.ROTATE_180_FLIPPED ): return size case ( Transform.ROTATE_90 | Transform.ROTATE_270 | Transform.ROTATE_90_FLIPPED | Transform.ROTATE_270_FLIPPED ): width, height = size return Dimension(height, width) case _: raise NotImplementedError def scale_size(size: Dimension, scale) -> Dimension: width, height = size return Dimension(round(width / scale), round(height / scale)) class DisplayConfig: STATE_VARIANT_TYPE = GLib.VariantType.new( "(ua((ssss)a(siiddada{sv})a{sv})a(iiduba(ssss)a{sv})a{sv})" ) def __init__(self): self._proxy = Gio.DBusProxy.new_for_bus_sync( bus_type=Gio.BusType.SESSION, flags=Gio.DBusProxyFlags.NONE, info=None, name=NAME, object_path=OBJECT_PATH, interface_name=INTERFACE, cancellable=None, ) def get_current_state(self) -> GLib.Variant: variant = self._proxy.call_sync( method_name="GetCurrentState", parameters=None, flags=Gio.DBusCallFlags.NO_AUTO_START, timeout_msec=-1, cancellable=None, ) assert variant.get_type().equal(self.STATE_VARIANT_TYPE) return variant def apply_monitors_config(self, config, config_method): serial = config.monitors_state.server_serial logical_monitors = config.generate_logical_monitor_tuples() monitors_for_lease = config.generate_monitors_for_lease_tuples() properties = {} if monitors_state.supports_changing_layout_mode: properties["layout-mode"] = GLib.Variant( "u", config.layout_mode.value ) if monitors_for_lease: properties["monitors-for-lease"] = GLib.Variant( "a(ssss)", monitors_for_lease ) parameters = GLib.Variant( "(uua(iiduba(ssa{sv}))a{sv})", ( serial, config_method.value, logical_monitors, properties, ), ) self._proxy.call_sync( method_name="ApplyMonitorsConfig", parameters=parameters, flags=Gio.DBusCallFlags.NO_AUTO_START, timeout_msec=-1, cancellable=None, ) def get_luminance(self, monitor, color_mode) -> tuple[float, bool]: variant = self._proxy.get_cached_property("Luminance") luminance_entry = next( entry for entry in variant if entry["connector"] == monitor.connector and ColorMode(entry["color-mode"]) == color_mode ) output_luminance = luminance_entry["luminance"] is_unset = luminance_entry["is-unset"] return (output_luminance, is_unset) def set_luminance(self, monitor, color_mode, luminance): parameters = GLib.Variant( "(sud)", ( monitor.connector, color_mode.value, luminance, ), ) self._proxy.call_sync( method_name="SetLuminance", parameters=parameters, flags=Gio.DBusCallFlags.NO_AUTO_START, timeout_msec=-1, cancellable=None, ) def reset_luminance(self, monitor, color_mode): parameters = GLib.Variant( "(su)", (monitor.connector, color_mode.value), ) self._proxy.call_sync( method_name="ResetLuminance", parameters=parameters, flags=Gio.DBusCallFlags.NO_AUTO_START, timeout_msec=-1, cancellable=None, ) @dataclass class MonitorMode: name: str resolution: Dimension refresh_rate: float preferred_scale: float supported_scales: list[float] properties: dict @classmethod def from_variant(cls, variant): return cls( name=variant[0], resolution=Dimension(variant[1], variant[2]), refresh_rate=variant[3], preferred_scale=variant[4], supported_scales=variant[5], properties=translate_properties(variant[6]), ) @dataclass class Monitor: connector: str vendor: str product: str display_name: str serial: str modes: list[MonitorMode] properties: dict current_mode: MonitorMode | None preferred_mode: MonitorMode | None color_mode: ColorMode | None supported_color_modes: list[ColorMode] @classmethod def from_variant(cls, variant): spec = variant[0] connector = spec[0] vendor = spec[1] if spec[1] != "" else None product = spec[2] if spec[2] != "" else None serial = spec[3] if spec[3] != "" else None modes = [ MonitorMode.from_variant(mode_variant) for mode_variant in variant[1] ] properties = translate_properties(variant[2]) current_mode = next( (mode for mode in modes if "is-current" in mode.properties), None, ) preferred_mode = next( (mode for mode in modes if "is-preferred" in mode.properties), None, ) display_name = properties.get("display-name", None) color_mode = properties.get("color-mode", None) supported_color_modes = properties.get("supported-color-modes") return cls( connector=connector, vendor=vendor, product=product, serial=serial, modes=modes, properties=properties, current_mode=current_mode, preferred_mode=preferred_mode, display_name=display_name, color_mode=color_mode, supported_color_modes=supported_color_modes, ) @dataclass class LogicalMonitor: monitors: list[Monitor] scale: float position: Position = Position(0, 0) transform: Transform = Transform.NORMAL is_primary: bool = False properties: dict[str, Any] = field(default_factory=dict) args: dict[str, Any] = field(default_factory=dict) @classmethod def from_variant(cls, monitors_state, variant): position = (variant[0], variant[1]) scale = variant[2] transform = Transform(variant[3]) is_primary = variant[4] connectors = [connector for connector, _, _, _ in variant[5]] monitors = [ monitors_state.monitors[connector] for connector in connectors ] properties = translate_properties(variant[6]) return cls( monitors=monitors, position=position, scale=scale, transform=transform, is_primary=is_primary, properties=properties, ) def calculate_size(self, layout_mode): mode = next(monitor.mode for monitor in self.monitors) size = transform_size(mode.resolution, self.transform) match layout_mode: case LayoutMode.LOGICAL: return scale_size(size, self.scale) case LayoutMode.PHYSICAL: return size def calculate_right_edge(self, layout_mode): x, _ = self.position width, _ = self.calculate_size(layout_mode) return x + width def calculate_bottom_edge(self, layout_mode): _, y = self.position _, height = self.calculate_size(layout_mode) return y + height def find_closest_scale(mode, scale) -> float: @dataclass class Scale: scale: float distance: float best: Scale | None = None for supported_scale in mode.supported_scales: scale_distance = abs(scale - supported_scale) if scale_distance > 0.1: continue if not best or scale_distance < best.distance: best = Scale(supported_scale, scale_distance) if not best: raise ValueError(f"Scale {scale} not supported by mode") return best.scale def count_keys(dictionary, keys): in_both = set(keys) & set(dictionary) return len(in_both) def place_right_of( logical_monitor: LogicalMonitor, monitor_mappings: dict, layout_mode: LayoutMode, connector: str, set_y_position: bool, ): connector_logical_monitor = monitor_mappings[connector] if not connector_logical_monitor.position: raise ValueError( f"Logical monitor position configured before {connector} " ) x = connector_logical_monitor.calculate_right_edge(layout_mode) if set_y_position: _, y = connector_logical_monitor.position else: y = None logical_monitor.position = Position(x, y) def place_left_of( logical_monitor: LogicalMonitor, monitor_mappings: dict, layout_mode: LayoutMode, connector: str, set_y_position: bool, ): connector_logical_monitor = monitor_mappings[connector] if not connector_logical_monitor.position: raise ValueError( f"Logical monitor position configured before {connector} " ) width, _ = logical_monitor.calculate_size(layout_mode) left_edge, _ = connector_logical_monitor.position x = left_edge - width if set_y_position: _, y = connector_logical_monitor.position else: y = None logical_monitor.position = Position(x, y) def place_below( logical_monitor: LogicalMonitor, monitor_mappings: dict, layout_mode: LayoutMode, connector: str, set_x_position: bool, ): connector_logical_monitor = monitor_mappings[connector] if not connector_logical_monitor.position: raise ValueError( f"Logical monitor position configured before {connector} " ) y = connector_logical_monitor.calculate_bottom_edge(layout_mode) if set_x_position: x, _ = connector_logical_monitor.position else: x = logical_monitor.position.x logical_monitor.position = Position(x, y) def place_above( logical_monitor: LogicalMonitor, monitor_mappings: dict, layout_mode: LayoutMode, connector: str, set_x_position: bool, ): connector_logical_monitor = monitor_mappings[connector] if not connector_logical_monitor.position: raise ValueError( f"Logical monitor position configured before {connector} " ) _, height = logical_monitor.calculate_size(layout_mode) _, top_edge = connector_logical_monitor.position y = top_edge - height if set_x_position: x, _ = connector_logical_monitor.position else: x = logical_monitor.position.x logical_monitor.position = Position(x, y) class PositionType(Flag): NONE = 0 ABSOLUTE_X = 1 << 0 RELATIVE_X = 1 << 1 ABSOLUTE_Y = 1 << 2 RELATIVE_Y = 1 << 3 def calculate_position( logical_monitor: LogicalMonitor, layout_mode: LayoutMode, monitor_mappings: dict, ): horizontal_args = count_keys( logical_monitor.args, ["right_of", "left_of", "x"] ) vertical_args = count_keys(logical_monitor.args, ["above", "below", "y"]) if horizontal_args > 1: raise ValueError("Multiple horizontal placement instructions used") if vertical_args > 1: raise ValueError("Multiple vertical placement instructions used") position_types = PositionType.NONE set_y_position = vertical_args == 0 x = None y = None if "x" in logical_monitor.args: x = int(logical_monitor.args["x"]) y = 0 if set_y_position else None logical_monitor.position = Position(x, y) position_types |= PositionType.ABSOLUTE_X elif "right_of" in logical_monitor.args: connector = logical_monitor.args["right_of"] if connector not in monitor_mappings: raise ValueError( f"Invalid connector {connector} passed to --right-of" ) place_right_of( logical_monitor, monitor_mappings, layout_mode, connector, set_y_position, ) position_types |= PositionType.RELATIVE_X elif "left_of" in logical_monitor.args: connector = logical_monitor.args["left_of"] if connector not in monitor_mappings: raise ValueError( f"Invalid connector {connector} passed to --left-of" ) place_left_of( logical_monitor, monitor_mappings, layout_mode, connector, set_y_position, ) position_types |= PositionType.RELATIVE_X else: logical_monitor.position = Position(0, 0) set_x_position = horizontal_args == 0 if "y" in logical_monitor.args: y = int(logical_monitor.args["y"]) x = 0 if set_x_position else logical_monitor.position.x logical_monitor.position = Position(x, y) position_types |= PositionType.ABSOLUTE_Y elif "below" in logical_monitor.args: connector = logical_monitor.args["below"] if connector not in monitor_mappings: raise ValueError(f"Invalid connector {connector} passed to --below") place_below( logical_monitor, monitor_mappings, layout_mode, connector, set_x_position, ) position_types |= PositionType.RELATIVE_Y elif "above" in logical_monitor.args: connector = logical_monitor.args["above"] if connector not in monitor_mappings: raise ValueError(f"Invalid connector {connector} passed to --above") place_above( logical_monitor, monitor_mappings, layout_mode, connector, set_x_position, ) position_types |= PositionType.RELATIVE_Y else: x, y = logical_monitor.position if not y: y = 0 logical_monitor.position = Position(x, y) assert logical_monitor.position.x is not None assert logical_monitor.position.y is not None return position_types def align_horizontally(logical_monitors: list[LogicalMonitor]): min_x = min( logical_monitor.position.x for logical_monitor in logical_monitors if logical_monitor.position.x is not None ) dx = min_x if dx == 0: return for logical_monitor in logical_monitors: x, y = logical_monitor.position logical_monitor.position = Position( x - dx if x is not None else None, y ) def align_vertically(logical_monitors: list[LogicalMonitor]): min_y = min( logical_monitor.position.y for logical_monitor in logical_monitors if logical_monitor.position.y is not None ) dy = min_y if dy == 0: return for logical_monitor in logical_monitors: x, y = logical_monitor.position logical_monitor.position = Position( x, y - dy if y is not None else None ) def calculate_positions( logical_monitors: list[LogicalMonitor], layout_mode: LayoutMode, monitor_mappings: dict, ): position_types = PositionType.NONE for logical_monitor in logical_monitors: position_types |= calculate_position( logical_monitor, layout_mode, monitor_mappings ) if not position_types & PositionType.ABSOLUTE_X: align_horizontally(logical_monitors) if not position_types & PositionType.ABSOLUTE_Y: align_vertically(logical_monitors) def create_logical_monitor(monitors_state, layout_mode, logical_monitor_args): if "monitors" not in logical_monitor_args: raise ValueError("Logical monitor empty") monitors_arg = logical_monitor_args["monitors"] scale = logical_monitor_args.get("scale", None) is_primary = logical_monitor_args.get("primary", False) transform = Transform.from_string( logical_monitor_args.get("transform", "normal") ) monitors = [] common_mode_resolution = None for monitor_args in monitors_arg: (connector,) = monitor_args["key"] if connector not in monitors_state.monitors: raise ValueError(f"Monitor {connector} not found") monitor = monitors_state.monitors[connector] mode_name = monitor_args.get("mode", None) if mode_name: mode = next( (mode for mode in monitor.modes if mode.name == mode_name), None ) if not mode: raise ValueError( f"No mode {mode_name} available for {connector}" ) else: mode = monitor.preferred_mode if not common_mode_resolution: common_mode_resolution = mode.resolution if not scale: scale = mode.preferred_scale else: scale = find_closest_scale(mode, scale) else: mode_width, mode_height = mode.resolution common_mode_width, common_mode_height = common_mode_resolution if ( mode_width != common_mode_width or mode_height != common_mode_height ): raise ValueError( "Different monitor resolutions within the same logical monitor" ) monitor.mode = mode monitor.color_mode = ColorMode.maybe_from_string( monitor_args.get("color_mode", None) ) monitors.append(monitor) return LogicalMonitor( monitors=monitors, scale=scale, is_primary=is_primary, transform=transform, position=None, args=logical_monitor_args, ) def generate_configuration(monitors_state, args): layout_mode_str = args.layout_mode if not layout_mode_str: layout_mode = monitors_state.layout_mode else: if not monitors_state.supports_changing_layout_mode: raise ValueError( "Configuring layout mode not supported by the server" ) layout_mode = LayoutMode.from_string(layout_mode_str) logical_monitors = [] monitor_mappings = {} for logical_monitor_args in args.logical_monitors: logical_monitor = create_logical_monitor( monitors_state, layout_mode, logical_monitor_args ) logical_monitors.append(logical_monitor) for monitor in logical_monitor.monitors: monitor_mappings[monitor.connector] = logical_monitor monitors_for_lease = [] for connector in args.monitors_for_lease: monitors_for_lease.append(monitors_state.monitors[connector]) calculate_positions(logical_monitors, layout_mode, monitor_mappings) return Config( monitors_state, logical_monitors, layout_mode, monitors_for_lease ) def derive_config_method(args): if args.persistent and args.verify: raise ValueError( "Configuration can't be both persistent and verify-only" ) if args.persistent: return ConfigMethod.PERSISTENT elif args.verify: return ConfigMethod.VERIFY else: return ConfigMethod.TEMPORARY def print_config(config): print("Configuration:") lines = [] print_data( level=0, is_last=False, lines=lines, data=f"Layout mode: {config.layout_mode}", ) print_data( level=0, is_last=False, lines=lines, data=f"Logical monitors ({len(config.logical_monitors)})", ) index = 1 for logical_monitor in config.logical_monitors: is_last = logical_monitor == config.logical_monitors[-1] print_data( level=1, is_last=is_last, lines=lines, data=f"Logical monitor #{index}", ) print_data( level=2, is_last=False, lines=lines, data=f"Position: {logical_monitor.position}", ) print_data( level=2, is_last=False, lines=lines, data=f"Scale: {logical_monitor.scale}", ) print_data( level=2, is_last=False, lines=lines, data=f"Transform: {logical_monitor.transform}", ) print_data( level=2, is_last=False, lines=lines, data=f"Primary: {'yes' if logical_monitor.is_primary else 'no'}", ) print_data( level=2, is_last=True, lines=lines, data=f"Monitors: ({len(logical_monitor.monitors)})", ) for monitor in logical_monitor.monitors: is_last = monitor == logical_monitor.monitors[-1] print_data( level=3, is_last=is_last, lines=lines, data=f"Monitor {monitor.connector} ({monitor.display_name})", ) print_data( level=4, is_last=not monitor.color_mode, lines=lines, data=f"Mode: {monitor.mode.name}", ) if monitor.color_mode: print_data( level=4, is_last=True, lines=lines, data=f"Color mode: {monitor.color_mode}", ) index += 1 print_data( level=0, is_last=True, lines=lines, data=f"Monitors for lease ({len(config.monitors_for_lease)})", ) for monitor in config.monitors_for_lease: is_last = monitor == config.monitors_for_lease[-1] print_data( level=1, is_last=is_last, lines=lines, data=f"Monitor {monitor.connector} ({monitor.display_name})", ) class MonitorsState: def __init__(self, display_config): current_state = display_config.get_current_state() self.display_config = display_config self.server_serial = current_state[0] self.properties = translate_properties(current_state[3]) self.supports_changing_layout_mode = self.properties.get( "supports-changing-layout-mode", False ) self.layout_mode = ( self.properties.get("layout-mode") or LayoutMode.LOGICAL ) self.init_monitors(current_state) self.init_logical_monitors(current_state) def init_monitors(self, current_state): self.monitors = {} for monitor_variant in current_state[1]: monitor = Monitor.from_variant(monitor_variant) self.monitors[monitor.connector] = monitor def init_logical_monitors(self, current_state): self.logical_monitors = [] for variant in current_state[2]: logical_monitor = LogicalMonitor.from_variant(self, variant) self.logical_monitors.append(logical_monitor) def create_current_config(self): return Config.create_current(self) def print_mode(self, mode, is_last, show_properties, lines): print_data(level=2, is_last=is_last, lines=lines, data=f"{mode.name}") if not show_properties: return print_data( level=3, is_last=False, lines=lines, data=f"Dimension: {mode.resolution}", ) print_data( level=3, is_last=False, lines=lines, data=f"Refresh rate: {mode.refresh_rate:.3f}", ) print_data( level=3, is_last=False, lines=lines, data=f"Preferred scale: {mode.preferred_scale}", ) print_data( level=3, is_last=False, lines=lines, data=f"Supported scales: {mode.supported_scales}", ) if show_properties: mode_properties = mode.properties print_properties(level=3, lines=lines, properties=mode_properties) def print_current_state(self, show_modes=False, show_properties=False): print("Monitors:") lines = [] monitors = list(self.monitors.values()) for monitor in monitors: is_last = monitor == monitors[-1] modes = monitor.modes properties = monitor.properties if monitor.display_name: monitor_title = ( f"Monitor {monitor.connector} ({monitor.display_name})" ) else: monitor_title = f"Monitor {monitor.connector}" print_data( level=0, is_last=is_last, lines=lines, data=monitor_title, ) if monitor.vendor: print_data( level=1, is_last=False, lines=lines, data=f"Vendor: {monitor.vendor}", ) if monitor.product: print_data( level=1, is_last=False, lines=lines, data=f"Product: {monitor.product}", ) if monitor.serial: print_data( level=1, is_last=False, lines=lines, data=f"Serial: {monitor.serial}", ) if show_modes: print_data( level=1, is_last=not show_properties, lines=lines, data=f"Modes ({len(modes)})", ) for mode in modes: is_last = mode == modes[-1] self.print_mode(mode, is_last, show_properties, lines) else: mode = next( (mode for mode in modes if "is-current" in mode.properties), None, ) if mode: mode_type = "Current" else: mode = next( ( mode for mode in modes if "is-preferred" in mode.properties ), None, ) if mode: mode_type = "Preferred" if mode: print_data( level=1, is_last=False, lines=lines, data=f"{mode_type} mode", ) self.print_mode(mode, True, show_properties, lines) print_monitor_prefs( self.display_config, monitor, level=1, lines=lines, is_last=not show_properties, ) if show_properties: print_properties(level=1, lines=lines, properties=properties) print() print("Logical monitors:") index = 1 for logical_monitor in self.logical_monitors: is_last = logical_monitor == self.logical_monitors[-1] print_data( level=0, is_last=is_last, lines=lines, data=f"Logical monitor #{index}", ) (x, y) = logical_monitor.position print_data( level=1, is_last=False, lines=lines, data=f"Position: ({x}, {y})", ) print_data( level=1, is_last=False, lines=lines, data=f"Scale: {logical_monitor.scale}", ) print_data( level=1, is_last=False, lines=lines, data=f"Transform: {logical_monitor.transform}", ) print_data( level=1, is_last=False, lines=lines, data=f"Primary: {'yes' if logical_monitor.is_primary else 'no'}", ) monitors = logical_monitor.monitors print_data( level=1, is_last=not show_properties, lines=lines, data=f"Monitors: ({len(monitors)})", ) for monitor in monitors: is_last = monitor == monitors[-1] if monitor.display_name: monitor_title = ( f"{monitor.connector} ({monitor.display_name})" ) else: monitor_title = f"{monitor.connector}" print_data( level=2, is_last=is_last, lines=lines, data=monitor_title, ) if show_properties: properties = logical_monitor.properties print_properties(level=1, lines=lines, properties=properties) index += 1 if show_properties: properties = self.properties print() print_properties(level=-1, lines=lines, properties=properties) @dataclass class Config: monitors_state: MonitorsState logical_monitors: list[LogicalMonitor] layout_mode: LayoutMode monitors_for_lease: Monitor def generate_monitor_tuples(self, monitors): tuples = [] for monitor in monitors: options = {} if monitor.color_mode: options["color-mode"] = GLib.Variant( "u", monitor.color_mode.value ) # Variant type: (ssa{sv}) tuples.append( ( monitor.connector, monitor.mode.name, options, ) ) return tuples def generate_logical_monitor_tuples(self): tuples = [] for logical_monitor in self.logical_monitors: x, y = logical_monitor.position scale = logical_monitor.scale transform = logical_monitor.transform.value is_primary = logical_monitor.is_primary monitors = self.generate_monitor_tuples(logical_monitor.monitors) # Variant type: (iiduba(ssa{sv})) tuples.append( ( x, y, scale, transform, is_primary, monitors, ) ) return tuples def generate_monitors_for_lease_tuples(self): tuples = [] for monitor in self.monitors_for_lease: tuples.append( ( monitor.connector, monitor.vendor, monitor.product, monitor.serial, ) ) return tuples class GroupAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): if len(values) == 1: (value,) = values namespace._current_group = { "key": value, } else: namespace._current_group = {} groups = namespace.__dict__.setdefault(self.dest, []) groups.append(namespace._current_group) class SubGroupAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): if not hasattr(namespace, "_current_group"): raise argparse.ArgumentError( self, "No current group to add sub-group to" ) if self.dest not in namespace._current_group: namespace._current_group[self.dest] = [] sub_group = { "key": values, } namespace._current_group[self.dest].append(sub_group) namespace._current_sub_group = sub_group class AppendToGlobal(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): if getattr(namespace, "_current_group", None) is not None: raise argparse.ArgumentError(self, "Must pass during global scope") setattr(namespace, self.dest, self.const or values) class AppendToGroup(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): if getattr(namespace, "_current_group", None) is None: raise argparse.ArgumentError(self, "No current group to add to") namespace._current_group[self.dest] = self.const or values class AppendToSubGroup(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): if getattr(namespace, "_current_group", None) is None: raise argparse.ArgumentError(self, "No current group") if getattr(namespace, "_current_sub_group", None) is None: raise argparse.ArgumentError(self, "No current sub-group") namespace._current_sub_group[self.dest] = self.const or values def clearattr(namespace, attr): if hasattr(namespace, attr): delattr(namespace, attr) class GdctlParser(argparse.ArgumentParser): def parse_args(self): namespace = super().parse_args() clearattr(namespace, "_current_group") clearattr(namespace, "_current_sub_group") return namespace class MonitorCompleter(BaseCompleter): def __call__(self, **kwargs): try: display_config = DisplayConfig() monitors_state = MonitorsState(display_config) return tuple(monitors_state.monitors) except Exception: return () class MonitorModeCompleter(BaseCompleter): def __call__(self, parsed_args=None, **kwargs): try: (connector,) = parsed_args._current_sub_group["key"] display_config = DisplayConfig() monitors_state = MonitorsState(display_config) monitor = monitors_state.monitors[connector] return (mode.name for mode in monitor.modes) except Exception: return () class ScaleCompleter(BaseCompleter): def __call__(self, parsed_args=None, **kwargs): try: (connector,) = parsed_args._current_sub_group["key"] display_config = DisplayConfig() monitors_state = MonitorsState(display_config) monitor = monitors_state.monitors[connector] mode = parsed_args._current_sub_group.get("mode", None) if not mode: mode = monitor.preferred_mode scales = mode.supported_scales scales.sort(key=lambda scale: abs(scale - mode.preferred_scale)) return (repr(scale) for scale in scales) except Exception: return () class NamedEnumCompleter(BaseCompleter): def __init__(self, enum_type): self.enum_type = enum_type def __call__(self, **kwargs): return (str(enum_value) for enum_value in self.enum_type) class LayoutModeCompleter(NamedEnumCompleter): def __init__(self): super().__init__(LayoutMode) class TransformCompleter(NamedEnumCompleter): def __init__(self): super().__init__(Transform) class ColorModeCompleter(NamedEnumCompleter): def __init__(self): super().__init__(ColorMode) if __name__ == "__main__": parser = GdctlParser( description="Display control utility", ) subparser = parser.add_subparsers( dest="command", title="The following commands are available", metavar="COMMAND", required=True, ) show_parser = subparser.add_parser( "show", help="Show display configuration" ) show_parser.add_argument( "-m", "--modes", action="store_true", help="List available monitor modes", ) show_parser.add_argument( "-p", "--properties", action="store_true", help="List properties", ) show_parser.add_argument( "-v", "--verbose", action="store_true", help="Display all available information", ) set_parser = subparser.add_parser( "set", help="Set display configuration", ) set_parser.add_argument( "-P", "--persistent", action=AppendToGlobal, const=True, nargs=0, default=False, ) set_parser.add_argument( "-v", "--verbose", action=AppendToGlobal, const=True, nargs=0, default=False, ) set_parser.add_argument( "-V", "--verify", action=AppendToGlobal, const=True, nargs=0, default=False, ) set_parser.add_argument( "-l", "--layout-mode", choices=[str(layout_mode) for layout_mode in list(LayoutMode)], type=str, action=AppendToGlobal, ).completer = LayoutModeCompleter() # type: ignore[attr-defined] set_parser.add_argument( "-L", "--logical-monitor", dest="logical_monitors", action=GroupAction, nargs=0, default=[], ) set_parser.add_argument( "-e", "--for-lease-monitor", dest="monitors_for_lease", action="append", type=str, default=[], ).completer = MonitorCompleter() # type: ignore[attr-defined] logical_monitor_parser = set_parser.add_argument_group( "logical_monitor", "Logical monitor options (pass after --logical-monitor)", argument_default=argparse.SUPPRESS, ) logical_monitor_parser.add_argument( "-M", "--monitor", dest="monitors", metavar="CONNECTOR", action=SubGroupAction, nargs=1, help="Configure monitor", ).completer = MonitorCompleter() # type: ignore[attr-defined] monitor_parser = set_parser.add_argument_group( "monitor", "Monitor options (pass after --monitor)", argument_default=argparse.SUPPRESS, ) monitor_parser.add_argument( "--mode", "-m", action=AppendToSubGroup, help="Monitor mode", type=str, ).completer = MonitorModeCompleter() # type: ignore[attr-defined] monitor_parser.add_argument( "--color-mode", "-c", action=AppendToSubGroup, help="Color mode", choices=[str(color_mode) for color_mode in list(ColorMode)], type=str, ).completer = ColorModeCompleter() # type: ignore[attr-defined] logical_monitor_parser.add_argument( "--primary", "-p", action=AppendToGroup, help="Mark as primary", type=bool, const=True, nargs=0, ) logical_monitor_parser.add_argument( "--scale", "-s", action=AppendToGroup, help="Logical monitor scale", type=float, ).completer = ScaleCompleter() # type: ignore[attr-defined] logical_monitor_parser.add_argument( "--transform", "-t", action=AppendToGroup, help="Apply viewport transform", choices=[str(transform) for transform in list(Transform)], type=str, ).completer = TransformCompleter() # type: ignore[attr-defined] logical_monitor_parser.add_argument( "--x", "-x", action=AppendToGroup, help="X position", type=int, ) logical_monitor_parser.add_argument( "--y", "-y", action=AppendToGroup, help="Y position", type=int, ) logical_monitor_parser.add_argument( "--right-of", action=AppendToGroup, metavar="CONNECTOR", help="Place right of other monitor", type=str, ).completer = MonitorCompleter() # type: ignore[attr-defined] logical_monitor_parser.add_argument( "--left-of", action=AppendToGroup, metavar="CONNECTOR", help="Place left of other monitor", type=str, ).completer = MonitorCompleter() # type: ignore[attr-defined] logical_monitor_parser.add_argument( "--above", action=AppendToGroup, metavar="CONNECTOR", help="Place above other monitor", type=str, ).completer = MonitorCompleter() # type: ignore[attr-defined] logical_monitor_parser.add_argument( "--below", action=AppendToGroup, metavar="CONNECTOR", help="Place below other monitor", type=str, ).completer = MonitorCompleter() # type: ignore[attr-defined] prefs_parser = subparser.add_parser( "prefs", help="Set display preferences", ) prefs_parser.add_argument( "-M", "--monitor", dest="monitors", metavar="CONNECTOR", action=GroupAction, nargs=1, default=[], help="Change monitor preferences", ).completer = MonitorCompleter() # type: ignore[attr-defined] monitor_prefs_parser = prefs_parser.add_argument_group( "monitor", "Monitor preferences (pass after --monitor)", argument_default=argparse.SUPPRESS, ) monitor_prefs_parser.add_argument( "-l", "--luminance", action=AppendToGroup, type=float, nargs=1, ) monitor_prefs_parser.add_argument( "--reset-luminance", action=AppendToGroup, type=bool, const=True, nargs=0, ) if argcomplete: for action in [ GroupAction, SubGroupAction, AppendToGroup, AppendToSubGroup, AppendToGlobal, ]: argcomplete.safe_actions.add(action) argcomplete.autocomplete( parser, default_completer=argcomplete.SuppressCompleter ) # type: ignore[arg-type] args = parser.parse_args() match args.command: case "show": try: display_config = DisplayConfig() monitors_state = MonitorsState(display_config) except GLib.Error as e: if e.domain == GLib.quark_to_string(Gio.DBusError.quark()): error_message = strip_dbus_error_prefix(e.message) print( f"Failed to retrieve current state: {error_message}", file=sys.stderr, ) sys.exit(1) if args.verbose: show_modes = True show_properties = True else: show_modes = args.modes show_properties = args.properties monitors_state.print_current_state( show_modes=show_modes, show_properties=show_properties, ) case "set": try: display_config = DisplayConfig() monitors_state = MonitorsState(display_config) except GLib.Error as e: if e.domain == GLib.quark_to_string(Gio.DBusError.quark()): error_message = strip_dbus_error_prefix(e.message) print( f"Failed to retrieve current state: {error_message}", file=sys.stderr, ) sys.exit(1) try: config = generate_configuration(monitors_state, args) config_method = derive_config_method(args) if args.verbose: print_config(config) display_config.apply_monitors_config(config, config_method) except ValueError as e: print(f"Failed to create configuration: {e}", file=sys.stderr) sys.exit(1) except GLib.Error as e: if e.domain == GLib.quark_to_string(Gio.DBusError.quark()): error_message = strip_dbus_error_prefix(e.message) print( f"Failed to apply configuration: {error_message}", file=sys.stderr, ) else: print( f"Failed to apply configuration: {e.message}", file=sys.stderr, ) sys.exit(1) case "prefs": try: display_config = DisplayConfig() monitors_state = MonitorsState(display_config) except GLib.Error as e: if e.domain == GLib.quark_to_string(Gio.DBusError.quark()): error_message = strip_dbus_error_prefix(e.message) print( f"Failed to retrieve current state: {error_message}", file=sys.stderr, ) sys.exit(1) for monitor_prefs in args.monitors: connector = monitor_prefs["key"] if ( "luminance" in monitor_prefs and "reset_luminance" in monitor_prefs ): print( "Cannot both set and reset luminance", file=sys.stderr, ) sys.exit(1) if connector not in monitors_state.monitors: print( f"Monitor with connector {connector} not found", file=sys.stderr, ) sys.exit(1) monitor = monitors_state.monitors[connector] if "luminance" in monitor_prefs: (luminance,) = monitor_prefs["luminance"] display_config.set_luminance( monitor, monitor.color_mode, luminance ) elif "reset_luminance" in monitor_prefs: display_config.reset_luminance(monitor, monitor.color_mode)