1#!/bin/sh
2#
3# The MIT License (MIT)
4#
5# Copyright (c) 2017-2018 Thomas "Ventto" Venriès <thomas.venries@gmail.com>
6#
7# Permission is hereby granted, free of charge, to any person obtaining a copy of
8# this software and associated documentation files (the "Software"), to deal in
9# the Software without restriction, including without limitation the rights to
10# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
11# the Software, and to permit persons to whom the Software is furnished to do so,
12# subject to the following conditions:
13#
14# The above copyright notice and this permission notice shall be included in all
15# copies or substantial portions of the Software.
16#
17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
19# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
20# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
21# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
22# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23usage() {
24    echo 'Usage: mons [OPTION]...
25
26Without argument, it prints connected monitors list with their names and ids.
27Options are exclusive and can be used in conjunction with extra options.
28
29Information:
30  -h    Prints this help and exits.
31  -v    Prints version and exits.
32
33Two monitors:
34  -o    Primary monitor only.
35  -s    Second monitor only.
36  -d    Duplicates the primary monitor.
37  -m    Mirrors the primary monitor.
38  -e <side>
39         Extends the primary monitor to the selected side
40         [ top | left | right | bottom ].
41  -n <side>
42         This mode selects the previous ones, one after another. The argument
43         sets the side for the extend mode.
44
45More monitors:
46  -O <mon>
47        Only enables the monitor with a specified id.
48  -S <mon1>,<mon2>:<pos>
49        Only enables two monitors with specified ids. The specified position
50        places the second monitor on the right (R) or at the top (T).
51
52Extra (in-conjunction or alone):
53  --dpi <dpi>
54        Set the DPI, a strictly positive value within the range [0 ; 27432].
55  --primary <mon_name>
56        Select a connected monitor as the primary output. Run the script
57        without argument to print monitors information, the names are in the
58        second column between ids and status. The primary monitor is marked
59        by an asterisk.
60
61Daemon mode:
62  -a    Performs an automatic display if it detects only one monitor.
63'
64}
65
66version() {
67    echo 'Mons 0.8.2
68Copyright (C) 2017 Thomas "Ventto" Venries.
69
70License MIT: <https://opensource.org/licenses/MIT>.
71
72THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
73IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
74FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
75'
76}
77
78# Helps to generate manpage with help2man before installing the library
79[ "$1" = '-h' ] && { usage; exit; }
80[ "$1" = '-v' ] && { version; exit; }
81lib='/usr/local/lib/libshlist/liblist.sh'
82[ ! -r "$lib" ] && { "$lib: library not found."; exit 1; }
83. "${lib}"
84
85arg_err() {
86    usage ; exit 2
87}
88
89enable_mon() {
90    "${XRANDR}" --output "${1}" --auto --dpi "${dpi}"
91}
92
93disable_mons() {
94    for mon in $@; do "${XRANDR}" --output "${mon}" --off ; done
95}
96
97arg2xrandr() {
98    case $1 in
99        left)   echo '--left-of'    ;;
100        right)  echo '--right-of'   ;;
101        bottom) echo '--below'      ;;
102        top)    echo '--above'      ;;
103    esac
104}
105
106whichmode() {
107    if [ "$(list_size "${disp_mons}")" -eq 1 ]; then
108        if echo "${enabled_out}" | grep prima > /dev/null 2>&1; then
109            echo 'primary'
110        else
111            echo 'second'
112        fi
113    else
114        if [ "$(list_size "${plug_mons}")" -gt 2 ] ; then
115            echo 'selection'; return 0
116        fi
117
118        enabled_out="$(echo "${enabled_out}" | \
119                        sed 's/^.*\( [0-9]\+\x[0-9]\++[0-9]\++[0-9]\+\).*/\1/')"
120
121        echo "${enabled_out}" | head -n1 | sed -e 's/+/ /g' | \
122        while read -r trash x1 y1; do
123            echo "${enabled_out}" | tail -n1 | sed -e 's/x/ /' -e 's/+/ /g' | \
124            while read -r w2 h2 x2 y2; do
125                echo "${xrandr_out}" | \
126                    awk "/^$(list_get 1 "${plug_mons}")/{nr[NR+1]}; NR in nr" | \
127                    awk '{print $1;}' | sed -e 's/x/ /' | \
128                while read -r wi2 hi2; do
129                    if [ "$x1" = "$x2" ] && [ "$y1" = "$y2" ]; then
130                        if [ "$w2" != "$wi2" ] || [ "$h2" != "$hi2" ]; then
131                            echo 'mirror'
132                        else
133                            echo 'duplicate'
134                        fi
135                    else
136                        echo 'extend'
137                    fi
138                done
139            done
140        done
141    fi
142}
143
144main() {
145    aFlag=false
146    dFlag=false
147    eFlag=false
148    mFlag=false
149    nFlag=false
150    oFlag=false
151    sFlag=false
152    OFlag=false
153    SFlag=false
154    pFlag=false
155    iFlag=false
156    is_flag=false
157    # X has assumed 96 DPI and this is fine for many traditional monitors.
158    dpi=96
159    primary=
160
161    # getopts does not support long options. We convert them to short one.
162    for arg in "$@"; do
163        shift
164        case "$arg" in
165            --dpi)      set -- "$@" '-i' ;;
166            --primary)  set -- "$@" '-p' ;;
167            *)          set -- "$@" "$arg"
168        esac
169    done
170
171    while getopts 'hvamosde:n:O:S:i:p:' opt; do
172        case $opt in
173            # Long options
174            i)
175                if ! echo "${OPTARG}" | \
176                    grep -E '^[1-9][0-9]*$' > /dev/null 2>&1; then
177                    arg_err
178                fi
179                iFlag=true; dpi="$OPTARG"
180                ;;
181            p)  if ! echo "${OPTARG}" | \
182                    grep -E '^[a-zA-Z][a-zA-Z0-9\-]+' > /dev/null 2>&1; then
183                    arg_err
184                fi
185                pFlag=true; primary="$OPTARG"
186                ;;
187            # Short options
188            a)  $is_flag && arg_err
189                aFlag=true ; is_flag=true
190                ;;
191            m)  $is_flag && arg_err
192                mFlag=true ; is_flag=true
193                ;;
194            o)  $is_flag && arg_err
195                oFlag=true ; is_flag=true
196                ;;
197            s)  $is_flag && arg_err
198                sFlag=true ; is_flag=true
199                ;;
200            d)  $is_flag && arg_err
201                dFlag=true ; is_flag=true
202                ;;
203            e|n)  $is_flag && arg_err
204                case ${OPTARG} in
205                    left | right | bottom | top) ;;
206                    *) arg_err ;;
207                esac
208                eArg=$OPTARG
209                [ "$opt" = "e" ] && eFlag=true || nFlag=true ; is_flag=true
210                ;;
211            O)  $is_flag && arg_err
212                ! echo "${OPTARG}" | grep -E '^[0-9]+$' > /dev/null && arg_err
213                OArg=$OPTARG
214                OFlag=true ; is_flag=true
215                ;;
216            S)  $is_flag && arg_err
217                idx1="$(echo "${OPTARG}" | cut -d',' -f1)"
218                idx2="$(echo "${OPTARG}" | cut -d',' -f2)"
219                area="$(echo "${idx2}" | cut -d ':' -f2)"
220                idx2="$(echo "${idx2}" | cut -d ':' -f1)"
221                ! echo "${idx1}" | grep -E '^[0-9]+$' > /dev/null && arg_err
222                ! echo "${idx2}" | grep -E '^[0-9]+$' > /dev/null && arg_err
223                ! echo "${area}" | grep -E '^[RT]$' > /dev/null && arg_err
224                [ "${idx1}" = "${idx2}" ] && arg_err
225                SFlag=true ; is_flag=true
226                ;;
227            h)  usage   ; exit ;;
228            v)  version ; exit ;;
229            \?) arg_err ;;
230            :)  arg_err ;;
231        esac
232    done
233
234    [ -z "${DISPLAY}" ] && { echo 'DISPLAY: no variable set.'; exit 1; }
235
236    XRANDR="$(command -v xrandr)"
237    [ "$?" -ne 0 ] && { echo 'xrandr: command not found.'; exit 1; }
238
239    # DPI set
240    $iFlag && [ "$#" -eq 2 ] && { "${XRANDR}" --dpi "$dpi"; exit; }
241
242    # Daemon mode
243    if $aFlag ; then
244        prev=0; i=0
245        while true; do
246            for status in /sys/class/drm/*/status; do
247                [ "$(<"$status")" = 'connected' ] && i=$((i+1))
248            done
249            if [ "$i" -eq 1 ] && [ "$i" != "$prev" ]; then
250                "${XRANDR}" --auto --dpi "${dpi}"
251            fi
252            prev="$i"; i=0
253            sleep 2
254        done
255    fi
256
257    # List all outputs (except primary one)
258    xrandr_out="$("${XRANDR}")"
259    enabled_out="$(echo "${xrandr_out}" | grep 'connect')"
260    [ -z "${enabled_out}" ] && { echo 'No monitor output detected.'; exit; }
261    mons="$(echo "${enabled_out}" | cut -d' ' -f1)"
262
263    # List plugged-in and turned-on outputs
264    enabled_out="$(echo "${enabled_out}" | grep ' connect')"
265    [ -z "${enabled_out}" ] && { echo 'No plugged-in monitor detected.'; exit 1; }
266    plug_mons="$(echo "${enabled_out}" | cut -d' ' -f1)"
267
268    # Set primary output
269    if $pFlag; then
270        if ! list_contains "${primary}" "${plug_mons}"; then
271            echo "${primary}: output not connected."
272            exit 1
273        fi
274        "${XRANDR}" --output "${primary}" --primary
275        [ "$#" -eq 2 ] && exit
276    else
277        primary="$(echo "${enabled_out}" | grep 'primary' | cut -d' ' -f1)"
278    fi
279
280    # Move the primary monitor to the head if connected otherwise the first
281    # connected monitor that appears in the xrandr output is considerate as
282    # the primary one.
283    if [ -n "${primary}" ]; then
284        plug_mons="$(list_erase "${primary}" "${plug_mons}")"
285        plug_mons="$(list_insert "${primary}" 0 "${plug_mons}")"
286    fi
287
288    enabled_out="$(echo "${enabled_out}" | grep -E '\+[0-9]{1,4}\+[0-9]{1,4}')"
289    disp_mons="$(echo "${enabled_out}" | cut -d' ' -f1)"
290
291    if [ "$#" -eq 0 ]; then
292        echo "Monitors: $(list_size "${plug_mons}")"
293        echo "Mode: $(whichmode)"
294
295        i=0
296        for mon in ${mons}; do
297            if echo "${plug_mons}" | grep "^${mon}$" > /dev/null; then
298                if echo "${disp_mons}" | grep "^${mon}$" > /dev/null; then
299                    state='(enabled)'
300                fi
301                if [ "${mon}" = "${primary}" ]; then
302                    printf '%-4s %-8s %-8s %-8s\n' "${i}:*" "${mon}" "${state}"
303                else
304                    printf '%-4s %-8s %-8s\n' "${i}:" "${mon}" "${state}"
305                fi
306            fi
307            i=$((i+1))
308            state=
309        done
310        exit
311    fi
312
313    if $nFlag ; then
314        case "$(whichmode)" in
315            primary)   sFlag=true;;
316            second)    eFlag=true;;
317            extend)    mFlag=true;;
318            mirror)    dFlag=true;;
319            duplicate) oFlag=true;;
320        esac
321    fi
322
323    if [ "$(list_size "${plug_mons}")" -eq 1 ] ; then
324        if $oFlag ; then
325            # After unplugging each monitor, the last preferred one might be
326            # still turned off or the window manager might need the monitor
327            # reset to cause the reconfiguration of the layout placement.
328            "${XRANDR}" --auto --dpi "${dpi}"
329        else
330            echo 'Only one monitor detected.'
331        fi
332        exit
333    fi
334
335    if $oFlag ; then
336        if [ "$(list_size "${disp_mons}")" -eq 1 ]; then
337            if [ "$(list_front "${disp_mons}")" = "$(list_front "${plug_mons}")" ]; then
338                exit
339            fi
340        fi
341        disp_mons="$(list_erase "$(list_front "${plug_mons}")" "$disp_mons")"
342        disable_mons "${disp_mons}"
343        enable_mon "$(list_front "${plug_mons}")"
344        exit
345    fi
346
347    if $OFlag ; then
348        if [ "${OArg}" -ge "$(list_size "${mons}")" ] ; then
349            echo "Monitor ID '${OArg}' does not exist."
350            echo 'Try without option to get monitor ID list.'
351            exit 2
352        fi
353        mons_elt="$(list_get "${OArg}" "${mons}")"
354        if ! list_contains "${mons_elt}" "${plug_mons}"; then
355            echo "Monitor ID '${OArg}' not plugged in."
356            echo 'Try without option to get monitor ID list.'
357            exit 2
358        fi
359
360        disp_mons="$(list_erase "${mons_elt}" "${disp_mons}")"
361        disable_mons "${disp_mons}"
362        enable_mon "${mons_elt}"
363        exit
364    fi
365
366    if $SFlag ; then
367        if [ "${idx1}" -ge "$(list_size "${mons}")" ] || \
368            [ "${idx2}" -ge "$(list_size "${mons}")" ]; then
369            echo 'One or both monitor IDs do not exist.'
370            echo 'Try without option to get monitor ID list.'
371            exit 2
372        fi
373        if ! list_contains "$(list_get "${idx1}" "${mons}")" "${plug_mons}" || \
374            ! list_contains "$(list_get "${idx2}" "${mons}")" "${plug_mons}" ; then
375            echo 'One or both monitor IDs are not plugged in.'
376            echo 'Try without option to get monitor ID list.'
377            exit 2
378        fi
379
380        [ "${area}" = 'R' ] && area="--right-of" || area="--above"
381
382        mon1="$(list_get "${idx1}" "${mons}")"
383        mon2="$(list_get "${idx2}" "${mons}")"
384        disp_mons="$(list_erase "${mon1}" "${disp_mons}")"
385        disp_mons="$(list_erase "${mon2}" "${disp_mons}")"
386        disable_mons "${disp_mons}"
387        enable_mon "${mon1}"
388        enable_mon "${mon2}"
389        "${XRANDR}" --output "${mon2}" "${area}" "${mon1}"
390        exit
391    fi
392
393    if [ "$(list_size "${plug_mons}")" -eq 2 ]; then
394        if $sFlag ; then
395            if [ "$(list_size "${disp_mons}")" -eq 1 ] ; then
396                if [ "$(list_front "${disp_mons}")" = "$(list_get 1 "${plug_mons}")" ] ; then
397                    enable_mon "$(list_get 1 "${plug_mons}")"
398                    exit
399                fi
400            fi
401            enable_mon "$(list_get 1 "${plug_mons}")"
402            disable_mons "$(list_front "${disp_mons}")"
403            exit
404        fi
405
406        # Resets the screen configuration
407        disable_mons "$(list_get 1 "${plug_mons}")"
408        "${XRANDR}" --auto --dpi "${dpi}"
409
410        if $dFlag ; then
411            "${XRANDR}" --output "$(list_get 1 "${plug_mons}")" \
412                --same-as "$(list_front "${plug_mons}")"
413            exit $?
414        fi
415
416        if $mFlag ; then
417            xrandr_out="$(echo "${xrandr_out}" | \
418                          awk "/primary/{nr[NR]; nr[NR+1]}; NR in nr")"
419            if echo "${xrandr_out}" | \
420                grep -E 'primary [0-9]+x[0-9]+' >/dev/null 2>&1; then
421                size="$(echo "${xrandr_out}" | head -n1 | cut -d' ' -f4 | \
422                        cut -d'+' -f1)"
423            else
424                size="$(echo "${xrandr_out}" | tail -n1 | awk '{ print $1 }')"
425            fi
426
427            "${XRANDR}" --output "$(list_get 1 "${plug_mons}")" \
428                --auto --scale-from  "${size}" \
429                --output "$(list_front "${plug_mons}")"
430            exit $?
431        fi
432
433        if $eFlag ; then
434            "${XRANDR}" --output "$(list_get 1 "${plug_mons}")" \
435                "$(arg2xrandr "$eArg")" "$(list_front "${plug_mons}")"
436            exit $?
437        fi
438    else
439        echo 'At most two plugged monitors for this option.'
440    fi
441}
442
443main "$@"
444