#!/usr/bin/perl # # Print menu lines with commands to enable some popular monitor # configurations or activate the selected combo. # # Copyright (c) 2017-2022 Eduard Bloch # License: Simplified BSD License # use strict; use warnings; use Getopt::Long qw(:config no_ignore_case bundling auto_help); #print("# WHAT: ".join(" ", @ARGV)); # helper: (h, v, rate) my $prim; my @connected; my %active; my @postcmd; # development switches. Debug value of 1 is creating output with # commented hints, 2 and above dump unformatted extra stuff into output my $debug=0; # Testing the --activate option without actually executing it my $dry_run; my $xrandr = "xrandr"; # monitor name mapping, either from cache or built from EDID info later my %realname; # main calculation collection my @actionMap; my $activate; my $auto_num = 0; sub parse_arguments { my $bad_max; GetOptions("xrandr=s" => \$xrandr, "max" => \$bad_max, "debug:i" => \$debug, "activate=s" => \$activate, "dry-run" => \$dry_run, "auto-number" => \$auto_num) or die("Error in command line arguments\n"); print "# Deprecated option --max, see --help or the manual page for alternatives.\n" if $bad_max; if($debug) { eval "use Data::Dumper"; eval "use Diagnostics"; eval "use warnings FATAL => 'all'"; } } sub get_contents { my $filename = shift; open my $fh, '<', $filename or return ""; my $data = do { local $/; <$fh> }; return $data; } print "Running $0\n" if $debug > 1; my @cfiledata; $xrandr.=" --verbose"; # Input: blob, start of the string data sub strip_tfield { my $name = substr($_[0], $_[1], 13); print "# Raw name: $name\n" if $debug > 4; $name=~s/^\W.*//; $name=~s/(\r|\n|\0).*//; $name=~s/\W$//; print "# Decoded: $name\n" if $debug > 2; return $name; } sub load_ini { my $cfname = "xrandr_menu"; foreach ($ENV{XDG_CONFIG_HOME} ? "$ENV{XDG_CONFIG_HOME}/icewm/$cfname" : undef, "$ENV{HOME}/.icewm/$cfname") { next if !$_; print ("# Ini-Check: $_\n") if $debug; next if ! -r $_; my $fd; my %cfg; { my %cfg=( "sectype" => "main"); push(@cfiledata, \%cfg); } if (open($fd, "<$_")) { for (<$fd>) { s/^\s+//; next if /^\s*#/; s/#.*//; s/\s+$//; chomp; print "# C: $_\n" if $debug > 3; if (/^\[\s*(\w+)(\s+(\w+))?/) { my %cfg=(); push(@cfiledata, \%cfg); $cfg{sectype} = $1; $cfg{"match-name"} = $3 if $3; } elsif(/^(\w\S+)\s*=\s*(\S.*)/) { my $key = $1; my $val = $2; $val=~s/^"(.*)"$/$1/; my $xr = $cfiledata[-1]; $$xr{$key} = $val; } } } print Dumper(@cfiledata) if $debug > 1; # config was found last; } } sub parse_edid { my ($hexblob, $curmon) = @_; my $name = ""; my $blob=pack("H*", $hexblob); print "Parsing monitor data for $curmon from $hexblob\n" if $debug > 2; if ($debug > 4) { my $foo; open($foo,"|hexdump -C"); print $foo $blob; } foreach my $fpos (0..3) { $fpos = 57+$fpos*18; my $cv = substr($blob, $fpos,1); $cv = ord($cv); print "tcode at $fpos: $cv\n" if $debug > 3; if ($cv == 252) # full user description, prefer this { $name = strip_tfield($blob, $fpos + 2); last; } # the alternative to Display Name descriptor is a set of # description words (Vendor, Type name, etc.) if ($cv == 254) { my $word = strip_tfield($blob, $fpos + 2); if (length($name) + length($word) < 31) { $name.=" " if $name; $name.=$word; } } } if ($name) { $name.="%PRIMFLAG"; $realname{$curmon} = $name.'~'.$curmon; print "Found: $name\n" if $debug > 2; } } sub match_output { my $output = shift; my @ret = (); #print Dumper("# cfiledata:", @cfiledata) if $debug > 4; if (! scalar @cfiledata) { return @ret; } for my $xr (@cfiledata) { #print "# What is this?".Dumper("# $xr -> ", $xr) if $debug > 5; my $st = $$xr{"sectype"}; next if !defined($st); next unless ($st eq "main" || $st eq "output"); my $fil = $$xr{"match-name"}; if (defined($fil)) { if ($fil =~ /^\/(.*)\/$/) { next unless $output =~ /$1/; } else { next unless $fil eq $output; } } $fil = $$xr{"match-label"}; if (defined($fil)) { my $label = $realname{$output}; if ($fil =~ /^\/(.*)\/$/) { next unless $label =~ /$1/; } else { next unless $fil eq $label; } } push(@ret, $xr); } return @ret; } sub pick_for_output { my ($iface, $key) = @_; my $ret; for my $xr (match_output($iface)) { my $xro = $$xr{$key}; next unless defined($xro); $ret = $xro; } return $ret; } # # Select a specified assignment for all combos which match the specified # label. # sub pick_for_combo { my $combo = shift; my $key = shift; my $val; for my $xr (@cfiledata) { my $st = $$xr{"sectype"}; next if ! defined($st); next unless ($st eq "main" || $st eq "combo"); my $fil = ($$xr{"match-name"} or $$xr{"match-label"}); if (defined($fil)) { if ($fil =~ /^\/(.*)\/$/) { next unless $combo =~ /$1/; } else { next unless $fil eq $combo; } } $val = ($$xr{$key} or $val); } return $val; } sub fmtMode { return (pick_for_output(shift, "options") or "--auto"); } sub fmtOutputMode { my $iface = shift; return "--output $iface ".fmtMode($iface); } sub read_xrandr { my @curres; my $currentEdid; my $curmon; my $disabled; for(@_) { print "# DBG: $_" if $debug > 4; my $resFound =/(\d+x\d+\++\d)/; # compressed, non-verbose output if(/^(\w\S*)\s+(connected|disconnected)\s+(primary)?/) { # parse state helper var $curmon = $1; $prim = $curmon if $3; print "# curmon hit? $1 - $2 - ".(defined($3) ? $3 : "")."\n" if $debug > 4; # disconnected and also no active resolution set! $disabled = ($2 eq "disconnected") && !$resFound; if($disabled) { print "# marked as disabled: $curmon\n" if $debug > 4; #$turnRestOff.=" --display $curmon --off"; } else { print "# add as connected: $curmon" if $debug > 4; push(@connected, $curmon); } } # consider active only when there is a flag in the resolution list... $active{$curmon} = (/^\s.*\*current/ && !$disabled) if $curmon; # print "# consider: $curmon" if $debug > 4; if (defined($currentEdid)) { if (/:/) # now analyze and stop merging { parse_edid($currentEdid, $curmon); undef $currentEdid; } else { /(\w+)/; $currentEdid .= $1; } } elsif (/EDID:/) { $currentEdid = ""; } } parse_edid($currentEdid, $curmon) if $currentEdid; } sub relabel_outputs { for my $output (@connected) { my $newLabel = pick_for_output($output, "label"); next unless $newLabel; $newLabel=~s/%OUTPUT/$output/g; my $oldLabel = $realname{$output}; $newLabel=~s/%LABEL/$oldLabel/g if defined($oldLabel); $realname{$output} = $newLabel; } } sub eval_output { my $l = shift; my $r = shift; # that is always first and there is only one primary return -333 if ($l eq $prim); return 333 if ($r eq $prim); my $ar = $active{$r} or 0; my $al = $active{$l} or 0; return $ar <=> $al if $al != $ar; # both are equally active or not active, then use output name return ($l cmp $r); } sub sort_monitors { # order the connected list, so that it starts with the active monitors, # and among those the primary should be on the first place @connected = sort { eval_output($a, $b) } @connected; } sub ajoin { return join(' ', grep {length($_)} @_); } sub format_last_off { my $start = shift; my $xlast = $#connected; return "" if $start > $xlast; return ajoin(map { "--output '$_' --off" } @connected[$start..$xlast]); } sub nameOf { my $id = shift; my $getsPrimary = shift; if(exists $realname{$id}) { $id = $realname{$id}; } else { # old workaround for sysfs <-> xrandr mapping # # uber-clever xrandr hides the bustype prefix if the name is unambiguous but the kernel doesn't, so try to find the real name there # foreach ("A", "D", "I") { # my $altName = $id; # return $realname{$altName} if ($altName =~ s/-(\d)/-A-$1/ && exists $realname{$altName}); # } } my $flag = $getsPrimary ? "*" : ""; $id =~ s/%PRIMFLAG/$flag/g; return $id; } sub loc { my $forced = pick_for_output(shift, "posopt"); return defined($forced) ? ($forced) : @_; } sub add_conf { my ($showname, $icon, $cmd, $postCmdHint) = @_; # push(@actionMap, [ mini_hash($cmd), $showname, $icon, $cmd ]); my @cmdHints = $postCmdHint ? @{$postCmdHint} : ($prim); my $newPrim = $cmdHints[0]; my %dedup; my @xcmds; print "add_conf: $showname, $icon, $cmd, $postCmdHint" if $debug > 4; for my $involved (@cmdHints) { print "# affected: $involved\n" if $debug; my $mocmd = pick_for_output($involved, "postcmd"); next unless defined($mocmd); $mocmd=~s/%OUTPUT/$involved/g; next if $dedup{$mocmd}++; push(@xcmds, $mocmd); } my $cocmd = pick_for_combo($showname, "postcmd"); push(@xcmds, $cocmd) if ($cocmd && 0 == $dedup{$cocmd}); push(@xcmds, "icesh restart") if ($cfiledata[0]{"restart-on-primary-change"} && $newPrim && $newPrim ne $prim); if (@xcmds) { $cmd = "sh -c '$xrandr ".join(" ; ", ($cmd, @xcmds))."'"; } else { $cmd = "$xrandr $cmd"; } push(@actionMap, [ #reserved undef, $showname, $icon, $cmd ]); } sub run { my $rof = "--right-of"; my $lof = "--left-of"; my $priFlag = "--primary"; parse_arguments; read_xrandr `$xrandr`; sort_monitors; load_ini; relabel_outputs; # XXX: the handling of future parameters is a mess, redundant flag passing for primary, multiple lookups from # config for the same target. It should better be calculated in objects. foreach my $mon (@connected) { my @parms = map { "--output $_ ".($_ eq $mon ? fmtMode($mon)." $priFlag" : "--off") } @connected; add_conf(nameOf($mon), "setscreen".(1+int(@actionMap)), ajoin( @parms), [$mon]); } if (@connected > 1) { my $killRest = format_last_off(2); my $sec = $connected[1]; add_conf(nameOf($prim, 1)." + ".nameOf($sec), "setscreen12", ajoin(fmtOutputMode($prim), $priFlag, loc($prim), fmtOutputMode($sec), loc($sec, $rof, $prim), $killRest), [$prim, $sec] ); add_conf(nameOf($sec)." + ".nameOf($prim, 1), "setscreen21", ajoin(fmtOutputMode($sec), loc($sec), fmtOutputMode($prim), $priFlag, loc($prim, $rof, $sec), $killRest), [$prim, $sec]); add_conf(nameOf($prim)." + ".nameOf($sec, 1), "setscreen12x", ajoin(fmtOutputMode($prim), loc($prim), fmtOutputMode($sec), $priFlag, loc($sec, $rof, $prim), $killRest), [$sec, $prim] ); add_conf(nameOf($sec, 1)." + ".nameOf($prim), "setscreen21x", ajoin(fmtOutputMode($prim), loc($prim), fmtOutputMode($sec), $priFlag, loc($sec, $lof, $prim), $killRest), [$sec, $prim]); add_conf(join(" / ", map { nameOf($_) } @connected), "display", ajoin(map { fmtOutputMode($_)." --pos 0x0" } @connected)); # one pair is covered above, for 3 and more, let's find something creative if (@connected > 2) { my $cmd; my $last; foreach(@connected) { $cmd .= ' ' if $cmd; $cmd .= fmtOutputMode($_).($last ? " $rof $last" : ""); $last = $_; } add_conf(join(" ++ ", map { nameOf($_) } @connected), "display", $cmd, [undef, @connected]); } } print Dumper(@actionMap) if $debug > 1; if ($activate) { for my $p (@actionMap) { my @el = @{$p}; next if $activate ne $el[1]; my $cmd = "$xrandr $el[3]"; print "# Would run: $cmd\n" if $dry_run; exit($dry_run ? 0 : system("sh", "-c", $cmd)); } print STDERR "Unknown selection: $activate\n"; exit(42); } else { for my $p (@actionMap) { my @el = @{$p}; my $name = $el[1]; my $icon = $el[2]; $name = $auto_num++ .": ".$name if $auto_num; print "prog '$name' $icon $el[3]\n"; } } } run; # vim: set ai sw=4 ts=4 tw=72 et nocin spell spelllang=en_ca: