Rewrite graphical tools in Python.
authorMark Wooding <mdw@distorted.org.uk>
Sun, 23 Mar 2008 16:06:36 +0000 (16:06 +0000)
committerMark Wooding <mdw@distorted.org.uk>
Sun, 23 Mar 2008 16:06:36 +0000 (16:06 +0000)
13 files changed:
.links
Makefile.am
configure.ac
debian/control
debian/rules
debian/xtoys-gtk.install [new file with mode: 0644]
xcatch.1
xcatch.in [new file with mode: 0644]
xgetline.1
xgetline.in [new file with mode: 0644]
xmsg.1
xmsg.in [new file with mode: 0644]
xtoys.py [new file with mode: 0644]

diff --git a/.links b/.links
index 5ecd9c6..dd8b261 100644 (file)
--- a/.links
+++ b/.links
@@ -1 +1,2 @@
 COPYING
+config/confsubst
index 14d29ed..ffdd407 100644 (file)
@@ -29,6 +29,9 @@ dist_man_MANS          =
 EXTRA_DIST              =
 CLEANFILES              =
 
+confsubst = $(srcdir)/config/confsubst
+EXTRA_DIST             += config/confsubst
+
 ###--------------------------------------------------------------------------
 ### Distribution arrangements.
 
@@ -57,6 +60,57 @@ xatom_SOURCES                += xatom.c
 xatom_SOURCES          += libxatom.h libxatom.c
 
 ###--------------------------------------------------------------------------
+### Graphical tools in Python.
+
+if HAVE_PYGTK
+
+python_PYTHON           =
+
+## Common code.
+python_PYTHON          += xtoys.py
+
+## xmsg
+bin_SCRIPTS            += xmsg
+CLEANFILES             += xmsg
+EXTRA_DIST             += xmsg.in
+
+dist_man_MANS          += xmsg.1
+
+xmsg: xmsg.in Makefile
+       $(confsubst) $(srcdir)/xmsg.in >$@.new \
+               PYTHON=$(PYTHON) VERSION=$(VERSION)
+       chmod +x $@.new
+       mv $@.new $@
+
+## xcatch
+bin_SCRIPTS            += xcatch
+CLEANFILES             += xcatch
+EXTRA_DIST             += xcatch.in
+
+dist_man_MANS          += xcatch.1
+
+xcatch: xcatch.in Makefile
+       $(confsubst) $(srcdir)/xcatch.in >$@.new \
+               PYTHON=$(PYTHON) VERSION=$(VERSION)
+       chmod +x $@.new
+       mv $@.new $@
+
+## xgetline
+bin_SCRIPTS            += xgetline
+CLEANFILES             += xgetline
+EXTRA_DIST             += xgetline.in
+
+dist_man_MANS          += xgetline.1
+
+xgetline: xgetline.in Makefile
+       $(confsubst) $(srcdir)/xgetline.in >$@.new \
+               PYTHON=$(PYTHON) VERSION=$(VERSION)
+       chmod +x $@.new
+       mv $@.new $@
+
+endif
+
+###--------------------------------------------------------------------------
 ### Debian.
 
 EXTRA_DIST             += debian/rules
@@ -66,4 +120,6 @@ EXTRA_DIST           += debian/changelog
 
 EXTRA_DIST             += debian/xtoys.install
 
+EXTRA_DIST             += debian/xtoys-gtk.install
+
 ###----- That's all, folks --------------------------------------------------
index 8e580c9..e338570 100644 (file)
@@ -44,6 +44,17 @@ CFLAGS="$CFLAGS $mLib_CFLAGS"
 LIBS="$LIBS $mLib_LIBS"
 
 dnl--------------------------------------------------------------------------
