macos_display_configuration.py (7545B)
1 import argparse 2 import sys 3 from typing import Any, NewType, Optional, Tuple 4 5 from Cocoa import NSURL 6 from ColorSync import ( 7 CGDisplayCreateUUIDFromDisplayID, 8 ColorSyncDeviceSetCustomProfiles, 9 kColorSyncDeviceDefaultProfileID, 10 kColorSyncDisplayDeviceClass, 11 ) 12 from Quartz import ( 13 CGBeginDisplayConfiguration, 14 CGCancelDisplayConfiguration, 15 CGCompleteDisplayConfiguration, 16 CGConfigureDisplayWithDisplayMode, 17 CGDisplayCopyAllDisplayModes, 18 CGDisplayCopyDisplayMode, 19 CGDisplayModeGetHeight, 20 CGDisplayModeGetIOFlags, 21 CGDisplayModeGetPixelHeight, 22 CGDisplayModeGetPixelWidth, 23 CGDisplayModeGetRefreshRate, 24 CGDisplayModeGetWidth, 25 CGDisplayModeIsUsableForDesktopGUI, 26 CGDisplayModeRef, 27 CGGetOnlineDisplayList, 28 kCGConfigurePermanently, 29 kCGErrorSuccess, 30 ) 31 32 # Display mode flags 33 kDisplayModeDefaultFlag = 0x00000004 # noqa: N816 34 35 # Create a new type for display IDs 36 CGDirectDisplayID = NewType("CGDirectDisplayID", int) 37 38 39 def get_pixel_size(mode: CGDisplayModeRef) -> Tuple[int, int]: 40 return (CGDisplayModeGetPixelWidth(mode), CGDisplayModeGetPixelHeight(mode)) 41 42 43 def get_size(mode: CGDisplayModeRef) -> Tuple[int, int]: 44 return (CGDisplayModeGetWidth(mode), CGDisplayModeGetHeight(mode)) 45 46 47 def calculate_mode_similarity_score( 48 mode: CGDisplayModeRef, current_mode: CGDisplayModeRef 49 ) -> int: 50 current_size = get_size(current_mode) 51 current_pixel_size = get_pixel_size(current_mode) 52 current_refresh_rate = CGDisplayModeGetRefreshRate(current_mode) 53 current_flags = CGDisplayModeGetIOFlags(current_mode) 54 55 size = get_size(mode) 56 pixel_size = get_pixel_size(mode) 57 refresh_rate = CGDisplayModeGetRefreshRate(mode) 58 flags = CGDisplayModeGetIOFlags(mode) 59 60 differences = 0 61 62 if size != current_size: 63 differences += 1 64 if pixel_size != current_pixel_size: 65 differences += 1 66 if refresh_rate != current_refresh_rate: 67 differences += 1 68 69 # Count how many individual flags are changing (XOR then count bits) 70 changed_flags = flags ^ current_flags 71 if sys.version_info >= (3, 10): 72 differences += changed_flags.bit_count() 73 else: 74 differences += bin(changed_flags).count("1") 75 76 return differences 77 78 79 def find_best_unscaled_mode(display_id: CGDirectDisplayID) -> CGDisplayModeRef: 80 current_mode: Optional[CGDisplayModeRef] = CGDisplayCopyDisplayMode(display_id) 81 82 # If we already have an unscaled mode, we're done. 83 if current_mode and ( 84 get_size(current_mode) == get_pixel_size(current_mode) 85 ): 86 return current_mode 87 88 all_modes = CGDisplayCopyAllDisplayModes(display_id, None) 89 if not all_modes: 90 raise Exception("No display modes") 91 92 # If we don't have a current mode, use the default mode instead. 93 if not current_mode: 94 default_modes = [ 95 m for m in all_modes if CGDisplayModeGetIOFlags(m) & kDisplayModeDefaultFlag 96 ] 97 if not default_modes: 98 raise Exception("No default display mode found") 99 current_mode = default_modes[0] 100 assert current_mode is not None 101 102 if get_size(current_mode) == get_pixel_size(current_mode): 103 return current_mode 104 105 candidates = [ 106 m 107 for m in all_modes 108 if CGDisplayModeIsUsableForDesktopGUI(m) and get_size(m) == get_pixel_size(m) 109 ] 110 if not candidates: 111 raise Exception("No suitable display modes") 112 113 same_size_candidates = [ 114 m for m in candidates if get_size(m) == get_size(current_mode) 115 ] 116 same_pixel_size_candidates = [ 117 m for m in candidates if get_pixel_size(m) == get_pixel_size(current_mode) 118 ] 119 120 if same_size_candidates: 121 candidates = same_size_candidates 122 elif same_pixel_size_candidates: 123 candidates = same_pixel_size_candidates 124 125 return min( 126 candidates, 127 key=lambda m: calculate_mode_similarity_score(m, current_mode), 128 ) 129 130 131 def set_color_profiles(profile_url: NSURL, *, dry_run: bool = False) -> bool: 132 max_displays = 10 133 134 (err, display_ids, display_count) = CGGetOnlineDisplayList(max_displays, None, None) 135 if err != kCGErrorSuccess: 136 raise ValueError(err) 137 138 display_uuids = [CGDisplayCreateUUIDFromDisplayID(d) for d in display_ids] 139 140 for display_id, display_uuid in zip(display_ids, display_uuids): 141 if dry_run: 142 print( 143 f"Would set color profile for display {display_id} to {profile_url.path()}" 144 ) 145 else: 146 profile_info = {kColorSyncDeviceDefaultProfileID: profile_url} 147 success = ColorSyncDeviceSetCustomProfiles( 148 kColorSyncDisplayDeviceClass, 149 display_uuid, 150 profile_info, 151 ) 152 if not success: 153 raise Exception(f"failed to set profile on {display_uuid}") 154 print(f"Set color profile for display {display_id}") 155 156 return True 157 158 159 def set_display_modes(*, dry_run: bool = False) -> bool: 160 max_displays = 10 161 162 err, display_ids, display_count = CGGetOnlineDisplayList(max_displays, None, None) 163 if err != kCGErrorSuccess: 164 raise ValueError(err) 165 166 if dry_run: 167 for display_id in display_ids: 168 best_mode = find_best_unscaled_mode(display_id) 169 best_size = get_size(best_mode) 170 print(f"Would change display {display_id} to {best_size}") 171 return True 172 173 err, config_ref = CGBeginDisplayConfiguration(None) 174 if err != kCGErrorSuccess: 175 raise Exception("Failed to begin display configuration") 176 177 try: 178 for display_id in display_ids: 179 best_mode = find_best_unscaled_mode(display_id) 180 best_size = get_size(best_mode) 181 182 err = CGConfigureDisplayWithDisplayMode( 183 config_ref, display_id, best_mode, None 184 ) 185 if err != kCGErrorSuccess: 186 raise Exception( 187 f"Failed to configure mode for display {display_id}: {err}" 188 ) 189 190 print(f"Configured display {display_id} mode to {best_size}") 191 192 except Exception: 193 CGCancelDisplayConfiguration(config_ref) 194 raise 195 196 else: 197 err = CGCompleteDisplayConfiguration(config_ref, kCGConfigurePermanently) 198 if err != kCGErrorSuccess: 199 raise Exception(f"Failed to complete display configuration: {err}") 200 201 print("Display configuration applied permanently") 202 203 return True 204 205 206 def create_parser() -> argparse.ArgumentParser: 207 parser = argparse.ArgumentParser() 208 parser.add_argument( 209 "--dry-run", 210 action="store_true", 211 help="Show what would be done without making changes", 212 ) 213 parser.add_argument( 214 "--no-color-profile", 215 action="store_false", 216 dest="color_profile", 217 help="Don't set color profiles", 218 ) 219 parser.add_argument( 220 "--no-display-mode", 221 action="store_false", 222 dest="display_mode", 223 help="Don't set display mode", 224 ) 225 parser.add_argument( 226 "--profile-path", 227 default="/System/Library/ColorSync/Profiles/sRGB Profile.icc", 228 help="Path to color profile to use (default: sRGB)", 229 ) 230 return parser 231 232 233 def run(venv: Any, **kwargs: Any) -> None: 234 profile_url = NSURL.fileURLWithPath_(kwargs["profile_path"]) 235 dry_run = kwargs["dry_run"] 236 237 if kwargs["color_profile"]: 238 set_color_profiles(profile_url, dry_run=dry_run) 239 240 if kwargs["display_mode"]: 241 set_display_modes(dry_run=dry_run)