#!/usr/bin/env python
"""Command runner/laucher with big buttons.

This program brings up a simple window with large buttons that execute commands
specified in an ultra simple config file. It can be seen as a general purpose
launcher for oft-used commands or programs.

Most typically, the config file is the program itself, e.g. ::

    #!/usr/bin/env bigbuts
    demo: python $HOME/project/bin/run-project
    debug: python $HOME/project/bin/run-project --debug --profile
    chklog: tail -n 20 /var/log/project.log

Or as defined as a shell function/macro::

    function bbapache {
       bigbuts <<EOF
    restart: sudo /etc/init.d/apache2 restart
    stop: sudo /etc/init.d/apache2 stop
    start: sudo /etc/init.d/apache2 start
    EOF
    }

You can also run it from a command-line, if you happen to need to repeat a
single command a lot of times::

 echo 'do it: sudo /etc/init.d/apache2 restart' | bigbuts

Features:

- Program output is intermingled with bigbuts output.

- It remembers its location between runs automatically. The geometry is saved in
  a file in the home directory of the user. (It automatically supports the
  geometry for multiple instances.)

- If you need to send an interrupt signal (SIGINT) to the child processes, type
  Ctrl-C in the bigbuts window. Typing it twice in a row will send a SIGKILL
  signal to the children.

- You can rotate the buttons by pressing TAB multiple times.

- Rolling the mouse wheel on the buttons generates whitespace in the output. I
  use this as a marker when I'm debugging code, spacing out sections of log
  output.

- If you kill bigbuts, the children live on.

- If you want to keep the output logs of the children process, you can specify
  an output directory as an option and all the log files will be saved there,
  per-process, under the PID name. This is useful if you're debugging and need
  access to the logs later on.

Note: This is an extremely helpful program, one which I have been using for
years and which has evolved to become one of the most important casual tools in
my toolbox. I run many instances of these on my desktop at all times. Also, I
can't help laughing really hard whenever I say its name around the office:
'Everyone needs bigbuts! We should have bigbuts on everyone's screen!'. I love
it. Many co-workers have adopted it over the years. This is a simple, relatively
mature program. Enjoy! Send me an email if you, like us, are enjoying bigbuts.
"""

__author__ = "Martin Blais <blais@furius.ca>"
__version__ = "2007-10-18"

# stdlib imports
import sys, os, re, string, signal, logging
from os.path import join, realpath, isdir
from subprocess import *

# qt imports
try:
    from PyQt4.Qt import *
except ImportError:
    logging.critical("You need to install PyQt4 to run bigbuts.")
    raise SystemExit

directions = (QBoxLayout.LeftToRight,
              QBoxLayout.BottomToTop,
              QBoxLayout.RightToLeft,
              QBoxLayout.TopToBottom)

children = []

logpfx = '-----(bigbuts)  '
geometry_fn = join(os.environ['HOME'], '.bigbuts_cache')

spacing_lines = 15