+dnl Python programming environment.
+
+AM_PATH_PYTHON([2.4], [python=yes], [python=no])
+AM_CONDITIONAL([HAVE_PYTHON], [test $python = yes])
+
+if test $python = yes; then
+  AC_PYTHON_MODULE([pygtk])
+fi
+AM_CONDITIONAL([HAVE_PYGTK], [test ${HAVE_PYMOD_PYGTK-no} = yes])
+
+dnl--------------------------------------------------------------------------
 dnl Output.
 
 AC_CONFIG_FILES(
index db4fd1b..139b25a 100644 (file)
@@ -1,8 +1,11 @@
 Source: xtoys
 Section: x11
 Priority: extra
-Build-Depends: libx11-dev, mlib-dev (>= 2.0.4), debhelper (>= 5)
+Build-Depends:
+       libx11-dev, mlib-dev (>= 2.0.4), debhelper (>= 5), 
+       python-central, python (>= 2.4), python-gtk2
 Maintainer: Mark Wooding <mdw@distorted.org.uk>
+XS-Python-Version: >= 2.4
 Standards-Version: 3.1.1
 
 Package: xtoys
@@ -12,3 +15,18 @@ Description: A collection of small X11 tools
  We have:
  xscsize -- reports the display size as shell variables
  xatom -- inspect properties on windows, and wait for changes
+
+Package: xtoys-gtk
+Architecture: all
+Depends: ${python:Depends}, python-gtk2 (>= 2.10)
+Recommends: xtoys
+XB-Python-Version: ${python:Versions}
+Description: A collection of small X11 tools
+ We have:
+ xcatch -- run a program, displaying its output in a scrolling window if
+   there is any
+ xgetline -- pops up a dialogue, reads a line of text, and reports the 
+   entered line on stdout; can do passwords, history with optional size
+   limit, or simple selection from a list
+ xmsg -- pop up a dialogue showing a message and a bunch of buttons, and
+   gets a response
index 1ee41f9..7aa6821 100755 (executable)
@@ -5,4 +5,6 @@ include $(CDBS)/rules/debhelper.mk
 include $(CDBS)/class/autotools.mk
 
 DEB_BUILDDIR = $(CURDIR)/build
-DEB_DESTDIR = $(CURDIR)/debian/tmp
+
+binary-install/xtoys-gtk::
+       dh_pycentral -pxtoys-gtk
diff --git a/debian/xtoys-gtk.install b/debian/xtoys-gtk.install
new file mode 100644 (file)
index 0000000..4f6fd0f
--- /dev/null
@@ -0,0 +1,4 @@
+debian/tmp/usr/bin/xcatch
+debian/tmp/usr/bin/xgetline
+debian/tmp/usr/bin/xmsg
+debian/tmp/usr/lib/python*
index 34bb62b..5c20670 100644 (file)
--- a/xcatch.1
+++ b/xcatch.1
@@ -4,10 +4,11 @@
 xcatch \- catch input and trap it in a window
 .SH SYNOPSIS
 .B xcatch
-.RB [ \-f
-.IR file ]
+.RI [ gtk-options ...]
 .RB [ \-F
 .IR font ]
+.RB [ \-f
+.IR file ]
 .RI [ command
 .RI [ args ]...]
 .SH DESCRIPTION
@@ -26,22 +27,39 @@ redirected to
 otherwise,
 .B xcatch
 reads from its own standard input.
+.PP
+It is better to write
+.IP
+.B xcatch 
+.I command args
+.PP
+than
+.IP
+.I command args
+.B | xcatch
+.PP
+The former allows
+.B xcatch
+to collect the exit status of the
+.I command
+and (a) report it in its window, and (b) provide it (or a version of it)
+as its own exit status for the caller to check.  It also enables
+.B xcatch
+to put the command line in its title bar.
 .SS Options
 .TP
-.BI "\-\-display " display
-Create the window on the named
-.IR display .
+.BI "\-F, \-\-font " font
+Display output text in 
+.I font
+rather than GTK's default.
 .TP
 .BI "\-f, \-\-file " file
 Read data from
 .I file
 instead of standard output.
-.TP
-.BI "\-F, \-\-font " font
-Display output text in 
-.I font
-rather than GTK's default (Helvetica).
 .SH BUGS
 None currently known.
+.SH SEE ALSO
+.BR gtk-options (7).
 .SH AUTHOR
 Mark Wooding (mdw@distorted.org.uk).
diff --git a/xcatch.in b/xcatch.in
new file mode 100644 (file)
index 0000000..0f49a80
--- /dev/null
+++ b/xcatch.in
@@ -0,0 +1,428 @@
+#! @PYTHON@
+### -*-python-*-
+###
+### Catch input and trap it in an X window
+###
+### (c) 2008 Straylight/Edgeware
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of the Edgeware X tools collection.
+###
+### X tools is free software; you can redistribute it and/or modify
+### it under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 2 of the License, or
+### (at your option) any later version.
+###
+### X tools is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU General Public License for more details.
+###
+### You should have received a copy of the GNU General Public License
+### along with X tools; if not, write to the Free Software Foundation,
+### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+VERSION = '@VERSION@'
+
+###--------------------------------------------------------------------------
+### External dependencies.
+
+import optparse as O
+from sys import stdin, stdout, stderr, exit
+import sys as SYS
+import os as OS
+import fcntl as FC
+import errno as E
+import subprocess as S
+import pango as P
+import signal as SIG
+import traceback as TB
+
+import xtoys as XT
+GTK, GDK, GO = XT.GTK, XT.GDK, XT.GO
+
+###--------------------------------------------------------------------------
+### Utilities.
+
+def nonblocking(file):
+  """
+  Make the FILE be nonblocking.
+
+  FILE may be either an integer file descriptor, or something whose fileno
+  method yields up a file descriptor.
+  """
+  if isinstance(file, int):
+    fd = file
+  else:
+    fd = file.fileno()
+  flags = FC.fcntl(fd, FC.F_GETFL)
+  FC.fcntl(fd, FC.F_SETFL, flags | OS.O_NONBLOCK)
+
+class complain (object):
+  """
+  Function decorator: catch exceptions and report them in an error box.
+
+  Example:
+
+          @complain(DEFAULT)
+          def foo(...):
+            ...
+
+  The decorated function is called normally.  If no exception occurs (or at
+  least none propagates out of the function) then it returns normally too.
+  Otherwise, the exception is trapped and displayed in a Message window, and
+  the function returns DEFAULT, which defaults to None.
+  """
+
+  def __init__(me, default = None):
+    """Initializer: store the DEFAULT value for later."""
+    me._default = default
+
+  def __call__(me, func):
+    """Decorate the function."""
+    def _(*args, **kw):
+      try:
+        return func(*args, **kw)
+      except:
+        type, info, _ = SYS.exc_info()
+        if isinstance(type, str):
+          head = 'Unexpected exception'
+          msg = type
+        else:
+          head = type.__name__
+          msg = ', '.join(info.args)
+        XT.Message(title = 'Error!', type = 'error', headline = head,
+                   buttons = ['gtk-ok'], message = msg).ask()
+        return me._default
+    return _
+
+###--------------------------------------------------------------------------
+### Watching processes.
+
+class Reaper (object):
+  """
+  The Reaper catches SIGCHLD and collects exit statuses.
+
+  There should ideally be only one instance of the class; the reaper method
+  returns the instance, creating it if necessary.  (The reaper uses up
+  resources which we can avoid wasting under some circumstances.)
+
+  Call add(KID, FUNC) to watch process-id KID; when it exits, the reaper
+  calls FUNC(KID, STATUS), where STATUS is the exit status directly from
+  wait.
+
+  Even though SIGCHLD occurs at unpredictable times, processes are not reaped
+  until we return to the GTK event loop.  This means that you can safely
+  create processes and add them without needing to interlock with the reaper
+  in complicated ways, and it also means that handler functions are not
+  called at unpredictable times.
+  """
+
+  def __init__(me):
+    """
+    Initialize the reaper.
+
+    We create a pipe and register the read end of it with the GTK event
+    system.  The SIGCHLD handler writes a byte to the pipe.
+    """
+    me._kidmap = {}
+    me._prd, pwr = OS.pipe()
+    nonblocking(me._prd)
+    SIG.signal(SIG.SIGCHLD, lambda sig, tb: OS.write(pwr, '?'))
+    GO.io_add_watch(me._prd, GO.IO_IN | GO.IO_HUP, me._wake)
+
+  _reaper = None
+  @classmethod
+  def reaper(cls):
+    """Return the instance of the Reaper, creating it if necessary."""
+    if cls._reaper is None:
+      cls._reaper = cls()
+    return cls._reaper
+
+  def add(me, kid, func):
+    """
+    Register the process-id KID with the reaper, calling FUNC when it exits.
+
+    As described, FUNC is called with two arguments, the KID and its exit
+    status.
+    """
+    me._kidmap[kid] = func
+
+  def _wake(me, file, reason):
+    """
+    Called when the event loop notices something in the signal pipe.
+
+    We empty the pipe and then reap any processes which need it.
+    """
+
+    ## Empty the pipe.  It doesn't matter how many bytes are stored in the
+    ## pipe, or what their contents are.
+    try:
+      while True:
+        OS.read(me._prd, 16384)
+    except OSError, err:
+      if err.errno != E.EAGAIN:
+        raise
+
+    ## Reap processes and pass their exit statuses on.
+    while True:
+      try:
+        kid, st = OS.waitpid(-1, OS.WNOHANG)
+      except OSError, err:
+        if err.errno == E.ECHILD:
+          break
+        else:
+          raise
+      if kid == 0:
+        break
+      try:
+        func = me._kidmap[kid]
+        del me._kidmap[kid]
+      except KeyError:
+        continue
+      func(kid, st)
+
+    ## Done: call me again.
+    return True
+
+###--------------------------------------------------------------------------
+### Catching and displaying output.
+
+class Catcher (object):
+  """
+  Catcher objects watch an input file and display the results in a window.
+
+  Initialization is a little cumbersome.  You make an object, and then add a
+  file and maybe a process-id to watch.  The catcher will not create a window
+  until it actually needs to display something.
+
+  The object can be configured by setting attributes before it first opens
+  its window.
+
+    * title: is the title for the window
+    * font: is the font to display text in, as a string
+
+  The rc attribute is set to a suitable exit status.
+  """
+
+  def __init__(me):
+    """Initialize the catcher."""
+    me.title = 'xcatch'
+    me._file = None
+    me._window = None
+    me._buf = None
+    me.font = None
+    me._openp = False
+    me.rc = 0
+
+  def watch_file(me, file):
+    """
+    Watch the FILE for input.
+
+    Any data arriving for the FILE is recoded into UTF-8 and displayed in the
+    window.  The file not reaching EOF is considered a reason not to end the
+    program.
+    """
+    XT.addreason()
+    nonblocking(file)
+    me._src = GO.io_add_watch(file, GO.IO_IN | GO.IO_HUP, me._ready)
+    me._file = file
+
+  def watch_kid(me, kid):
+    """
+    Watch the process-id KID for exit.
+
+    If the child dies abnormally then a message is written to the window.
+    """
+    XT.addreason()
+    Reaper.reaper().add(kid, me._exit)
+
+  def make_window(me):
+    """
+    Construct the output window if necessary.
+
+    Having the window open is a reason to continue.
+    """
+
+    ## If the window exists, just make sure it's visible.
+    if me._window is not None:
+      if me._openp == False:
+        me._window.present()
+        XT.addreason()
+        me._openp = True
+      return
+
+    ## Make the buffer.
+    buf = GTK.TextBuffer()
+    me._deftag = buf.create_tag('default')
+    if me.font is not None:
+      me._deftag.set_properties(font = me.font)
+    me._exittag = \
+      buf.create_tag('exit', style_set = True, style = P.STYLE_ITALIC)
+    me._buf = buf
+
+    ## Make the window.
+    win = GTK.Window(GTK.WINDOW_TOPLEVEL)
+    win.set_title(me.title)
+    win.connect('delete-event', me._delete)
+    win.connect('key-press-event', me._keypress)
+    view = GTK.TextView(buf)
+    view.set_editable(False)
+    scr = GTK.ScrolledWindow()
+    scr.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
+    scr.set_shadow_type(GTK.SHADOW_IN)
+    scr.add(view)
+    win.set_default_size(480, 200)
+    win.add(scr)
+
+    ## All done.
+    win.show_all()
+    XT.addreason()
+    me._openp = True
+    me._window = win
+
+  def _keypress(me, win, event):
+    """
+    Handle a keypress on the window.
+
+    Escape or Q will close the window.
+    """
+    key = GDK.keyval_name(event.keyval)
+    if key in ['Escape', 'q', 'Q']:
+      me._delete()
+      return True
+    return False
+
+  def _delete(me, *_):
+    """
+    Handle a close request on the window.
+
+    Closing the window removes a reason to continue.
+    """
+    me._window.hide()
+    XT.delreason()
+    me._openp = False
+
+  @complain(True)
+  def _ready(me, file, *_):
+    """
+    Process input arriving on the FILE.
+    """
+    try:
+      buf = file.read(16384)
+    except IOError, err:
+      if err.errno == E.EAGAIN:
+        return True
+      me._close()
+      me.rc = 127
+      raise
+    if buf == '':
+      me._close()
+      return True
+    me.make_window()
+    uni = buf.decode(SYS.getdefaultencoding(), 'replace')
+    utf8 = uni.encode('utf-8')
+    end = me._buf.get_end_iter()
+    me._buf.insert_with_tags(end, utf8, me._deftag)
+    return True
+
+  def _close(me):
+    """
+    Close the input file.
+    """
+    XT.delreason()
+    GO.source_remove(me._src)
+    me._file = None
+
+  def _exit(me, kid, st):
+    """
+    Handle the child process exiting.
+    """
+    if st == 0:
+      XT.delreason()
+      return
+    me.make_window()
+    XT.delreason()
+    end = me._buf.get_end_iter()
+    if not end.starts_line():
+      me._buf.insert(end, '\n')
+    if OS.WIFEXITED(st):
+      msg = 'exited with status %d' % OS.WEXITSTATUS(st)
+      me.rc = OS.WEXITSTATUS(st)
+    elif OS.WIFSIGNALED(st):
+      msg = 'killed by signal %d' % OS.WTERMSIG(st)
+      me.rc = OS.WTERMSIG(st) | 128
+      if OS.WCOREDUMP(st):
+        msg += ' (core dumped)'
+    else:
+      msg = 'exited with unknown code 0x%x' % st
+      me.rc = 255
+    me._buf.insert_with_tags(end, '\n[%s]\n' % msg,
+                             me._deftag, me._exittag)
+
+###--------------------------------------------------------------------------
+### Option parsing.
+
+def parse_args():
+  """
+  Parse the command line, returning a triple (PARSER, OPTS, ARGS).
+  """
+
+  op = XT.make_optparse \
+       ([('f', 'file',
+          {'dest': 'file',
+           'help': "Read input from FILE."}),
+         ('F', 'font',
+          {'dest': 'font',
+           'help': "Display output using FONT."})],
+        version = VERSION,
+        usage = '%prog [-f FILE] [-F FONT] [COMMAND [ARGS...]]')
+
+  op.set_defaults(file = None,
+                  font = 'monospace')
+
+  opts, args = op.parse_args()
+  if len(args) > 0 and opts.file is not None:
+    op.error("Can't read from a file and a command simultaneously.")
+  return op, opts, args
+
+###--------------------------------------------------------------------------
+### Main program.
+
+def main():
+
+  ## Check options.
+  op, opts, args = parse_args()
+
+  ## Set up the file to read from.
+  catcher = Catcher()
+  if opts.file is not None:
+    if opts.file == '-':
+      name = '<stdin>'
+      catcher.watch_file(stdin)
+    else:
+      name = file
+      catcher.watch(open(opts.file, 'r'))
+  elif len(args) == 0:
+    name = '<stdin>'
+    catcher.watch_file(stdin)
+  else:
+    name = ' '.join(args)
+    Reaper.reaper()
+    proc = S.Popen(args, stdout = S.PIPE, stderr = S.STDOUT)
+    catcher.watch_file(proc.stdout)
+    catcher.watch_kid(proc.pid)
+
+  catcher.title = 'xcatch: ' + name
+  catcher.font = opts.font
+
+  ## Let things run their course.
+  GTK.main()
+  exit(catcher.rc)
+
+if __name__ == '__main__':
+  main()
+
+###----- That's all, folks --------------------------------------------------
index caec17c..22757db 100644 (file)
@@ -4,17 +4,18 @@
 xgetline \- request a line of text in an X dialogue box
 .SH SYNOPSIS
 .B xgetline
-.RB [ -in ]
-.RB [ \-t
-.IR title ]
-.RB [ \-p
-.IR prompt ]
+.RI [ gtk-options ...]
+.RB [ -Hin ]
+.RB [ \- M
+.IR max ]
 .RB [ \-d
 .IR default ]
-.RB [ \-l | \-H
+.RB [ \-l
 .IR file ]
-.RB [ \- m
-.IR max ]
+.RB [ \-p
+.IR prompt ]
+.RB [ \-t
+.IR title ]
 .SH DESCRIPTION
 The
 .B xgetline
@@ -43,26 +44,24 @@ shell command attached to a hotkey:
 .RS 5
 .ft B
 .nf
-cmd=`xgetline -t "Shell command in window" -p "Command:"` &&
+cmd=$(xgetline -t "Shell command in window" -p "Command:") &&
   xterm -T "$cmd" -e sh -c "$cmd"
 .ft R
 .fi
 .SS Options
 .TP 5
-.B \-i, \-\-invisible
-Don't echo characters to the screen when they're typed.  Useful when
-requesting passwords and similar secrets.
+.B "\-H, \-\-history"
+With
+.BR \-l ,
+update the file with the newly entered line at the top.  Other lines
+matching the newly entered string are not written.  No effect without
+.BR \-l .
 .TP 5
-.BI "\-t, \-\-title " title
-Sets the title of the dialogue box to
-.IR title .
-The default title is
-.RB ` "Input request" '.
-.TP 5
-.BI "\-p, \-\-prompt " prompt
-Sets the prompt string in the dialogue box to
-.IR prompt .
-The default is to have no prompt string.
+.BI "\-M, \-\-histmax " max
+When writing an updated history file, do not write more than
+.I max
+lines.  The default is 20; a value of 0 disables a length limit on the
+history file.
 .TP 5
 .BI "\-d, \-\-default " default
 Sets the default text in the entry field to
@@ -73,6 +72,10 @@ default string
 sets the default to be the first item in the history list, if one is
 supplied.
 .TP 5
+.B \-i, \-\-invisible
+Don't echo characters to the screen when they're typed.  Useful when
+requesting passwords and similar secrets.
+.TP 5
 .BI "\-l, \-\-list " file
 Reads a list of alternatives from
 .I file
@@ -86,20 +89,19 @@ option below).
 One of the items from the selection list must be chosen; the user may
 not type an entry in directly.
 .TP 5
-.BI "\-H, \-\-history " file
-Reads a file and displays the contents in a drop-down list, as for
-.B \-\-list
-above.  Once the user has entered a string, a new list written to 
-.I file
-containing the newly entered string as the first item; other lines
-matching the newly entered string are not written.
+.BI "\-p, \-\-prompt " prompt
+Sets the prompt string in the dialogue box to
+.IR prompt .
+The default is to have no prompt string.
 .TP 5
-.BI "\-m, \-\-histmax " max
-When writing an updated history file, do not write more than
-.I max
-lines.  The default is 20; a value of 0 disables a length limit on the
-history file.
+.BI "\-t, \-\-title " title
+Sets the title of the dialogue box to
+.IR title .
+The default title is
+.RB ` "Input request" '.
 .SH BUGS
 Hopefully none.
+.SH SEE ALSO
+.BR gtk-options (7).
 .SH AUTHOR
 Mark Wooding (mdw@distorted.org.uk).
diff --git a/xgetline.in b/xgetline.in
new file mode 100644 (file)
index 0000000..8f649d5
--- /dev/null
@@ -0,0 +1,358 @@
+#! @PYTHON@
+### -*-python-*-
+###
+### Utility module for xtoys Python programs
+###
+### (c) 2007 Straylight/Edgeware
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of the Edgeware XT tools collection.
+###
+### XT tools is free software; you can redistribute it and/or modify
+### it under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 2 of the License, or
+### (at your option) any later version.
+###
+### XT tools is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU General Public License for more details.
+###
+### You should have received a copy of the GNU General Public License
+### along with XT tools; if not, write to the Free Software Foundation,
+### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+VERSION = '@VERSION@'
+
+###--------------------------------------------------------------------------
+### External dependencies.
+
+import optparse as O
+from sys import stdout, stderr, exit
+import os as OS
+import errno as E
+
+import xtoys as XT
+GTK, GDK, GO = XT.GTK, XT.GDK, XT.GO
+
+###--------------------------------------------------------------------------
+### Entry classes.
+
+### These package up the mess involved with the different kinds of dialogue
+### box xgetline can show.  The common interface is informal, but looks like
+### this:
+###
+### ready(): prepare the widget for action
+### value(): extract the value the user entered
+### setvalue(VALUE): show VALUE as the existing value in the widget
+### sethistory(HISTORY): store the HISTORY in the widget's history list
+### gethistory(): extract the history list back out again
+
+def setup_entry(entry):
+  """Standard things to do to an entry widget."""
+  entry.grab_focus()
+  entry.set_activates_default(True)
+
+class SimpleEntry (GTK.Entry):
+  """A plain old Entry widget with no bells or whistles."""
+  def setvalue(me, value):
+    me.set_text(value)
+  def ready(me):
+    setup_entry(me)
+  def value(me):
+    return me.get_text()
+  def gethistory(me):
+    return ()
+
+class ModelMixin (object):
+  """
+  A helper mixin for classes which make use of a TreeModel.
+
+  It provides the sethistory and gethistory methods for the common widget
+  interface, and can produce a CellRenderer for stuffing into the viewer
+  widget, whatever that might be.
+  """
+
+  def __init__(me):
+    """Initialize the ModelMixin."""
+    me.model = GTK.ListStore(GO.TYPE_STRING)
+    me.set_model(me.model)
+
+  def setlayout(me):
+    """Insert a CellRenderer for displaying history items into the widget."""
+    cell = GTK.CellRendererText()
+    me.pack_start(cell)
+    me.set_attributes(cell, text = 0)
+
+  def sethistory(me, history):
+    """Write the HISTORY into the model."""
+    for line in history:
+      me.model.append([line])
+
+  def gethistory(me):
+    """Extract the history from the model."""
+    return (l[0] for l in me.model)
+
+class Combo (ModelMixin, GTK.ComboBox):
+  """
+  A widget which uses a non-editable Combo box for entry.
+  """
+
+  def __init__(me):
+    """Initialize the widget."""
+    GTK.ComboBox.__init__(me)
+    ModelMixin.__init__(me)
+    me.setlayout()
+
+  def sethistory(me, history):
+    """
+    Insert the HISTORY.
+
+    We have to select some item, so it might as well be the first one.
+    """
+    ModelMixin.sethistory(me, history)
+    me.set_active(0)
+
+  def ready(me):
+    """Nothing special needed to make us ready."""
+    pass
+
+  def setvalue(me, value):
+    """
+    Store a value in the widget.
+
+    This involves finding it in the list and setting it by index.  I suppose
+    I could keep a dictionary, but it seems bad to have so many copies.
+    """
+    for i in xrange(len(me.model)):
+      if me.model[i][0] == value:
+        me.set_active(i)
+
+  def value(me):
+    """Extract the current selection."""
+    return me.model[me.get_active()][0]
+
+class ComboEntry (ModelMixin, GTK.ComboBoxEntry):
+  """
+  A widget which uses an editable combo box.
+  """
+
+  def __init__(me):
+    """
+    Initialize the widget.
+    """
+    GTK.ComboBoxEntry.__init__(me)
+    ModelMixin.__init__(me)
+    me.set_text_column(0)
+
+  def ready(me):
+    """
+    Set up the entry widget.
+
+    We grab the arrow keys to step through the history.
+    """
+    setup_entry(me.child)
+    me.child.connect('key-press-event', me.press)
+
+  def press(me, _, event):
+    """
+    Handle key-press events.
+
+    Specifically, up and down to move through the history.
+    """
+    if GDK.keyval_name(event.keyval) in ('Up', 'Down'):
+      me.popup()
+      return True
+    return False
+
+  def setvalue(me, value):
+    me.child.set_text(value)
+  def value(me):
+    return me.child.get_text()
+
+###--------------------------------------------------------------------------
+### Utility functions.
+
+def chomped(lines):
+  """For each line in LINES, generate a line without trailing newline."""
+  for line in lines:
+    if line != '' and line[-1] == '\n':
+      line = line[:-1]
+    yield line
+
+###--------------------------------------------------------------------------
+### Create the window.
+
+def escape(_, event, win):
+  """Key-press handler: on escape, destroy WIN."""
+  if GDK.keyval_name(event.keyval) == 'Escape':
+    win.destroy()
+    return True
+  return False
+
+def accept(_, entry, win):
+  """OK button handler: store user's value and end."""
+  global result
+  result = entry.value()
+  win.destroy()
+  return True
+
+def make_window(opts):
+  """
+  Make and return the main window.
+  """
+
+  ## Create the window.
+  win = GTK.Window(GTK.WINDOW_TOPLEVEL)
+  win.set_title(opts.title)
+  win.set_position(GTK.WIN_POS_MOUSE)
+  win.connect('destroy', lambda _: XT.delreason())
+
+  ## Make a horizontal box for the widgets.
+  box = GTK.HBox(spacing = 4)
+  box.set_border_width(4)
+  win.add(box)
+
+  ## If we have a prompt, insert it.
+  if opts.prompt is not None:
+    box.pack_start(GTK.Label(opts.prompt), False)
+
+  ## Choose the appropriate widget.
+  if opts.file is None:
+    entry = SimpleEntry()
+    opts.history = False
+    if opts.invisible:
+      entry.set_visibility(False)
+  else:
+    if opts.nochoice:
+      entry = Combo()
+    else:
+      entry = ComboEntry()
+    try:
+      entry.sethistory(chomped(open(opts.file, 'r')))
+    except IOError, error:
+      if error.errno == E.ENOENT and opts.history:
+        pass
+      else:
+        raise
+
+  ## Insert the widget and configure it.
+  box.pack_start(entry, True)
+  if opts.default == '@':
+    try:
+      entry.setvalue(entry.gethistory.__iter__.next())
+    except StopIteration:
+      pass
+  elif opts.default is not None:
+    entry.setvalue(opts.default)
+  entry.ready()
+
+  ## Sort out the OK button.
+  ok = GTK.Button('OK')
+  ok.set_flags(ok.flags() | GTK.CAN_DEFAULT)
+  box.pack_start(ok, False)
+  ok.connect('clicked', accept, entry, win)
+  ok.grab_default()
+
+  ## Handle escape.
+  win.connect('key-press-event', escape, win)
+
+  ## Done.
+  win.show_all()
+  return entry
+
+###--------------------------------------------------------------------------
+### Option parsing.
+
+def parse_args():
+  """
+  Parse the command line, returning a triple (PARSER, OPTS, ARGS).
+  """
+
+  op = XT.make_optparse \
+       ([('H', 'history',
+          {'action': 'store_true', 'dest': 'history',
+           'help': "With `--list', update with new string."}),
+         ('M', 'history-max',
+          {'type': 'int', 'dest': 'histmax',
+           'help': "Maximum number of items written back to file."}),
+         ('d', 'default',
+          {'dest': 'default',
+           'help': "Set the default entry."}),
+         ('i', 'invisible',
+          {'action': 'store_true', 'dest': 'invisible',
+           'help': "Don't show the user's string as it's typed."}),
+         ('l', 'list',
+          {'dest': 'file',
+           'help': "Read FILE into a drop-down list."}),
+         ('n', 'no-choice',
+          {'action': 'store_true', 'dest': 'nochoice',
+           'help': "No free text input: user must choose item from list."}),
+         ('p', 'prompt',
+          {'dest': 'prompt',
+           'help': "Set the window's prompt string."}),
+         ('t', 'title',
+          {'dest': 'title',
+           'help': "Set the window's title string."})],
+        version = VERSION,
+        usage = '%prog [-Hin] [-M HISTMAX] [-p PROMPT] [-l FILE] [-t TITLE]')
+
+  op.set_defaults(title = 'Input request',
+                  invisible = False,
+                  prompt = None,
+                  file = None,
+                  nochoice = False,
+                  history = False,
+                  histmax = 20)
+
+  opts, args = op.parse_args()
+  return op, opts, args
+
+###--------------------------------------------------------------------------
+### Main program.
+
+result = None
+
+def main():
+
+  ## Startup.
+  op, opts, args = parse_args()
+  if len(args) > 0:
+    op.print_usage(stderr)
+    exit(1)
+  entry = make_window(opts)
+  XT.addreason()
+  GTK.main()
+
+  ## Closedown.
+  if result is None:
+    exit(1)
+  if opts.history:
+    try:
+      new = '%s.new' % opts.file
+      out = open(new, 'w')
+      print >>out, result
+      i = 0
+      for l in entry.gethistory():
+        if opts.histmax != 0 and i >= opts.histmax:
+          break
+        if l != result:
+          print >>out, l
+          i += 1
+      out.close()
+      OS.rename(new, opts.file)
+    finally:
+      try:
+        OS.unlink(new)
+      except OSError, err:
+        if err.errno != E.ENOENT:
+          raise
+  print result
+  exit(0)
+
+if __name__ == '__main__':
+  main()
+
+###----- That's all, folks --------------------------------------------------
diff --git a/xmsg.1 b/xmsg.1
index 9ca1386..e5c21b4 100644 (file)
--- a/xmsg.1
+++ b/xmsg.1
@@ -3,23 +3,15 @@
 .SH NAME
 xmsg \- pops up a message box
 .SH SYNOPSIS
-.ll +5i
 .B xmsg
-.RB [ \-\-display
-.IR display ]
-.RB [ \-f ]
+.RI [ gtk-options ...]
+.RB [ \-EIQWm ]
+.RB [ \-d
+.IR headline ]
 .RB [ \-t
 .IR title ]
-.RB [ \-c | \-d
-.IR button ]
-.if n \{\
-.br
-\h'1i'
-..
-\}
 .I message
 .RI [ button ...]
-.ll -5i
 .SH DESCRIPTION
 The
 .B xmsg
@@ -29,66 +21,115 @@ one per argument, after the message.  If no buttons are requested, an
 .B OK
 button is provided anyway.
 .PP
-If the
+The user dismisses the message window by activating one of the buttons
+or just closing the window using the window manager.  The
+.B xsmg
+program then writes a string to its standard output describing the
+user's action and exits.  The string written is, by default, the label
+of the activated button, though this can be overridden: see below.
+.SS "Message specifications"
+The
+.I message
+argument is usually just a text string to be displayed.  However, if the
 .I message
 is
 .RB ` \- '
-then instead the message to display is read from standard input.  If the
+then, instead, the message to display is read from standard input.  If the
 first character of
 .I message
 is
-.RB ` % '
-then that character is removed.
+.RB ` ! '
+then that character is removed.  (Hence, if you really wanted to show
+the message
+.RB ` \- ', 
+you need to pass
+.RB ` !\- '.)
+Conscientious script authors will prefix strings appropriately.
+.PP
+Pango markup may be used in message and headline strings if the
+.B \-m
+option is requested.
+.SS "Button specifications"
+A
+.I button
+argument has the form
+.RI [ opt \fB: opt \fB: ...] \c
+.RB [ ! ] \c
+.IR label .
+The
+.I label
+is either a text string, or a GTK stock-id (e.g., 
+.BR gtk-ok ).
+Mnemonic characters in button labels may be marked by prefixing them
+with underscores.  Write two underscores if you really want a literal
+underscore to appear.
 .PP
-A button may be selected as being the default (i.e., may be chosen by
-pressing
-.IR enter ),
-using the
-.B \-d
-option: the argument must either match a button name, or be an index
-(zero-based) of the requested button.  If you don't select a default,
-the first (rightmost) button becomes the default anyway.
+Each
+.I opt
+may be one of the following.
+.TP
+.B default
+This should be the default button, activated when the user presses the
+.I enter
+or
+.I return
+key.
+.TP
+.B cancel
+This should be the cancel button, activated when the user presses the
+.I escape
+key or simply dismisses the window.
+.TP
+.BI = tag
+If the user activates this button, output the
+.I tag
+rather than the button's label.
 .PP
-Similarly, a button may be selected as being the `cancel' action (i.e.,
-may be chosen by closing the window or pressing
-.IR escape ),
-using
-the
-.B \-c
-option.  Again, the argument must either match a button name or be an
-index of a button.  If you don't select a cancel button, the last
-(leftmost) button because the cancel button.
+If no button is marked as the default, then the rightmost (first
+specified) is chosen automatically; similarly, if there is no specified
+cancel button then the last is chosen.  If several buttons are marked as
+default or cancel buttons then the behaviour is unspecified.
 .PP
-If there is more than one button, the name of the selected button is
-printed on standard output when the program exits.
+Button options are usually processed while colons remain in the button
+specification.  Processing stops early if an exclamation mark
+.RB ` ! '
+is reached.  For example,
+.B default:!cancel:button
+is parsed has specifying the
+.B default
+option and a label text of
+.BR cancel:button .
+.PP
+If no
+.I button
+arguments are given,
+.B xmsg
+automatically provides an OK button (it actually uses the GTK
+.B gtk-ok
+stock button) but produces no output.
 .SS Options
-.TP 5
-.BI "\-\-display " display
-Attempt to connect to
-.I display
-rather than the display named in the usual environment variable.
-.TP 5
-.B "\-f, \-\-focus "
-Sets a magical property to ensure that the window acquires the focus
-under a customized
-.BR fvwm (3)
-version I don't use any more.
-.TP 5
+.TP
+.BR \-E ", " \-I ", " \-Q ", " \-W
+Mark the message window as, respectively, reporting an error, providing
+information, asking a question, or giving a warning.
+.TP
+.BR "\-d, \-\-headline " headline
+Write the
+.I headline
+above the main message, in larger and bolder text.
+.TP
+.B "\-m, \-\-markup"
+Enable the use of Pango XML-like markup in the message and headline
+strings.  See the Pango documentation for a description of the markup
+tags available.
+.TP
 .BI "\-t, \-\-title " title
 Sets the title for the window.  If you don't specify a title, the window
 is labelled
 .RB ` xmsg '.
-.TP 5
-.BI "\-c, \-\-cancel " button
-Selects the given
-.I button
-as being the cancel button.
-.TP 5
-.BI "\-d, \-\-default " button
-Selects the given
-.I button
-as being the default button.
 .SH BUGS
 None currently known.
+.SH SEE ALSO
+.BR gtk-options (7).
 .SH AUTHOR
 Mark Wooding (mdw@distorted.org.uk).
diff --git a/xmsg.in b/xmsg.in
new file mode 100644 (file)
index 0000000..7b79178
--- /dev/null
+++ b/xmsg.in
@@ -0,0 +1,119 @@
+#! @PYTHON@
+### -*-python-*-
+###
+### Report a message to the user
+###
+### (c) 2008 Straylight/Edgeware
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of the Edgeware X tools collection.
+###
+### X tools is free software; you can redistribute it and/or modify
+### it under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 2 of the License, or
+### (at your option) any later version.
+###
+### X tools is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU General Public License for more details.
+###
+### You should have received a copy of the GNU General Public License
+### along with X tools; if not, write to the Free Software Foundation,
+### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+VERSION = '@VERSION@'
+
+###--------------------------------------------------------------------------
+### External dependencies.
+
+import optparse as O
+from sys import stdin, stdout, stderr, exit
+import os as OS
+import errno as E
+
+import xtoys as XT
+GTK = XT.GTK
+
+###--------------------------------------------------------------------------
+### Option parsing.
+
+def parse_args():
+  """
+  Parse the command line, returning a triple (PARSER, OPTS, ARGS).
+  """
+
+  op = XT.make_optparse \
+       ([('E', 'error',
+          {'action': 'store_const', 'dest': 'type', 'const': 'error',
+           'help': "Mark the window as reporting an error."}),
+         ('I', 'informational',
+          {'action': 'store_const', 'dest': 'type', 'const': 'info',
+           'help': "Mark the window as providing information."}),
+         ('Q', 'question',
+          {'action': 'store_const', 'dest': 'type', 'const': 'question',
+           'help': "Mark the window as asking a question."}),
+         ('W', 'warning',
+          {'action': 'store_const', 'dest': 'type', 'const': 'warning',
+           'help': "Mark the window as giving a warning."}),
+         ('d', 'headline',
+          {'dest': 'headline',
+           'help': "Set the window's headline message."}),
+         ('m', 'markup',
+          {'action': 'store_true', 'dest': 'markupp',
+           'help': "Parse message strings for Pango markup."}),
+         ('t', 'title',
+          {'dest': 'title',
+           'help': "Set the window's title string."})],
+        version = VERSION,
+        usage = '%prog [-EIQWm] [-t TITLE] [-d HEADLINE] '
+                'MESSAGE [BUTTONS...]')
+
+  op.set_defaults(title = 'xmsg',
+                  type = 'info',
+                  headline = None,
+                  markupp = False)
+
+  opts, args = op.parse_args()
+  return op, opts, args
+
+###--------------------------------------------------------------------------
+### Main program.
+
+def main():
+  op, opts, args = parse_args()
+  if len(args) == 0:
+    op.print_usage(stderr)
+    exit(1)
+
+  ## Sort out the message.
+  message = args[0]
+  buttons = args[1:]
+  if message.startswith('!'):
+    message = message[1:]
+  elif message == '-':
+    message = stdin.read()
+
+  ## Display it and retrieve and answer.
+  try:
+    msg = XT.Message(title = opts.title,
+                     type = opts.type,
+                     message = message,
+                     headline = opts.headline,
+                     buttons = buttons,
+                     markupp = opts.markupp)
+  except ValueError, err:
+    op.error(err[0])
+  result = msg.ask()
+
+  ## Done.
+  if result != True:
+    print result
+  exit(0)
+
+if __name__ == '__main__':
+  main()
+
+###----- That's all, folks --------------------------------------------------
diff --git a/xtoys.py b/xtoys.py
new file mode 100644 (file)
index 0000000..6109c85
--- /dev/null
+++ b/xtoys.py
@@ -0,0 +1,264 @@
+### -*-python-*-
+###
+### Utility module for xtoys Python programs
+###
+### (c) 2007 Straylight/Edgeware
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of the Edgeware X tools collection.
+###
+### X tools is free software; you can redistribute it and/or modify
+### it under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 2 of the License, or
+### (at your option) any later version.
+###
+### X tools is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU General Public License for more details.
+###
+### You should have received a copy of the GNU General Public License
+### along with X tools; if not, write to the Free Software Foundation,
+### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+###--------------------------------------------------------------------------
+### External dependencies.
+
+import os as OS
+import optparse as O
+from sys import stdin, stdout, exit, argv
+
+import pygtk
+pygtk.require('2.0')
+import gtk as GTK
+GDK = GTK.gdk
+import gobject as GO
+del pygtk
+
+###--------------------------------------------------------------------------
+### Reasons for living.
+
+_reasons = 0
+
+def addreason():
+  """Add a reason."""
+  global _reasons
+  _reasons += 1
+
+def delreason():
+  """Drop a reason.  When reasons reach zero, the main loop stops."""
+  global _reasons
+  _reasons -= 1
+  if _reasons == 0:
+    GTK.main_quit()
+
+###--------------------------------------------------------------------------
+### General utilities.
+
+def make_optparse(options, **kw):
+  """
+  Construct an option parser object.
+
+  The KW are keyword arguments to be passed to OptionParser.  The OPTIONS are
+  a list of (SHORT, LONG, KW) triples; the SHORT and LONG strings do /not/
+  have leading dashes.
+  """
+  op = O.OptionParser(**kw)
+  for short, long, kw in options:
+    names = ['--%s' % long]
+    if short is not None:
+      names.append('-%s' % short)
+    op.add_option(*names, **kw)
+  return op
+
+###--------------------------------------------------------------------------
+### Message boxes.
+
+class MessageButton (object):
+  """
+  An object storing information about a button in a Message.
+  """
+
+  def __init__(me, label, value = None, *options):
+    """
+    Initialize a button definition.
+
+    If LABEL is a tuple, then it should have the form
+    (LABEL, VALUE, OPTIONS...); the initialization parser is applied to its
+    contents.  This is not done recursively.
+
+    If LABEL is a string, and VALUE and OPTIONS are omitted, then it is
+    parsed as OPT:OPT:...:LABEL, where OPT is either an option (see below) or
+    `=VALUE'.  Only one VALUE may be given.
+
+    The LABEL is the label to put on the button, or the GTK stock id.  The
+    VALUE is the value to return from Message.ask if the button is chosen.
+    The OPTIONS are:
+
+      * 'default': this is the default button
+      * 'cancel': this is the cancel button
+    """
+    if value is not None or len(options) > 0:
+      me._doinit(label, value, *options)
+    elif isinstance(label, tuple):
+      me._doinit(*label)
+    else:
+      i = 0
+      options = []
+      while 0 <= i < len(label):
+        if label[i] == '!':
+          i += 1
+          break
+        j = label.find(':', i)
+        if j < 0:
+          break
+        if label[i] == '=':
+          if value is not None:
+            raise ValueError, 'Duplicate value in button spec %s' % label
+          value = label[i + 1:j]
+        else:
+          options.append(label[i:j])
+        i = j + 1
+      label = label[i:]
+      me._doinit(label, value, *options)
+
+  def _doinit(me, label, value = None, *options):
+    """
+    Does the work of processing the initialization parameters.
+    """
+    me.label = label
+    if value is None:
+      me.value = label
+    else:
+      me.value = value
+    me.defaultp = me.cancelp = False
+    for opt in options:
+      if opt == 'default':
+        me.defaultp = True
+      elif opt == 'cancel':
+        me.cancelp = True
+      else:
+        raise ValueError, 'unknown button option %s' % opt
+
+class Message (GTK.MessageDialog):
+  """
+  A simple message-box window: contains text and some buttons.
+
+  See __init__ for the usage instructions.
+  """
+
+  ## Mapping from Pythonic strings to GTK constants.
+  dboxtype = {'info': GTK.MESSAGE_INFO,
+              'warning': GTK.MESSAGE_WARNING,
+              'question': GTK.MESSAGE_QUESTION,
+              'error': GTK.MESSAGE_ERROR}
+
+  def __init__(me,
+               title = None,
+               type = 'info',
+               message = '',
+               headline = None,
+               buttons = [],
+               markupp = False):
+    """
+    Report a message to the user and get a response back.
+
+    The TITLE is placed in the window's title bar.
+
+    The TYPE controls what kind of icon is placed in the window; it should be
+    one of 'info', 'warning', 'question' or 'error'.
+
+    The MESSAGE is the string which should be displayed.  It may have
+    multiple lines.  There may also be a HEADLINE message.  The messages are
+    parsed for Pango markup if MARKUPP is set.
+
+    The BUTTONS are a list of buttons to show, right to left.  Each one
+    should be a string which may either be a GTK stock tag or a plain label
+    string.  This may be prefixed, optionally, by `!' to ignore other prefix
+    characters, `+' to make this button the default, or `:' to make this the
+    `cancel' button, chosen by pressing escape.  If no button is marked as
+    cancel, we just use the first.
+    """
+
+    ## Initialize superclasses.
+    GTK.MessageDialog.__init__(me, type = me.dboxtype.get(type))
+
+    ## Set the title.
+    if title is None:
+      title = OS.path.basename(argv[0])
+    me.set_title(title)
+
+    ## Add the message strings.
+    me.set_property('use-markup', markupp)
+    if headline is None:
+      me.set_property('text', message)
+    else:
+      me.set_property('text', headline)
+      me.set_property('secondary-text', message)
+      me.set_property('secondary-use-markup', markupp)
+
+    ## Include the buttons.
+    if len(buttons) == 0:
+      buttons = [MessageButton('gtk-ok', True, 'default', 'cancel')]
+    else:
+      buttons = buttons[:]
+      buttons.reverse()
+    me.buttons = [isinstance(b, MessageButton) and b or MessageButton(b)
+                  for b in buttons]
+    cancel = -1
+    default = -1
+    for i in xrange(len(buttons)):
+      button = me.buttons[i]
+      label = button.label
+      if GTK.stock_lookup(label) is None:
+        b = GTK.Button(label = label)
+      else:
+        b = GTK.Button(stock = label)
+      if button.defaultp:
+        default = i
+      if button.cancelp:
+        cancel = i
+      b.set_flags(b.flags() | GTK.CAN_DEFAULT)
+      button.widget = b
+      me.add_action_widget(b, i)
+
+    ## Choose default buttons.
+    if cancel == -1:
+      cancel = 0
+    if default == -1:
+      default = len(me.buttons) - 1
+    me.cancel = cancel
+    me.default = default
+
+    ## Connect up the handlers and go.
+    me.connect('key-press-event', me.keypress, me.buttons[cancel])
+    me.buttons[default].widget.grab_default()
+
+  def keypress(me, win, event, cancel):
+    """
+    Handle key-press events.
+
+    If the EVENT was an escape-press, then activate the CANCEL button.
+    """
+    key = GDK.keyval_name(event.keyval)
+    if key != 'Escape':
+      return False
+    cancel.widget.activate()
+    return True
+
+  def ask(me):
+    """
+    Display the message, and wait for a response.
+
+    The return value is the label of the button which was chosen.
+    """
+    me.show_all()
+    r = me.run()
+    if r < 0:
+      r = me.cancel
+    me.hide()
+    return me.buttons[r].value
+
+###----- That's all, folks --------------------------------------------------