// Name: Swap Display Positions (macOS) // Description: Detects displays and horizontally mirrors their arrangement (left↔right) using displayplacer with safe rollback. // Author: dallascrilley // GitHub: dallascrilley import "@johnlindquist/kit" /* Language and file extension recommendation: - Use TypeScript (.ts) with Script Kit for best DX and typed globals. Overview: - Safely swap the left↔right orientation of your macOS display layout using the `displayplacer` CLI. - Preserves each display's resolution, refresh rate, color profile, scaling, rotation—only changes origin positions. - Behavior with 1 display: informs and exits. - Behavior with 2 displays: swaps their horizontal positions. - Behavior with 3+ displays: mirrors the entire arrangement horizontally across the overall bounding box (left/right reversed, vertical unchanged). - Reversible: Provides a built-in rollback. After applying, you have a time-limited confirmation prompt to keep or revert. If no response, it auto-rolls back. Technical notes: - Requires macOS and the `displayplacer` CLI. If missing and Homebrew is available, the script can install it for you. - Supported macOS: Same as displayplacer (generally 10.13+). Tested commonly on recent macOS versions. - Permissions: None special for applying layouts. If installing via Homebrew, you may be prompted by Homebrew. - Performance: Immediate; changes apply instantly. This script waits for your confirmation with an auto-rollback safety window. */ // ---------- Safety and environment checks ---------- if (!isMac) { await div(md(`This script only supports macOS.`)) exit() } const screens = await getScreens() if (!screens || screens.length < 2) { await div(md(`Only ${screens.length || 0} display detected. Nothing to swap.`)) exit() } const confirmRun = await arg({ placeholder: "Swap display positions?", hint: `Detected ${screens.length} display(s). This will only change horizontal positions (x-origin). All other settings are preserved. You can rollback immediately if needed.`, enter: "Proceed", }, [ { name: "Proceed (safe mode, with rollback option)", value: "yes" }, { name: "Cancel", value: "no" }, ]) if (confirmRun !== "yes") exit() // ---------- Ensure displayplacer is available (install via brew if needed) ---------- async function whichCmd(cmd: string): Promise<boolean> { try { const w = await which(cmd) return !!w } catch { return false } } async function ensureDisplayplacer(): Promise<void> { const hasDisplayplacer = await whichCmd("displayplacer") if (hasDisplayplacer) return const hasBrew = await whichCmd("brew") const choice = await arg( { placeholder: "displayplacer not found", hint: hasBrew ? `displayplacer can be installed via Homebrew.` : `Homebrew not found. Please install Homebrew first from https://brew.sh and then run: brew install displayplacer`, }, hasBrew ? [ { name: "Install displayplacer via Homebrew now", value: "install" }, { name: "Cancel", value: "cancel" }, ] : [ { name: "Open https://brew.sh to install Homebrew", value: "open-brew" }, { name: "Cancel", value: "cancel" }, ] ) if (choice === "install") { try { await term({ command: `brew install displayplacer`, cwd: home() }) } catch (err) { await div(md(`Failed to install displayplacer. Error: ${String(err)}`)) exit(1) } const installed = await whichCmd("displayplacer") if (!installed) { await div(md(`displayplacer still not found in PATH after install. Please ensure Homebrew bin path is on your PATH and retry.`)) exit(1) } } else if (choice === "open-brew") { await browse("https://brew.sh") await div(md(`Please install Homebrew, then run: brew install displayplacer`)) exit() } else { exit() } } await ensureDisplayplacer() // ---------- Read current displayplacer config and parse ---------- async function getDisplayplacerList(): Promise<string> { try { const { stdout } = await $`displayplacer list` return stdout } catch (err) { await div(md(`Failed to read current display configuration via displayplacer.\nError: ${String(err)}`)) exit(1) } } const listOutput = await getDisplayplacerList() // Extract the "apply" command suggested by displayplacer function extractApplyCommandFromList(output: string): string | null { // The list usually contains a full `displayplacer "id:..." ...` example line // We'll find the first occurrence of a line starting with "displayplacer " and capture the rest of the line const lines = output.split(/\r?\n/) const cmdLine = lines.find(l => l.trim().startsWith("displayplacer ")) return cmdLine?.trim() || null } const rollbackCommand = extractApplyCommandFromList(listOutput) if (!rollbackCommand) { await div(md(`Could not extract a rollback command from displayplacer output. Aborting to be safe.`)) exit(1) } // Parse quoted segments like "id:12345 res:1920x1080 scaling:on origin:(0,0) degree:0 ..." type DpSegment = { raw: string id: number x: number y: number w: number h: number } function parseSegmentsFromApplyCommand(cmd: string): DpSegment[] { const segments: DpSegment[] = [] const quoted = cmd.match(/"([^"]+)"/g) || [] for (const q of quoted) { const raw = q.slice(1, -1) // remove quotes const idMatch = raw.match(/id:(\d+)/) const originMatch = raw.match(/origin:\((-?\d+),\s*(-?\d+)\)/) const resMatch = raw.match(/res:(\d+)x(\d+)/) if (!idMatch || !originMatch || !resMatch) continue const id = Number(idMatch[1]) const x = Number(originMatch[1]) const y = Number(originMatch[2]) const w = Number(resMatch[1]) const h = Number(resMatch[2]) segments.push({ raw, id, x, y, w, h }) } return segments } const currentSegments = parseSegmentsFromApplyCommand(rollbackCommand) if (!currentSegments.length) { await div(md(`No display segments parsed from displayplacer output. Aborting to be safe.`)) exit(1) } // ---------- Compute new (mirrored) horizontal positions ---------- // Strategy for 2+ displays: // - Compute bounding box minX and maxRight from current origin and width. // - Mirror each display horizontally across this bounding box: // newX = minX + maxRight - (x + w) // newY = y (unchanged) // // Notes: // - With 2 displays: this swaps left and right monitors. // - With 3+ displays: this reverses the entire horizontal arrangement around the center (left/right mirrored). // // This preserves each display's res/hz/scaling/etc by only changing origin. const minX = Math.min(...currentSegments.map(s => s.x)) const maxRight = Math.max(...currentSegments.map(s => s.x + s.w)) const mirroredSegments = currentSegments.map(s => { const newX = minX + maxRight - (s.x + s.w) return { ...s, x: newX } }) // For clarity and safety, if exactly 2 displays, also ensure the primary/secondary swap is clear in comments (handled by mirror). // For 3+ displays, we just applied the full horizontal mirror. // ---------- Build the new apply command (only origins changed) ---------- function buildApplyCommandFromSegments(originalCmd: string, updated: DpSegment[]): string { // Replace only origin:(x,y) in each quoted segment of the original command // by matching each segment by id: let result = originalCmd for (const seg of updated) { // Replace origin in the quoted segment with same id // We'll replace within quotes using a targeted regex // Pattern: "id:SEGID ... origin:(oldX,oldY) ..." const quotePattern = new RegExp(`"([^"]*id:${seg.id}[^"]*)"`, "g") result = result.replace(quotePattern, (match, inside) => { const replacedInside = inside.replace(/origin:\((-?\d+),\s*(-?\d+)\)/, `origin:(${seg.x},${seg.y})`) return `"${replacedInside}"` }) } return result } const applyCommand = buildApplyCommandFromSegments(rollbackCommand, mirroredSegments) // ---------- Show summary and confirm ---------- function segmentsSummary(segments: DpSegment[]): string { return segments .map(s => `• id:${s.id} res:${s.w}x${s.h} origin:(${s.x},${s.y})`) .join("\n") } const summaryBefore = segmentsSummary(currentSegments) const summaryAfter = segmentsSummary(mirroredSegments) const proceed = await arg( { placeholder: "Confirm swap (left↔right)", enter: "Apply", }, md( [ `Detected ${screens.length} display(s).`, ``, `Current layout:`, "```", summaryBefore, "```", `Proposed layout (horizontal mirror):`, "```", summaryAfter, "```", `Notes:`, `- 1 display: this script does nothing`, `- 2 displays: swaps left/right`, `- 3+ displays: reverses the entire horizontal order (mirror)`, `- All other settings remain unchanged (resolution, refresh rate, scaling, rotation, color profiles)`, ].join("\n") ) ) if (proceed === undefined) exit() // ---------- Apply changes with an automatic rollback prompt ---------- async function runDisplayplacer(cmd: string): Promise<void> { try { await $`${cmd}` } catch (err) { throw new Error(`displayplacer failed: ${String(err)}`) } } // Safety: store rollback command on disk in case of crash const rollbackScriptPath = tmpPath(`displayplacer-rollback-${Date.now()}.sh`) await writeFile(rollbackScriptPath, `#!/bin/sh\n${rollbackCommand}\n`, { encoding: "utf-8" }) await chmod("+x", rollbackScriptPath) try { await runDisplayplacer(applyCommand) } catch (err) { await div(md(`Failed to apply new layout. Attempting rollback.\nError: ${String(err)}`)) try { await runDisplayplacer(rollbackCommand) } catch (rbErr) { await div(md(`Rollback failed. Please manually run:\n\n${rollbackCommand}\n\nError: ${String(rbErr)}`)) } exit(1) } // Confirm to keep changes with auto-rollback if no response const keepOrRollback = await arg( { placeholder: `Keep these display changes?`, hint: `Auto-rollback in 15 seconds if no selection.`, onInit: async () => { setHint("Auto-rollback in 15 seconds...") setTimeout(() => { submit("rollback") }, 15000) }, }, [ { name: "Keep", value: "keep" }, { name: "Rollback", value: "rollback" }, ] ) if (keepOrRollback !== "keep") { try { await runDisplayplacer(rollbackCommand) await notify(`Rolled back display arrangement`) } catch (rbErr) { await div(md(`Rollback failed. Please manually run:\n\n${rollbackCommand}\n\nError: ${String(rbErr)}`)) exit(1) } } else { await notify(`Display arrangement kept`) } // Final note and exit await div( md( [ `Done.`, ``, `Tip: Run this script again to toggle back (mirror is symmetrical).`, ``, `If you ever need to restore the previous layout manually, use:\n`, "```bash", rollbackCommand, "```", `Saved temporary rollback script: ${rollbackScriptPath}`, ].join("\n") ) )