class BigButton(QPushButton):

    def __init__(self, name, cmd, parent):
        QPushButton.__init__(self, name, parent)
        self.setSizePolicy(QSizePolicy(QSizePolicy.Expanding,
                                       QSizePolicy.Expanding))
        self.setMinimumSize(1, 1)
        self.setFocusPolicy(Qt.StrongFocus)

        self.name = name
        self.cmd = cmd

        self.connect(self, SIGNAL("clicked()"), self.click)

        self.ecount = 0
        self.last_interrupt_ecount = -1
        self.last_quit_ecount = -1

    def click(self):
        if opts.separators:
            sys.stdout.write('\n\n%s %s:  %s\n' % (logpfx, 'Starting', self.cmd))
        child = spawn_child(self.cmd)
        children.append(child)
        if opts.log:
            sys.stdout.write('\n\n%s %s:  %s\n' % (logpfx, 'Child PID', child.pid))
        sys.stdout.flush()

    def event(self, e):
        if e.type() == QEvent.KeyPress:
            self.keyPressedEvent(e)
        return QPushButton.event(self, e)

    def enterEvent(self, e):
        self.ecount += 1

    def leaveEvent(self, e):
        self.ecount += 1

    def mousePressEvent(self, e):
        self.ecount += 1
        if e.button() == Qt.RightButton:
            self.space_output(spacing_lines)
            e.accept()
        else:
            return QPushButton.mousePressEvent(self, e)

    def wheelEvent(self, e):
        self.space_output(abs(e.delta() / 120))
        e.accept()

    def keyPressedEvent(self, e):
        if e.key() == Qt.Key_C and bool(e.modifiers() & Qt.ControlModifier):
            if self.ecount == self.last_interrupt_ecount:
                interrupt(signal.SIGKILL, 'KILL')
            else:
                self.last_interrupt_ecount = self.ecount
                interrupt(signal.SIGINT, 'INT')

        else:
            self.ecount += 1
            if e.key() == Qt.Key_Q and bool(e.modifiers() & Qt.ControlModifier):
                if self.ecount == self.last_quit_ecount:
                    sys.stdout.write('%s %s\n' % (logpfx, '(Quitting.)'))
                    qApp.quit()
                else:
                    sys.stdout.write('%s %s\n' % (
                        logpfx, "(Welcome to the smoker's club: Quit again to really quit.)"))
                    self.last_quit_ecount = self.ecount

            elif e.key() in (Qt.Key_R,):
                self.parent().rotate()

            elif e.key() in (Qt.Key_Tab,Qt.Key_C, Qt.Key_S):
                self.space_output(spacing_lines)

            elif e.key() in (Qt.Key_D,):
                self.dump_config()

    def space_output(self, nlines):
        sys.stdout.write('\n' * nlines)

    def dump_config(self):
        print '#!/usr/bin/env bigbuts'
        for name, cmd in commands:
            print name, ':', cmd


class Window(QWidget):

    updating = False

    maxPixelSize = 48

    def resizeEvent(self, e):
        if not self.updating:
            write_geometry(*self.getgeom())
        self.resetFonts()
        return QWidget.resizeEvent(self,e)

    def resetFonts(self):
        "Find a font that fits in the given button sizes."
        afont = qApp.font()

        # Try to guess the largest font that can be accomomdate by any of the
        # children (all fonts should be the same size).
        pixelSize = 100000
        for child in self.children():
            if isinstance(child, BigButton):
                s = child.size()
                ms = QSize(s.width()-10, s.height()-10)
                font, _ = getfont(opts.font_family, ms, child.text(), afont)
                if font is not None:
                    pixelSize = min(pixelSize, font.pixelSize())

        afont.setPixelSize(min(pixelSize, self.maxPixelSize))
        qApp.setFont(afont)

    def moveEvent(self, e):
        if self.updating:
            return
        write_geometry(*self.getgeom())

    def rotate(self):
        "Rotate clock-wise."
        (w, h, x, y, ori) = self.getgeom()
        self.setgeom((w, h, x, y, (ori+1)%4))
        write_geometry(*self.getgeom())
        qApp.processEvents()
        self.resetFonts()

    def getgeom(self):
        "Return the current persistable geometry of the window."
        r = self.geometry()
        return (r.width(), r.height(), r.x(), r.y(), self.layout().direction())

    def setgeom(self, (w, h, x, y, ori)):
        "Set the geometry of this window."

        self.updating = True
        # Remove old layout and create a newone.
        old_layout = self.layout()
        oleft, otop, oright, obottom = old_layout.getContentsMargins()
        ospacing = old_layout.spacing()
        if old_layout is not None:
            import sip; sip.delete(old_layout)

        layout = QBoxLayout(QBoxLayout.Direction(ori), self)
        layout.setSpacing(ospacing)
        ## layout.setContentsMargins(oleft, otop, oright, obottom)
        for button in self.findChildren(BigButton):
            layout.addWidget(button)

        # Move and resize the window to its rightful place.
        r = QRect(x, y, w, h)
        if r != self.geometry():
            self.setGeometry(r)

        self.updating = False


def read_geometry():
    try:
        f = open(geometry_fn, 'r')
        geomap = dict(line.split() for line in f)
        f.close()
    except IOError:
        geomap = {}
    return geomap

def write_geometry(w, h, x, y, ori):
    geom = '%dx%d+%d+%d,%d' % (w, h, x, y, ori)
    geomap = read_geometry()
    geomap[bigbuts_script] = geom
    f = open(geometry_fn, 'w')
    for fn, geom in geomap.iteritems():
        f.write('%s %s\n' % (fn, geom))
    f.close()

