wizpym/wizpym.py
2011-09-03 22:34:48 +02:00

630 lines
22 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
#
# wizpym - The Wyzard Python Module, a tool to write Wizard-like apps.
# Copyright (C) 2010 David Soulayrol <david.soulayrol@gmail.com>
#
# wizpym 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 3 of the License, or
# (at your option) any later version.
#
# This program 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 this program; if not, see <http://www.gnu.org/licenses/>.
"""A tool to write Wizard-like apps.
This module provides classes to easily build wizard-type applications
with GTK. This kind of application is composed of a succession of
panes which asks the user for inputs, and processes them to achieve
some result. It is a quite natural way to propose a graphical view for
short and simple tasks, or batch processes.
Wizard applications thus are successions of short tasks which
cooperate to work on some subject, each one adding a value or choosing
the next step in the whole process. wizpym provides a framework for
the developers to focus on these tasks by providing the tools to plug
them together and to handle background activities.
"""
import gobject
import gtk
import threading
__all__ = ['InvalidPane', 'Pane', 'ActivePane', 'Frame']
__version_info__ = (0, 2, 0)
__version__ = '.'.join((str(i) for i in __version_info__))
class InvalidPane(Exception):
"""Raised when a wrong argument is given in pane manipulating methods."""
pass
class Pane(gtk.VBox):
"""A single pane from a Wizard.
Each pane of a wizard has zero or one predecessor and one or many
successors. The successor is chosen upon an switch, which can be
altered when the user interacts with the pane.
All panes from one wizard share a subject. The subject is the
object that forwards data from one pane to the other and can be a
simple string as well as a complex structure. It is initially set
by the wizard Frame when the first pane is created.
A pane can also state that it is not ready to fulfill the next
pane requirements. The frame will grey the Next button as long as
its ready attribute is set to False.
"""
def __init__(self, frame):
"""Constructor.
A fresh pane is in a ready state and has no successors nor
predecessor. The constructor doesn't either set the subject:
the wizard frame takes care of setting it, and passing it from
one pane to the other.
Note that all wizard panes constructors should take no
parameter since they are called automatically by the wizard
frame. The developer should consider using the wizard subject
or environment variables to configure the panes if necessary.
"""
gtk.VBox.__init__(self, False, 5)
self._frame = frame
self._header = gtk.VBox()
self.pack_start(self._header, False)
self._error = gtk.VBox()
self.pack_end(self._error, False)
self._subject = None
self._switch = None
self._successors = {}
self._ready = True
def link_to(self, klass):
"""Add a successor to this pane.
This method is intended to be called by the pane itself at
construction time to set its successors, but nothing forbids
to do it a different way.
The switch is set to the first successor set. If the provided
pane is already a successor of this pane, the InvalidPane
exception is raised.
"""
if klass is None or klass in self._successors:
raise InvalidPane()
self._successors[klass] = klass(self._frame)
if len(self._successors) == 1:
self._switch = klass
def set_header(self, msg):
"""Add a help header to the pane with the given message.
Since it is quite common that panes begin with a little guide
explaining the current step, calling this method will ensure
such panes all present the same header style.
"""
self._header.foreach(lambda w: self._header.remove(w))
if msg:
help_label = gtk.Label(msg)
help_label.set_justify(gtk.JUSTIFY_FILL)
help_label.set_line_wrap(True)
self._header.pack_start(help_label, False, False, 10)
self._header.pack_start(gtk.HSeparator(), False)
self._header.show_all()
def set_error(self, msg):
"""Display a error in the pane's footer.
Display the given message in red in a special label attached
to the footer of the pane. The pane is hidden if the message
is empty.
"""
self._error.foreach(lambda w: self._error.remove(w))
if msg:
error_label = gtk.Label()
error_label.set_markup(
'<span foreground="red" weight="bold">' + msg + '</span>')
error_label.set_justify(gtk.JUSTIFY_FILL)
error_label.set_line_wrap(True)
self._error.pack_start(error_label, False, False, 10)
self._error.show_all()
def clear(self, safe=None):
"""Clear all the pane children but internal widgets.
If the safe parameter is a list, then its content should be
widgets that should not be removed either. Invalid parameters
are ignored.
This method leaves the header and the error labels untouched
(eg. if they were displayed, they remain displayed).
"""
if safe is None or not isinstance(safe, list):
safe = []
safe.extend([self._header, self._error])
for w in filter(lambda x: x not in safe, self.get_children()):
self.remove(w)
def switch(self, klass):
"""Update the switch using the given class.
If the provided class is None or is not a successor of this
pane, the InvalidPane exception is raised.
"""
if klass is None or klass in [o.__class__ for o in self._successors]:
raise InvalidPane()
self._switch = klass
def is_terminal(self):
"""Whether this pane has no successor."""
return len(self._successors) == 0
def enter(self):
"""Proceed to this pane initialization.
This method is called by the wizard main frame when this pane
is entered or reentered from its predecessor. It should be
overriden if this pane needs to update itself with the received
subject.
"""
pass
def leave(self):
"""Proceed to this pane finalization.
This method is called when this pane is replaced by a
successor. It should be overriden if this pane needs to
compute or to retrieve some data to update the subject before
it is passed to the successor.
"""
pass
def next(self):
"""Return the next pane as designated by the switch."""
return self._successors.get(self._switch)
def _get_subject(self):
return self._subject
def _set_subject(self, s):
self._subject = s
def _is_ready(self):
return self._ready
def _set_ready(self, v):
self._ready = v
self._frame.update()
subject = property(_get_subject, _set_subject, None, 'This pane subject')
ready = property(_is_ready, _set_ready, None, 'Whether the next pane can be called')
class ActivePane(Pane):
"""An active pane from a Wizard.
ActivePane specializes the Pane class by providing a mean of
running long tasks in the background. Applications using this kind
of Pane should not forget to call gobject.threads_init before
anything else.
"""
def __init__(self, frame):
"""Constructor."""
Pane.__init__(self, frame)
self._progress_box = gtk.VBox()
self.pack_end(self._progress_box, False)
self._thread = None
def update_progress_bar(self, fraction=None, text=None):
"""Update this pane's progress bar.
The progress bar, when used, shows up at the bottom of the
pane. When this method is called, it is first displayed is
absent, and the updated with the given fraction, between 0.0
and 1.0, and the given text if any. In the special case where
the fraction is not provided, the ProgressBar.pulse method is
invoked.
Returns True, so that it can be used in a GObject timer.
This method is non-GTK thread safe, which means that any
thread can call it.
"""
gobject.idle_add(self._do_update_progress_bar, fraction, text)
return True
def hide_progress_bar(self):
"""Hide this pane's progress bar.
The progress bar is in fact destroyed, so its state is lost.
This method is non-GTK thread safe, which means that any
thread can call it. If no progress bar is currently displayed,
it does nothing.
"""
if len(self._progress_box.get_children()):
gobject.idle_add(self._progress_box.remove,
self._progress_box.get_children()[0])
def clear(self, safe=None):
"""Clear all the pane children but internal widgets.
This method specializes Pane.clear so that the progress box is
also left untouched.
"""
if safe is None or not isinstance(safe, list):
safe = []
safe.append(self._progress_box)
Pane.clear(self, safe)
def enter(self):
"""Proceed to this pane initialization.
See Pane.enter. All ActivePane instances that override this
method should call thir parent one, since the background task
start is handled here.
"""
self._thread = threading.Thread(
name=self.__class__.__name__, target=self._wrapper)
self._thread.start()
def is_running(self):
"""Whether this pane background task is running."""
return not self._thread is None and self._thread.is_alive()
def _runner(self):
"""The background task.
All ActivePane instances should override this method to put
their behaviour. It is up to the developer to handle
synchronisation problems. Note in particular that all pane
drawing invoked from there should be done through some helper
like gobject.idle_add.
"""
pass
def _wrapper(self):
"""A internal wrapper around the pane task."""
gobject.idle_add(self._frame.lock)
self._runner()
gobject.idle_add(self._frame.unlock)
def _do_update_progress_bar(self, fraction, text):
"""An internal facility to update the progress bar.
This private method is intended to be called in the GTK main
thread.
"""
if not len(self._progress_box.get_children()):
self._progress_box.pack_start(gtk.ProgressBar())
self._progress_box.show_all()
bar = self._progress_box.get_children()[0]
if text:
bar.set_text(text)
bar.pulse() if fraction is None else bar.set_fraction(fraction)
class Frame(gtk.Dialog):
"""The Wizard window.
Each wizard application is built around exactly one Frame
instance. The frame is responsible to show the panes and to
forward the subject between them.
"""
def __init__(self, start_klass, subject=None, title='Wizard', min_size=(400, 200)):
"""Constructor.
A frame must be provided at least the initial pane class. If
no subject is set, then a (initially empty) string is provided
to the first pane.
"""
gtk.Dialog.__init__(self, title)
# Members are all defined before calling the First pane so as
# to be ready if the start pane constructor invokes the frame
# (such as the ready attribute setting)
self._locked = False
self._track = []
self._prev_button = gtk.Button('Previous')
self._prev_button.set_sensitive(False)
self._next_button = gtk.Button('Next')
self._next_button.set_sensitive(False)
self._quit_button = gtk.Button('Quit')
self._quit_button.set_sensitive(True)
self.set_default_size(min_size[0], min_size[1])
self.set_position(gtk.WIN_POS_CENTER)
self.has_separator = True
self.add_action_widget(self._prev_button, gtk.RESPONSE_REJECT)
self.add_action_widget(self._next_button, gtk.RESPONSE_ACCEPT)
self.add_action_widget(self._quit_button, gtk.RESPONSE_CLOSE)
self.connect('close', gtk.main_quit)
self.connect('destroy', gtk.main_quit)
self.connect("response", self._on_button)
start_pane = start_klass(self)
if not subject is None:
start_pane.subject = subject
self._track = [start_pane]
self._enter_pane(start_pane, True)
def lock(self):
"""Freeze the previous and next buttons.
This method is called by ActivePane instances when their
background task is started.
"""
self._locked = True
self.update()
def unlock(self):
"""Unfreeze the previous and next buttons.
This method is called by ActivePane instances when their
background task is terminated.
"""
self._locked = False
self.update()
def update(self):
"""Update the frame buttons display."""
# Beware that this method can be called early, before the track
# contains the first pane
if len(self._track):
pane = self._track[-1]
self._prev_button.set_sensitive(
not self._locked and len(self._track) > 1)
self._next_button.set_sensitive(
not self._locked and pane.ready and not pane.is_terminal())
self._quit_button.set_sensitive(not self._locked)
def _on_button(self, w, response_id):
"""The frame buttons handler."""
if response_id == gtk.RESPONSE_REJECT:
if len(self._track) > 1:
self._exit_pane(self._track.pop(), False)
self._enter_pane(self._track[-1], False)
elif response_id == gtk.RESPONSE_ACCEPT:
p = self._track[-1].next()
if p:
self._exit_pane(self._track[-1], True)
p.subject = self._track[-1].subject
self._track.append(p)
self._enter_pane(p, True)
elif response_id == gtk.RESPONSE_CLOSE:
gobject.idle_add(gtk.main_quit)
def _enter_pane(self, pane, forward):
"""Handle a new pane display."""
if forward:
pane.enter()
self.update()
self.vbox.pack_start(self._track[-1], True, True)
self.vbox.show_all()
def _exit_pane(self, pane, forward):
"""Handle a pane exit."""
if forward:
pane.leave()
self.vbox.remove(pane)
class AboutTestPane(Pane):
def __init__(self, frame):
Pane.__init__(self, frame)
self.link_to(InputTestPane)
self.pack_start(gtk.Label('A simple search tool.'), True, True, 10)
self.pack_start(
gtk.Label(
'Copyright 2010 David Soulayrol <david.soulayrol@gmail.com>'), False)
comment = gtk.Label()
comment.set_markup('This wizard tests the main features of wizpym, '
'which are:\n\n'
' The handling of a flow of panes, each one having '
'any number of successors.\n'
' A full history to go back the full track down to '
'the beginning and to keep the status of the reverted panes.\n'
' Data transfer from one pane to the next one.\n'
' The possiblity to do heavy stuff in background on '
'each pane.\n\n\n'
'This program comes with ABSOLUTELY NO WARRANTY. '
'This is free software and you are welcome to '
'redistribute it under certain conditions. See '
'<a href="http://www.gnu.org/licenses">GNU licenses</a> '
'for details.')
comment.set_line_wrap(True)
self.pack_start(comment, True, True, 10)
class InputTestPane(Pane):
def __init__(self, frame):
Pane.__init__(self, frame)
self.set_header('Please select your kind of search and fill in criteria fields.')
self.ready = False
self.link_to(GoogleSearchTestPane)
self.link_to(LocalSearchTestPane)
frame = gtk.Frame('Kind of search:')
frame.set_border_width(10)
self.pack_start(frame, padding=10)
vbox = gtk.VBox()
frame.add(vbox)
button = gtk.RadioButton(None, 'Search Google Code')
button.connect("toggled", self._on_button, GoogleSearchTestPane)
button.set_active(True)
vbox.pack_start(button)
button = gtk.RadioButton(button, "Search Local Files")
button.connect("toggled", self._on_button, LocalSearchTestPane)
vbox.pack_start(button)
box = gtk.HBox(False, 10)
box.set_border_width(10)
self._folder_entry = gtk.Entry()
self._folder_entry.connect('changed', self._on_entry_update)
box.pack_start(gtk.Label('Directory:'), False)
box.pack_start(self._folder_entry)
vbox.pack_start(box)
box = gtk.HBox(False, 10)
box.set_border_width(10)
self._keywords_entry = gtk.Entry()
self._keywords_entry.connect('changed', self._on_entry_update)
box.pack_start(gtk.Label('Keywords:'), False)
box.pack_start(self._keywords_entry)
self.pack_start(box)
self._update_status()
def leave(self):
self.subject[0] = self._keywords_entry.get_text()
self.subject[1] = self._folder_entry.get_text()
def _on_entry_update(self, w):
self._update_status()
def _on_button(self, w, klass):
if w.get_active():
self.switch(klass)
self._update_status()
def _update_status(self):
r = len(self._keywords_entry.get_text())
e = None if r else 'Please fill in the keywords field'
if isinstance(self.next(), LocalSearchTestPane):
if not len(self._folder_entry.get_text()):
r = False
e = 'Local search needs a root folder'
else:
try:
os.stat(self._folder_entry.get_text())
except OSError:
r = False
e = self._folder_entry.get_text() + ' is not a valid path'
self.set_error(e)
self.ready = r
class GoogleSearchTestPane(ActivePane):
PATH = 'https://www.google.com/codesearch/feeds/search?'
XMLNS_ATOM = '{http://www.w3.org/2005/Atom}'
XMLNS_GCS = '{http://schemas.google.com/codesearch/2006}'
def __init__(self, frame):
ActivePane.__init__(self, frame)
def enter(self):
self.set_header('Google Code results for: "' + self.subject[0] + '"')
self.clear()
# Start thread
ActivePane.enter(self)
def _runner(self):
timer = gobject.timeout_add(100, lambda: self.update_progress_bar())
self.update_progress_bar(text='Searching...')
c = httplib.HTTPSConnection('www.google.com')
c.request('GET', self.PATH + urllib.urlencode({'q': self.subject[0]}),
None, {'GData-Version': '2'})
self.hide_progress_bar()
gobject.source_remove(timer)
gobject.idle_add(self._show_response, c.getresponse())
def _show_response(self, r):
lbl = None
if r.status != 200:
lbl = gtk.Label('Got error (' + r.reason + ') from google')
lbl.show()
self.pack_start(lbl)
else:
tree = etree.fromstring(r.read())
for e in tree.findall(self.XMLNS_ATOM + 'entry'):
lbl = gtk.Label(e.find(self.XMLNS_GCS + 'package').get('name'))
lbl.show()
self.pack_start(lbl)
class LocalSearchTestPane(ActivePane):
def __init__(self, frame):
ActivePane.__init__(self, frame)
sw = gtk.ScrolledWindow()
sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
textview = gtk.TextView()
textview.set_editable(False)
sw.add(textview)
self.pack_start(sw)
self._buffer = textview.get_buffer()
def enter(self):
self.set_header(
'Grep results for: "' + self.subject[0].split()[0] + '" in ' + self.subject[1])
# Start thread
ActivePane.enter(self)
def _runner(self):
timer = gobject.timeout_add(100, lambda: self.update_progress_bar())
self.update_progress_bar(0, 'Counting files...')
nb_files = 0
for root, dirs, files in os.walk(self.subject[1]):
self._filter_directories(dirs)
nb_files += len(files)
gobject.source_remove(timer)
# Tweak so that the division below operates on floats.
count = float(0)
item = self.subject[0].split()[0]
for root, dirs, files in os.walk(self.subject[1]):
self._filter_directories(dirs)
for f in files:
name = os.path.join(root, f)
p = subprocess.Popen(['grep', '-iHn', item, name],
stdout=subprocess.PIPE)
gobject.idle_add(self._buffer.insert_at_cursor, p.communicate()[0])
self.update_progress_bar(
count / nb_files,
name + '(' + str(int(count)) + '/' + str(nb_files) + ')')
count += 1
self.hide_progress_bar()
def _filter_directories(self, dirs):
for f in ['CVS', '.svn', '.git']:
if f in dirs:
dirs.remove(f)
if __name__ == '__main__':
import httplib, os, subprocess, urllib
import xml.etree.ElementTree as etree
gobject.threads_init()
Frame(AboutTestPane, ['', '']).run()
gtk.main()