From: Mark Wooding Date: Sun, 23 Mar 2008 16:06:36 +0000 (+0000) Subject: Rewrite graphical tools in Python. X-Git-Tag: 1.4.0~1 X-Git-Url: https://git.distorted.org.uk/~mdw/xtoys/commitdiff_plain/bce8c6eed8bd2fb91fc505a26483e449aefac819 Rewrite graphical tools in Python. --- diff --git a/.links b/.links index 5ecd9c6..dd8b261 100644 --- a/.links +++ b/.links @@ -1 +1,2 @@ COPYING +config/confsubst diff --git a/Makefile.am b/Makefile.am index 14d29ed..ffdd407 100644 --- a/Makefile.am +++ b/Makefile.am @@ -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 -------------------------------------------------- diff --git a/configure.ac b/configure.ac index 8e580c9..e338570 100644 --- a/configure.ac +++ b/configure.ac @@ -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( diff --git a/debian/control b/debian/control index db4fd1b..139b25a 100644 --- a/debian/control +++ b/debian/control @@ -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 +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 diff --git a/debian/rules b/debian/rules index 1ee41f9..7aa6821 100755 --- a/debian/rules +++ b/debian/rules @@ -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 index 0000000..4f6fd0f --- /dev/null +++ b/debian/xtoys-gtk.install @@ -0,0 +1,4 @@ +debian/tmp/usr/bin/xcatch +debian/tmp/usr/bin/xgetline +debian/tmp/usr/bin/xmsg +debian/tmp/usr/lib/python* diff --git a/xcatch.1 b/xcatch.1 index 34bb62b..5c20670 100644 --- 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 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 = '' + catcher.watch_file(stdin) + else: + name = file + catcher.watch(open(opts.file, 'r')) + elif len(args) == 0: + name = '' + 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 -------------------------------------------------- diff --git a/xgetline.1 b/xgetline.1 index caec17c..22757db 100644 --- a/xgetline.1 +++ b/xgetline.1 @@ -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 index 0000000..8f649d5 --- /dev/null +++ b/xgetline.in @@ -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 --- 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 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 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 --------------------------------------------------