def restore_geometry(window):
    """Read the geometry from the geometry cache file and restore it on the
    given window."""
    try:
        geomap = read_geometry()
        geom = geomap.get(bigbuts_script, None)
        if geom is not None:
            mo = re.match('([0-9]+)x([0-9]+)\\+([0-9]+)\\+([0-9]+),([0123])', geom)
            assert mo
            geom = map(int, mo.group(1, 2, 3, 4, 5))
            window.setgeom(geom)
    except AssertionError:
        pass


def read_commands(fn):
    """Read the configuration file that describes the commands to have buttons
    created for."""

    empre = re.compile('^\s*$')
    comre = re.compile('^#')
    global commands; commands = []

    try:
        if fn is None or fn == '-':
            f = sys.stdin
        else:
            f = open(fn, 'r')
        for line in map(string.strip, f.readlines()):
            if empre.match(line) or comre.match(line):
                continue
            try:
                idx = line.find(':')
                if idx == -1:
                    name = re.split('[ \t]', line)[0]
                    cmd = line
                else:
                    name = string.strip(line[:idx])
                    cmd = string.strip(line[idx+1:])
                commands.append( (name, cmd) )
            except ValueError:
                print >> sys.stderr, "Error: in config file"
                print >> sys.stderr, "missing semicolon on line '%s'" % line

    except IOError, e:
        print >> sys.stderr, "Error: error reading file", fn
        sys.exit(1)

    return commands

def create_gui():
    "Create and display the GUI."

    app = QApplication(sys.argv[0:1])
    app.setApplicationName('Big Buts')
    font = QFont()
    font.setBold(True)
    app.setFont(font)

    parent = Window()
    parent.setWindowTitle('Big Buts')
    parent.setWindowIconText('Big Buts')
    parent.setObjectName('bigbuts')

    layout = QBoxLayout(QBoxLayout.LeftToRight, parent)
    layout.setSpacing(2)
    ## layout.setContentsMargins(2, 2, 2, 2)
    for i in commands:
        (name, cmd) = i
        button = BigButton(name, cmd, parent)
        layout.addWidget(button)

    return app, parent

def getfont(family, size, text, protofont):
    """Given a font family name and a rectangle, find a font that will fit the
    given text within the rectangle. Note: this is reasonably fast, and
    typically will iterate no more than 2-3 times.

    'size' should be a QSize or a QRect, or an object that supports width() and
    height()."""
    pxsize = size.height()
    font = QFont(protofont)
    pxsize_prev = None
    while pxsize > 1 and pxsize != pxsize_prev:
        font.setPixelSize(pxsize)
        fm = QFontMetrics(font)
        r = fm.boundingRect(text)
        if r.width() <= size.width():
            return font, r
        pxsize_prev = pxsize
        pxsize *= (size.width() / float(r.width()))
    return None, None


def spawn_child(cmd):
    """
    Spawn a child process.
    """
    # Note: setpgid makes it so that the children outlive the bigbuts
    # instance.
    kw = {}
    if opts.log:
        kw['stdout'] = PIPE
        kw['stderr'] = PIPE

    p = Popen(cmd,
              shell=True,
              preexec_fn=lambda: os.setpgid(0, 0), **kw)

    if opts.log:
        outfn = join(opts.log, '%d.stdout' % p.pid)
        tee = Popen(('/usr/bin/tee', outfn), shell=False, stdin=p.stdout)

        errfn = join(opts.log, '%d.stderr' % p.pid)
        tee = Popen(('/usr/bin/tee', errfn), shell=False, stdin=p.stderr)

    return p

def main():
    import optparse
    class NopHelpFormatter(optparse.IndentedHelpFormatter):
        def _format_text(self, text): return text
    parser = optparse.OptionParser(description=__doc__,
                                   formatter=NopHelpFormatter(),
                                   version=__version__)

    parser.add_option('-g', '--no-geometry', '--ignore-geomtry',
                      dest='restore_geometry', action='store_false', default=True,
                      help="Ignore the previously saved geometry (ignore the size cache).")

    parser.add_option('-l', '--log', '--log-directory', action='store',
                      metavar='DIRECTORY',
                      help="Log the outputs of the children processes in "
                      "the given directory.")

    parser.add_option('-c', '--console', action='store_true',
                      help="Do not show buttons; run from the console instead.")

    # Hidden options, I never used them, and no-one else I know does either, so
    # we make them hidden. Remove them at some point.
    parser.add_option('-s', '--no-separators',
                      dest='separators', default=True, action='store_false',
                      help=optparse.SUPPRESS_HELP)
                      ##"Don't insert separators between program runs in the shell output."

    parser.add_option('-f', '--font-family', action='store',
                      default="Helvetica",
                      help=optparse.SUPPRESS_HELP)

    global opts; opts, args = parser.parse_args()

    # Create the logs output directory if needed.
    if opts.log and not isdir(opts.log):
        try:
            os.makedirs(opts.log)
        except OSError:
            parser.error("Cannot open or create log directory '%s'." % opts.log)

    if len(args) > 1:
       parser.error("Bigbuts accepts an optional filename as input.")
    if args:
        fn = args[0]
    else:
        fn = None

    # Compute a name for the script itself.
    global bigbuts_script
    bigbuts_script = realpath(fn or __file__)

    # Read the configuration file and create the interface.
    commands = read_commands(fn)

    if not opts.console:
        # Create a GUI application.
        app, parent = create_gui()

        # Restore the previous size of the window.
        if opts.restore_geometry:
            restore_geometry(parent)

        parent.show()

        # Allow the app to be interrupted once we enter the event loop.
        signal.signal(signal.SIGINT, signal.SIG_DFL) ## sigint_handler

        # Capture when a child dies.
        signal.signal(signal.SIGCHLD, sigchld_handler)

        # Setup a bogus idle timer just to release the GIL periodically,
        # allowing the SIGCHLD handler to actually run when there are no events.
        if idleTimerPeriod:
            global idleTimer
            idleTimer = QTimer()
            QObject.connect(idleTimer, SIGNAL('timeout()'), lambda: None)
            idleTimer.start(idleTimerPeriod)

        app.exec_()
    else:
        # Create a console application.
        run_console(commands)


int_interrupts = 0

def sigint_handler(signo, frame):
    global int_interrupts; int_interrupts += 1
    if int_interrupts >= 2:
        qApp.quit()


def run_console(commands):
    """Run a command from the console. This is merely a way to reuse the bigbuts
    commands definitions."""
    while 1:
        # Print out the list of choices.
        nextletter = iter(string.digits[1:] + string.ascii_lowercase).next
        choices = {}
        for name, cmd in commands:
            l = nextletter()
            choices[l] = cmd
            print '%3s. %s ' % (l, name)

        # Read the user's selection.
        response = raw_input()
        if response == '':
            continue
        try:
            cmd = choices[response]
            break
        except KeyError:
            print 'Invalid choice. Try again.'

    if cmd is not None:
        p = spawn_child(cmd)
        p.wait()


def interrupt(signo, signame):
    """Send an interrupt all the running children, if we're still waiting on
    them."""

    for child in list(children):
        try:
            os.killpg(child.pid, signo)
        except OSError:
            sys.stdout.write('%s Could not interrupt process: %d\n' %
                             (logpfx, child.pid))
        else:
            sys.stdout.write('%s Sending %s to process: %d\n' %
                             (logpfx, signame, child.pid))
    sys.stdout.flush()
        
    if signo == signal.SIGKILL:
        children[:] = []

# Idle timer that allows SIGCHLD handlers to run.
idleTimer = None
idleTimerPeriod = 1500

def sigchld_handler(signo, frame):
    "Handler for children that exit."
    for child in list(children):
        code = child.poll()
        if code is not None:
            children.remove(child)
            if opts.log or 1:
                sys.stdout.write('%s Child %s exited with status: %d\n' %
                                 (logpfx, child.pid, code))
                sys.stdout.flush()

if __name__ == "__main__":
    main()


# TODO / IDEAS
# ============
#
# - Store the list of processes per-button, and ^C should send the signals ouly to
#   those processes for which the mouse is over the given button.
# - Pressing 1, 2, 3 should produce some big separator on the output, i.e., placing
#   marks for reference.
