Second version (squashed) of TUI patch

Martin Sivak msivak at redhat.com
Fri Aug 10 14:11:33 UTC 2012


Here is the complete patch against current master. Doing this using format patch is hard as the merges got in the way.

diff --git a/Makefile.am b/Makefile.am
index 9470447..733a575 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -126,6 +126,21 @@ runhub:
 	GI_TYPELIB_PATH=widgets/src/ \
 	pyanaconda/ui/gui/tools/run-hub.py ${HUB_MODULE} ${HUB_CLASS}

+runtextspoke:
+	ANACONDA_DATA=${PWD}/data \
+	ANACONDA_INSTALL_CLASSES=${PWD}/pyanaconda/installclasses \
+	PYTHONPATH=.:pyanaconda/isys/.libs:widgets/python/:widgets/src/.libs/ \
+	LD_LIBRARY_PATH=widgets/src/.libs \
+	pyanaconda/ui/tui/tools/run-text-spoke.py ${SPOKE_MODULE} ${SPOKE_CLASS}
+
+runtexthub:
+	ANACONDA_DATA=${PWD}/data \
+	ANACONDA_INSTALL_CLASSES=${PWD}/pyanaconda/installclasses \
+	PYTHONPATH=.:pyanaconda/isys/.libs:widgets/python/:widgets/src/.libs/ \
+	LD_LIBRARY_PATH=widgets/src/.libs \
+	pyanaconda/ui/tui/tools/run-text-hub.py ${HUB_MODULE} ${HUB_CLASS}
+
+
 runglade:
 	ANACONDA_DATA=${PWD}/data \
 	ANACONDA_WIDGETS_OVERRIDES=${PWD}/widgets/python \
diff --git a/configure.ac b/configure.ac
index 8e7bbc2..530dd67 100644
--- a/configure.ac
+++ b/configure.ac
@@ -295,6 +295,11 @@ AC_CONFIG_FILES([Makefile
                  pyanaconda/ui/gui/spokes/lib/Makefile
                  pyanaconda/ui/gui/tools/Makefile
                  pyanaconda/ui/gui/Makefile
+                 pyanaconda/ui/tui/hubs/Makefile
+                 pyanaconda/ui/tui/simpleline/Makefile
+                 pyanaconda/ui/tui/spokes/Makefile
+                 pyanaconda/ui/tui/tools/Makefile
+                 pyanaconda/ui/tui/Makefile
                  tests/Makefile
                  tests/mock/Makefile
                  tests/kickstart_test/Makefile
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 55a6d2a..3525093 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -55,6 +55,22 @@ pyanaconda/storage/iscsi.py
 pyanaconda/storage/partitioning.py
 pyanaconda/storage/zfcp.py

+# Interfaces
+pyanaconda/ui/common.py
+pyanaconda/ui/__init__.py
+
+# Text interface
+pyanaconda/ui/tui/hubs/summary.py
+pyanaconda/ui/tui/hubs/__init__.py
+pyanaconda/ui/tui/tuiobject.py
+pyanaconda/ui/tui/simpleline/widgets.py
+pyanaconda/ui/tui/simpleline/base.py
+pyanaconda/ui/tui/simpleline/__init__.py
+pyanaconda/ui/tui/spokes/password.py
+pyanaconda/ui/tui/spokes/time.py
+pyanaconda/ui/tui/spokes/__init__.py
+pyanaconda/ui/tui/__init__.py
+
 # Graphical interface
 pyanaconda/ui/gui/__init__.py
 pyanaconda/ui/gui/categories/__init__.py
diff --git a/pyanaconda/ui/Makefile.am b/pyanaconda/ui/Makefile.am
index 970fc82..cff34cc 100644
--- a/pyanaconda/ui/Makefile.am
+++ b/pyanaconda/ui/Makefile.am
@@ -15,7 +15,7 @@
 #
 # Author: Chris Lumens <clumens at redhat.com>

-SUBDIRS = gui
+SUBDIRS = gui tui

 MAINTAINERCLEANFILES = Makefile.in

diff --git a/pyanaconda/ui/__init__.py b/pyanaconda/ui/__init__.py
index 3cf3eb6..4cf5f5e1 100644
--- a/pyanaconda/ui/__init__.py
+++ b/pyanaconda/ui/__init__.py
@@ -19,6 +19,11 @@
 # Red Hat Author(s): Chris Lumens <clumens at redhat.com>
 #

+__all__ = ["UserInterface", "collect"]
+
+import os
+from common import collect
+
 class UserInterface(object):
     """This is the base class for all kinds of install UIs.  It primarily
        defines what kinds of dialogs and entry widgets every interface must
@@ -93,6 +98,39 @@ class UserInterface(object):
         """
         raise NotImplementedError

+    def getActionClasses(self, module_pattern, path, hubs, standalone_class):
+        """Collect all the Hub and Spoke classes which should be enqueued for
+           processing and order them according to their pre/post dependencies.
+
+           :param module_pattern: the full name pattern (pyanaconda.ui.gui.spokes.%s)
+                                  of modules we about to import from path
+           :type module_pattern: string
+
+           :param path: the directory we are picking up modules from
+           :type path: string
+
+           :param hubs: the list of Hub classes we check to be in pre/postForHub
+                        attribute of Spokes to pick up
+           :type hubs: common.Hub based types
+
+           :param standalone_class: the parent type of Spokes we want to pick up
+           :type standalone_class: common.StandaloneSpoke based types
+        """
+
+
+        standalones = collect(module_pattern, path, lambda obj: issubclass(obj, standalone_class) and \
+                              getattr(obj, "preForHub", False) or getattr(obj, "postForHub", False))
+
+        actionClasses = []
+        for hub in hubs:
+            actionClasses.extend(sorted(filter(lambda obj: getattr(obj, "preForHub", None) == hub, standalones),
+                                        key=lambda obj: obj.priority))
+            actionClasses.append(hub)
+            actionClasses.extend(sorted(filter(lambda obj: getattr(obj, "postForHub", None) == hub, standalones),
+                                        key=lambda obj: obj.priority))
+
+        return actionClasses
+
     def mainExceptionWindow(self, text, exn_file):
         """Return window with the exception and buttons for debugging, bug
            reporting and exitting the installer.
diff --git a/pyanaconda/ui/common.py b/pyanaconda/ui/common.py
new file mode 100644
index 0000000..8f6c4b7
--- /dev/null
+++ b/pyanaconda/ui/common.py
@@ -0,0 +1,389 @@
+import os
+import importlib
+import inspect
+
+class UIObject(object):
+    """This is the base class from which all other UI classes are derived.  It
+       thus contains only attributes and methods that are common to everything
+       else.  It should not be directly instantiated.
+       """
+
+    def __init__(self, data):
+        """Create a new UIObject instance, including loading its uiFile and
+           all UI-related objects.
+
+           Instance attributes:
+
+           data     -- An instance of a pykickstart Handler object.  The Hub
+                       never directly uses this instance.  Instead, it passes
+                       it down into Spokes when they are created and applied.
+                       The Hub simply stores this instance so it doesn't need
+                       to be passed by the user.
+        """
+        if self.__class__ is UIObject:
+            raise TypeError("UIObject is an abstract class")
+
+        self.skipTo = None
+        self._data = data
+
+    def initialize(self):
+        """Perform whatever actions are necessary to pre-fill the UI with
+           values.  This method is called only once, after the object is
+           created.  The difference between this method and __init__ is that
+           this method may take a long time (especially for NormalSpokes) and
+           thus may be run in its own thread.
+        """
+        pass
+
+    def retranslate(self):
+        """This method should be called when the current language is changed
+           in order to update the UI for the new language.  Since we don't get
+           any toolkit help for this, it is largely a manual process.
+        """
+        pass
+
+    def refresh(self):
+        """Perform whatever actions are necessary to reset the UI immediately
+           before it is displayed.  This method is called every time a screen
+           is shown, which could potentially be several times in the case of a
+           NormalSpoke.  Thus, it's important to not do things like populate
+           stores (which could result in the store having duplicate entries) or
+           anything that takes a long time (as that will result in a delay
+           between the user's action and showing the results).
+
+           For anything potentially long-lived, use the initialize method.
+        """
+        pass
+
+    @property
+    def showable(self):
+        """Should this object even be shown?  This method is useful for checking
+           some precondition before this screen is shown.  If False is returned,
+           the screen will be skipped and the object destroyed.
+        """
+        return True
+
+    def teardown(self):
+        """Perform whatever actions are necessary to clean up after this object
+           is done.  It's not necessary for every subclass to have an instance
+           of this method.
+
+           NOTE:  It is important for this method to not destroy self.window if
+           you are making a Spoke or Hub subclass.  It is assumed that once
+           these are instantiated, they live until the program terminates.  This
+           is required for various status notifications.
+        """
+        pass
+
+    @property
+    def window(self):
+        """Return an object with show_all and hide methods that is to be used
+           to display this UI object.
+        """
+        raise TypeError("UIObject.window has to be overriden")
+
+    @property
+    def data(self):
+        return self._data
+
+class Spoke(UIObject):
+    """A Spoke is a single configuration screen.  There are several different
+       places where a Spoke can be displayed, each of which will have its own
+       unique class.  A Spoke is typically used when an element in the Hub is
+       selected but can also be displayed before a Hub or between multiple
+       Hubs.
+
+       What amount of the UI layout a Spoke provides depends upon where it is
+       to be shown.  Regardless, the UI of a Spoke should be given by an
+       interface description file like glade as often as possible, though this
+       is not a strict requirement.
+
+       Class attributes:
+
+       category   -- Under which SpokeCategory shall this Spoke be displayed
+                     in the Hub?  This is a reference to a Hub subclass (not an
+                     object, but the class itself).  If no category is given,
+                     this Spoke will not be displayed.  Note that category is
+                     not required for any Spokes appearing before or after a
+                     Hub.
+       icon       -- The name of the icon to be displayed in the SpokeSelector
+                     widget corresponding to this Spoke instance.  If no icon
+                     is given, the default from SpokeSelector will be used.
+       title      -- The title to be displayed in the SpokeSelector widget
+                     corresponding to this Spoke instance.  If no title is
+                     given, the default from SpokeSelector will be used.
+    """
+    category = None
+    icon = None
+    title = None
+
+    def __init__(self, data, storage, payload, instclass):
+        """Create a new Spoke instance.
+
+           The arguments this base class accepts defines the API that spokes
+           have to work with.  A Spoke does not get free reign over everything
+           in the anaconda class, as that would be a big mess.  Instead, a
+           Spoke may count on the following:
+
+           data         -- An instance of a pykickstart Handler object.  The
+                           Spoke uses this to populate its UI with defaults
+                           and to pass results back after it has run.
+           storage      -- An instance of storage.Storage.  This is useful for
+                           determining what storage devices are present and how
+                           they are configured.
+           payload      -- An instance of a packaging.Payload subclass.  This
+                           is useful for displaying and selecting packages to
+                           install, and in carrying out the actual installation.
+           instclass    -- An instance of a BaseInstallClass subclass.  This
+                           is useful for determining distribution-specific
+                           installation information like default package
+                           selections and default partitioning.
+        """
+        if self.__class__ is Spoke:
+            raise TypeError("Spoke is an abstract class")
+
+        UIObject.__init__(self, data)
+        self.storage = storage
+        self.payload = payload
+        self.instclass = instclass
+        self.applyOnSkip = False
+
+    def apply(self):
+        """Apply the selections made on this Spoke to the object's preset
+           data object.  This method must be provided by every subclass.
+        """
+        raise NotImplementedError
+
+    @property
+    def completed(self):
+        """Has this spoke been visited and completed?  If not, a special warning
+           icon will be shown on the Hub beside the spoke, and a highlighted
+           message will be shown at the bottom of the Hub.  Installation will not
+           be allowed to proceed until all spokes are complete.
+        """
+        return False
+
+    def execute(self):
+        """Cause the data object to take effect on the target system.  This will
+           usually be as simple as calling one or more of the execute methods on
+           the data object.  This method does not need to be provided by all
+           subclasses.
+
+           This method will be called in two different places:  (1) Immediately
+           after initialize on kickstart installs.  (2) Immediately after apply
+           in all cases.
+        """
+        pass
+
+    def initialize(self):
+        UIObject.initialize(self)
+
+    @property
+    def status(self):
+        """Given the current status of whatever this Spoke configures, return
+           a very brief string.  The purpose of this is to display something
+           on the Hub under the Spoke's title so the user can tell at a glance
+           how things are configured.
+
+           A spoke's status line on the Hub can also be overloaded to provide
+           information about why a Spoke is not yet ready, or if an error has
+           occurred when setting it up.  This can be done by calling
+           send_message from pyanaconda.ui.gui.communication with the target
+           Spoke's class name and the message to be displayed.
+
+           If the Spoke was not yet ready when send_message was called, the
+           message will be overwritten with the value of this status property
+           when the Spoke becomes ready.
+        """
+        raise NotImplementedError
+
+class NormalSpoke(Spoke):
+    priority = 100
+
+    """A NormalSpoke is a Spoke subclass that is displayed when the user
+       selects something on a Hub.  This is what most Spokes in anaconda will
+       be based on.
+
+       From a layout perspective, a NormalSpoke takes up the entire screen
+       therefore hiding the Hub and its action area.  The NormalSpoke also
+       provides some basic navigation information (where you are, what you're
+       installing, how to get back to the Hub) at the top of the screen.
+    """
+    def __init__(self, data, storage, payload, instclass):
+        """Create a NormalSpoke instance."""
+        if self.__class__ is NormalSpoke:
+            raise TypeError("NormalSpoke is an abstract class")
+
+        Spoke.__init__(self, data, storage, payload, instclass)
+        self.selector = None
+
+    @property
+    def indirect(self):
+        """If this property returns True, then this spoke is considered indirect.
+           An indirect spoke is one that can only be reached through another spoke
+           instead of directly through the hub.  One example of this is the
+           custom partitioning spoke, which may only be accessed through the
+           install destination spoke.
+
+           Indirect spokes do not need to provide a completed or status property.
+
+           For most spokes, overriding this property is unnecessary.
+        """
+        return False
+
+    @property
+    def ready(self):
+        """Returns True if the Spoke has all the information required to be
+           displayed.  Almost all spokes should keep the default value here.
+           Only override this method if the Spoke requires some potentially
+           long-lived process (like storage probing) before it's ready.
+
+           A Spoke may be marked as ready or not by calling send_ready or
+           send_not_ready from pyanaconda.ui.gui.communication with the
+           target Spoke's class name.
+
+           While a Spoke is not ready, a progress message may be shown to
+           give the user some feedback.  See the status property for details.
+        """
+        return True
+
+class StandaloneSpoke(NormalSpoke):
+    """A StandaloneSpoke is a Spoke subclass that is displayed apart from any
+       Hub.  It is suitable to be used as a Welcome screen.
+
+       From a layout perspective, a StandaloneSpoke provides a full screen
+       interface.  However, it also provides navigation information at the top
+       and bottom of the screen that makes it look like the StandaloneSpoke
+       fits into some other UI element.
+
+       Class attributes:
+
+       preForHub/postForHub   -- A reference to a Hub subclass this Spoke is
+                                 either a pre or post action for.  Only one of
+                                 these may be set at a time.  Note that all
+                                 post actions will be run for one hub before
+                                 any pre actions for the next.
+       priority               -- This value is used to sort pre and post
+                                 actions.  The lower a value, the earlier it
+                                 will be run.  So a value of 0 for a post action
+                                 ensures it will run immediately after a Hub,
+                                 while a value of 0 for a pre actions means
+                                 it will run as the first thing.
+    """
+    preForHub = None
+    postForHub = None
+
+    def __init__(self, data, storage, payload, instclass):
+        """Create a StandaloneSpoke instance."""
+        if self.__class__ is StandaloneSpoke:
+            raise TypeError("StandaloneSpoke is an abstract class")
+
+        if self.preForHub and self.postForHub:
+            raise AttributeError("StandaloneSpoke instance %s may not have both preForHub and postForHub set" % self)
+
+        Spoke.__init__(self, data, storage, payload, instclass)
+
+
+
+class PersonalizationSpoke(Spoke):
+    """A PersonalizationSpoke is a Spoke subclass that is displayed when the
+       user selects something on the Hub during package installation.
+
+       From a layout perspective, a PersonalizationSpoke takes up the middle
+       of the screen therefore hiding the Hub but leaving its action area
+       displayed.  This allows the user to continue seeing package installation
+       progress being made.  The PersonalizationSpoke also provides the same
+       basic navigation information at the top of the screen as a NormalSpoke.
+    """
+    def __init__(self, data, storage, payload, instclass):
+        """Create a PersonalizationSpoke instance."""
+        if self.__class__ is PersonalizationSpoke:
+            raise TypeError("PersonalizationSpoke is an abstract class")
+
+        Spoke.__init__(self, data, storage, payload, instclass)
+
+class Hub(UIObject):
+    """A Hub is an overview UI screen.  A Hub consists of one or more grids of
+       configuration options that the user may choose from.  Each grid is
+       provided by a SpokeCategory, and each option is provided by a Spoke.
+       When the user dives down into a Spoke and is finished interacting with
+       it, they are returned to the Hub.
+
+       Some Spokes are required.  The user must interact with all required
+       Spokes before they are allowed to proceed to the next stage of
+       installation.
+
+       From a layout perspective, a Hub is the entirety of the screen, though
+       the screen itself can be roughly divided into thirds.  The top third is
+       some basic navigation information (where you are, what you're
+       installing).  The middle third is the grid of Spokes.  The bottom third
+       is an action area providing additional buttons (quit, continue) or
+       progress information (during package installation).
+
+       Installation may consist of multiple chained Hubs, or Hubs with
+       additional standalone screens either before or after them.
+    """
+
+    def __init__(self, data, storage, payload, instclass):
+        """Create a new Hub instance.
+
+           The arguments this base class accepts defines the API that Hubs
+           have to work with.  A Hub does not get free reign over everything
+           in the anaconda class, as that would be a big mess.  Instead, a
+           Hub may count on the following:
+
+           data         -- An instance of a pykickstart Handler object.  The
+                           Hub uses this to populate its UI with defaults
+                           and to pass results back after it has run.
+           storage      -- An instance of storage.Storage.  This is useful for
+                           determining what storage devices are present and how
+                           they are configured.
+           payload      -- An instance of a packaging.Payload subclass.  This
+                           is useful for displaying and selecting packages to
+                           install, and in carrying out the actual installation.
+           instclass    -- An instance of a BaseInstallClass subclass.  This
+                           is useful for determining distribution-specific
+                           installation information like default package
+                           selections and default partitioning.
+        """
+        UIObject.__init__(self, data)
+
+        self._spokes = {}
+        self.storage = storage
+        self.payload = payload
+        self.instclass = instclass
+
+def collect(module_pattern, path, pred):
+    """Traverse the directory (given by path), import all files as a module
+       module_pattern % filename and find all classes withing that match
+       the given predicate.  This is then returned as a list of classes.
+
+       It is suggested you use collect_categories or collect_spokes instead of
+       this lower-level method.
+
+       :param module_pattern: the full name pattern (pyanaconda.ui.gui.spokes.%s)
+                              of modules we about to import from path
+       :type module_pattern: string
+
+       :param path: the directory we are picking up modules from
+       :type path: string
+
+
+       :param pred: function which marks classes as good to import
+       :type pred: function with one argument returning True or False
+    """
+
+    retval = []
+    for module_file in os.listdir(path):
+        if not module_file.endswith(".py") or module_file == "__init__.py":
+            continue
+
+        mod_name = module_file[:-3]
+        module = importlib.import_module(module_pattern % mod_name)
+
+        p = lambda obj: inspect.isclass(obj) and pred(obj)
+
+        for (name, val) in inspect.getmembers(module, p):
+            retval.append(val)
+
+    return retval
diff --git a/pyanaconda/ui/gui/__init__.py b/pyanaconda/ui/gui/__init__.py
index 5887047..5ebb53d 100644
--- a/pyanaconda/ui/gui/__init__.py
+++ b/pyanaconda/ui/gui/__init__.py
@@ -21,7 +21,7 @@
 import importlib, inspect, os, sys
 import meh.ui.gui

-from pyanaconda.ui import UserInterface
+from pyanaconda.ui import UserInterface, common, collect
 from pyanaconda.ui.gui.utils import enlightbox

 import gettext
@@ -54,16 +54,8 @@ class GraphicalUserInterface(UserInterface):
         self._hubs.extend([SummaryHub, ProgressHub])

         # First, grab a list of all the standalone spokes.
-        standalones = collect("spokes", lambda obj: issubclass(obj, StandaloneSpoke) and \
-                                                    getattr(obj, "preForHub", False) or getattr(obj, "postForHub", False))
-
-        actionClasses = []
-        for hub in self._hubs:
-            actionClasses.extend(sorted(filter(lambda obj: getattr(obj, "preForHub", None) == hub, standalones),
-                                        key=lambda obj: obj.priority))
-            actionClasses.append(hub)
-            actionClasses.extend(sorted(filter(lambda obj: getattr(obj, "postForHub", None) == hub, standalones),
-                                        key=lambda obj: obj.priority))
+        path = os.path.join(os.path.dirname(__file__), "spokes")
+        actionClasses = self.getActionClasses("pyanaconda.ui.gui.spokes.%s", path, self._hubs, StandaloneSpoke)

         # Instantiate all hubs and their pre/post standalone spokes, passing
         # the arguments defining our spoke API and setting up continue/quit
@@ -195,8 +187,8 @@ class GraphicalUserInterface(UserInterface):
         if rc == 1:
             sys.exit(0)

-class UIObject(object):
-    """This is the base class from which all other UI classes are derived.  It
+class GUIObject(common.UIObject):
+    """This is the base class from which all other GUI classes are derived.  It
        thus contains only attributes and methods that are common to everything
        else.  It should not be directly instantiated.

@@ -251,8 +243,10 @@ class UIObject(object):
                        spoke off a hub.  They can only skip to the hub
                        itself.
         """
-        if self.__class__ is UIObject:
-            raise TypeError("UIObject is an abstract class")
+        common.UIObject.__init__(self, data)
+
+        if self.__class__ is GUIObject:
+            raise TypeError("GUIObject is an abstract class")

         # This couldn't possibly be a bigger hack job.  This structure holds the
         # untranslated strings out of each widget.  retranslate works by taking the
@@ -262,7 +256,6 @@ class UIObject(object):
         # original English, so we'd be looking up translations by translations.
         self._origStrings = {}

-        self.data = data
         self.skipTo = None
         self.applyOnSkip = False

@@ -311,15 +304,6 @@ class UIObject(object):

         _screenshotIndex += 1

-    def initialize(self):
-        """Perform whatever actions are necessary to pre-fill the UI with
-           values.  This method is called only once, after the object is
-           created.  The difference between this method and __init__ is that
-           this method may take a long time (especially for NormalSpokes) and
-           thus may be run in its own thread.
-        """
-        pass
-
     def retranslate(self):
         """This method should be called when the current language is changed
            in order to update the UI for the new language.  Since we don't get
@@ -353,39 +337,6 @@ class UIObject(object):
                 xlated = _(before)
                 getattr(obj, funcs[1])(xlated)

-    def refresh(self):
-        """Perform whatever actions are necessary to reset the UI immediately
-           before it is displayed.  This method is called every time a screen
-           is shown, which could potentially be several times in the case of a
-           NormalSpoke.  Thus, it's important to not do things like populate
-           stores (which could result in the store having duplicate entries) or
-           anything that takes a long time (as that will result in a delay
-           between the user's action and showing the results).
-
-           For anything potentially long-lived, use the initialize method.
-        """
-        pass
-
-    @property
-    def showable(self):
-        """Should this object even be shown?  This method is useful for checking
-           some precondition before this screen is shown.  If False is returned,
-           the screen will be skipped and the object destroyed.
-        """
-        return True
-
-    def teardown(self):
-        """Perform whatever actions are necessary to clean up after this object
-           is done.  It's not necessary for every subclass to have an instance
-           of this method.
-
-           NOTE:  It is important for this method to not destroy self.window if
-           you are making a Spoke or Hub subclass.  It is assumed that once
-           these are instantiated, they live until the program terminates.  This
-           is required for various status notifications.
-        """
-        pass
-
     @property
     def window(self):
         """Return the top-level object out of the GtkBuilder representation
@@ -399,7 +350,7 @@ class UIObject(object):

         return self._window

-class QuitDialog(UIObject):
+class QuitDialog(GUIObject):
     builderObjects = ["quitDialog"]
     mainWidgetName = "quitDialog"
     uiFile = "main.glade"
@@ -407,27 +358,3 @@ class QuitDialog(UIObject):
     def run(self):
         rc = self.window.run()
         return rc
-
-def collect(subpath, pred):
-    """Traverse the subdirectory (given by subpath) of this module's current
-       directory and find all classes that math the given category.  This is
-       then returned as a list of classes.  If category is None, this method
-       will return a list of all matching subclasses.
-
-       It is suggested you use collect_categories or collect_spokes instead of
-       this lower-level method.
-    """
-    retval = []
-    for module_file in os.listdir(os.path.dirname(__file__) + "/" + subpath):
-        if not module_file.endswith(".py") or module_file in [__file__, "__init__.py"]:
-            continue
-
-        mod_name = module_file[:-3]
-        module = importlib.import_module("pyanaconda.ui.gui.%s.%s" % (subpath, mod_name))
-
-        p = lambda obj: inspect.isclass(obj) and pred(obj)
-
-        for (name, val) in inspect.getmembers(module, p):
-            retval.append(val)
-
-    return retval
diff --git a/pyanaconda/ui/gui/categories/__init__.py b/pyanaconda/ui/gui/categories/__init__.py
index 31739d7..1e43148 100644
--- a/pyanaconda/ui/gui/categories/__init__.py
+++ b/pyanaconda/ui/gui/categories/__init__.py
@@ -21,6 +21,7 @@

 N_ = lambda x: x

+import os.path
 from pyanaconda.ui.gui import collect

 __all__ = ["SpokeCategory", "collect_categories"]
@@ -72,4 +73,4 @@ class SpokeCategory(object):

 def collect_categories():
     """Return a list of all category subclasses."""
-    return collect("categories", lambda obj: getattr(obj, "displayOnHub", None) != None)
+    return collect("pyanaconda.ui.gui.categories.%s", os.path.dirname(__file__), lambda obj: getattr(obj, "displayOnHub", None) != None)
diff --git a/pyanaconda/ui/gui/hubs/__init__.py b/pyanaconda/ui/gui/hubs/__init__.py
index 110f150..f5e211f 100644
--- a/pyanaconda/ui/gui/hubs/__init__.py
+++ b/pyanaconda/ui/gui/hubs/__init__.py
@@ -26,11 +26,12 @@ from gi.repository import GLib

 from pyanaconda.flags import flags

-from pyanaconda.ui.gui import UIObject
+from pyanaconda.ui import common
+from pyanaconda.ui.gui import GUIObject
 from pyanaconda.ui.gui.categories import collect_categories
 from pyanaconda.ui.gui.spokes import StandaloneSpoke, collect_spokes

-class Hub(UIObject):
+class Hub(GUIObject, common.Hub):
     """A Hub is an overview UI screen.  A Hub consists of one or more grids of
        configuration options that the user may choose from.  Each grid is
        provided by a SpokeCategory, and each option is provided by a Spoke.
@@ -74,7 +75,8 @@ class Hub(UIObject):
                            installation information like default package
                            selections and default partitioning.
         """
-        UIObject.__init__(self, data)
+        GUIObject.__init__(self, data)
+        common.Hub.__init__(self, data, storage, payload, instclass)

         self._autoContinue = False
         self._incompleteSpokes = []
@@ -82,10 +84,6 @@ class Hub(UIObject):
         self._notReadySpokes = []
         self._spokes = {}

-        self.storage = storage
-        self.payload = payload
-        self.instclass = instclass
-
     def _runSpoke(self, action):
         from gi.repository import Gtk

@@ -287,7 +285,7 @@ class Hub(UIObject):
         return True

     def refresh(self):
-        UIObject.refresh(self)
+        GUIObject.refresh(self)
         self._createBox()

         self._update_spoke_id = GLib.timeout_add_seconds(1, self._update_spokes)
diff --git a/pyanaconda/ui/gui/spokes/__init__.py b/pyanaconda/ui/gui/spokes/__init__.py
index 2cd3d1b..61aea5c 100644
--- a/pyanaconda/ui/gui/spokes/__init__.py
+++ b/pyanaconda/ui/gui/spokes/__init__.py
@@ -19,79 +19,17 @@
 # Red Hat Author(s): Chris Lumens <clumens at redhat.com>
 #

-from pyanaconda.ui.gui import UIObject, collect
+from pyanaconda.ui import collect, common
+from pyanaconda.ui.gui import GUIObject
+import os.path

 __all__ = ["Spoke", "StandaloneSpoke", "NormalSpoke", "PersonalizationSpoke",
            "collect_spokes"]

-class Spoke(UIObject):
-    """A Spoke is a single configuration screen.  There are several different
-       places where a Spoke can be displayed, each of which will have its own
-       unique class.  A Spoke is typically used when an element in the Hub is
-       selected but can also be displayed before a Hub or between multiple
-       Hubs.
-
-       What amount of the UI layout a Spoke provides depends upon where it is
-       to be shown.  Regardless, the UI of a Spoke should be given by an
-       interface description file like glade as often as possible, though this
-       is not a strict requirement.
-
-       Class attributes:
-
-       category   -- Under which SpokeCategory shall this Spoke be displayed
-                     in the Hub?  This is a reference to a Hub subclass (not an
-                     object, but the class itself).  If no category is given,
-                     this Spoke will not be displayed.  Note that category is
-                     not required for any Spokes appearing before or after a
-                     Hub.
-       icon       -- The name of the icon to be displayed in the SpokeSelector
-                     widget corresponding to this Spoke instance.  If no icon
-                     is given, the default from SpokeSelector will be used.
-       title      -- The title to be displayed in the SpokeSelector widget
-                     corresponding to this Spoke instance.  If no title is
-                     given, the default from SpokeSelector will be used.
-    """
-    category = None
-    icon = None
-    title = None
-
+class Spoke(GUIObject, common.Spoke):
     def __init__(self, data, storage, payload, instclass):
-        """Create a new Spoke instance.
-
-           The arguments this base class accepts defines the API that spokes
-           have to work with.  A Spoke does not get free reign over everything
-           in the anaconda class, as that would be a big mess.  Instead, a
-           Spoke may count on the following:
-
-           ksdata       -- An instance of a pykickstart Handler object.  The
-                           Spoke uses this to populate its UI with defaults
-                           and to pass results back after it has run.
-           storage      -- An instance of storage.Storage.  This is useful for
-                           determining what storage devices are present and how
-                           they are configured.
-           payload      -- An instance of a packaging.Payload subclass.  This
-                           is useful for displaying and selecting packages to
-                           install, and in carrying out the actual installation.
-           instclass    -- An instance of a BaseInstallClass subclass.  This
-                           is useful for determining distribution-specific
-                           installation information like default package
-                           selections and default partitioning.
-
-           applyOnSkip  -- Run the apply method, even in the case where skipTo
-                           is set.  You usually don't want this set to True, since
-                           the skipTo attribute means to jump to another spoke
-                           right away.  However, there are instances where running
-                           apply anyway can be useful.
-        """
-        if self.__class__ is Spoke:
-            raise TypeError("Spoke is an abstract class")
-
-        UIObject.__init__(self, data)
-        self.storage = storage
-        self.payload = payload
-        self.instclass = instclass
-
-        self.applyOnSkip = False
+        GUIObject.__init__(self, data)
+        common.Spoke.__init__(self, data, storage, payload, instclass)

     def apply(self):
         """Apply the selections made on this Spoke to the object's preset
@@ -121,67 +59,11 @@ class Spoke(UIObject):
         pass

     def initialize(self):
-        UIObject.initialize(self)
+        GUIObject.initialize(self)

         self.window.set_property("window-name", self.title or "")

-    @property
-    def status(self):
-        """Given the current status of whatever this Spoke configures, return
-           a very brief string.  The purpose of this is to display something
-           on the Hub under the Spoke's title so the user can tell at a glance
-           how things are configured.
-
-           A spoke's status line on the Hub can also be overloaded to provide
-           information about why a Spoke is not yet ready, or if an error has
-           occurred when setting it up.  This can be done by calling
-           send_message from pyanaconda.ui.gui.communication with the target
-           Spoke's class name and the message to be displayed.
-
-           If the Spoke was not yet ready when send_message was called, the
-           message will be overwritten with the value of this status property
-           when the Spoke becomes ready.
-        """
-        raise NotImplementedError
-
-class StandaloneSpoke(Spoke):
-    """A StandaloneSpoke is a Spoke subclass that is displayed apart from any
-       Hub.  It is suitable to be used as a Welcome screen.
-
-       From a layout perspective, a StandaloneSpoke provides a full screen
-       interface.  However, it also provides navigation information at the top
-       and bottom of the screen that makes it look like the StandaloneSpoke
-       fits into some other UI element.
-
-       Class attributes:
-
-       preForHub/postForHub   -- A reference to a Hub subclass this Spoke is
-                                 either a pre or post action for.  Only one of
-                                 these may be set at a time.  Note that all
-                                 post actions will be run for one hub before
-                                 any pre actions for the next.
-       priority               -- This value is used to sort pre and post
-                                 actions.  The lower a value, the earlier it
-                                 will be run.  So a value of 0 for a post action
-                                 ensures it will run immediately after a Hub,
-                                 while a value of 0 for a pre actions means
-                                 it will run as the first thing.
-    """
-    preForHub = None
-    postForHub = None
-
-    priority = 100
-
-    def __init__(self, data, storage, payload, instclass):
-        """Create a StandaloneSpoke instance."""
-        if self.__class__ is StandaloneSpoke:
-            raise TypeError("StandaloneSpoke is an abstract class")
-
-        if self.preForHub and self.postForHub:
-            raise AttributeError("StandaloneSpoke instance %s may not have both preForHub and postForHub set" % self)
-
-        Spoke.__init__(self, data, storage, payload, instclass)
-
+class StandaloneSpoke(Spoke, common.StandaloneSpoke):
     def _on_continue_clicked(self, cb):
         self.apply()
         cb()
@@ -192,79 +74,18 @@ class StandaloneSpoke(Spoke):
         elif event == "quit":
             self.window.connect("quit-clicked", lambda *args: cb())

-class NormalSpoke(Spoke):
-    """A NormalSpoke is a Spoke subclass that is displayed when the user
-       selects something on a Hub.  This is what most Spokes in anaconda will
-       be based on.
-
-       From a layout perspective, a NormalSpoke takes up the entire screen
-       therefore hiding the Hub and its action area.  The NormalSpoke also
-       provides some basic navigation information (where you are, what you're
-       installing, how to get back to the Hub) at the top of the screen.
-    """
-    def __init__(self, data, storage, payload, instclass):
-        """Create a NormalSpoke instance."""
-        if self.__class__ is NormalSpoke:
-            raise TypeError("NormalSpoke is an abstract class")
-
-        Spoke.__init__(self, data, storage, payload, instclass)
-        self.selector = None
-
-    @property
-    def indirect(self):
-        """If this property returns True, then this spoke is considered indirect.
-           An indirect spoke is one that can only be reached through another spoke
-           instead of directly through the hub.  One example of this is the
-           custom partitioning spoke, which may only be accessed through the
-           install destination spoke.
-
-           Indirect spokes do not need to provide a completed or status property.
-
-           For most spokes, overriding this property is unnecessary.
-        """
-        return False
-
-    @property
-    def ready(self):
-        """Returns True if the Spoke has all the information required to be
-           displayed.  Almost all spokes should keep the default value here.
-           Only override this method if the Spoke requires some potentially
-           long-lived process (like storage probing) before it's ready.
-
-           A Spoke may be marked as ready or not by calling send_ready or
-           send_not_ready from pyanaconda.ui.gui.communication with the
-           target Spoke's class name.
-
-           While a Spoke is not ready, a progress message may be shown to
-           give the user some feedback.  See the status property for details.
-        """
-        return True
-
+class NormalSpoke(Spoke, common.NormalSpoke):
     def on_back_clicked(self, window):
         from gi.repository import Gtk

         self.window.hide()
         Gtk.main_quit()

-class PersonalizationSpoke(Spoke):
-    """A PersonalizationSpoke is a Spoke subclass that is displayed when the
-       user selects something on the Hub during package installation.
-
-       From a layout perspective, a PersonalizationSpoke takes up the middle
-       of the screen therefore hiding the Hub but leaving its action area
-       displayed.  This allows the user to continue seeing package installation
-       progress being made.  The PersonalizationSpoke also provides the same
-       basic navigation information at the top of the screen as a NormalSpoke.
-    """
-    def __init__(self, data, storage, payload, instclass):
-        """Create a PersonalizationSpoke instance."""
-        if self.__class__ is PersonalizationSpoke:
-            raise TypeError("PersonalizationSpoke is an abstract class")
-
-        Spoke.__init__(self, data, storage, payload, instclass)
+class PersonalizationSpoke(Spoke, common.PersonalizationSpoke):
+    pass

 def collect_spokes(category):
     """Return a list of all spoke subclasses that should appear for a given
        category.
     """
-    return collect("spokes", lambda obj: hasattr(obj, "category") and obj.category != None and obj.category.__name__ == category)
+    return collect("pyanaconda.ui.gui.spokes.%s", os.path.dirname(__file__), lambda obj: hasattr(obj, "category") and obj.category != None and obj.category.__name__ == category)
diff --git a/pyanaconda/ui/gui/spokes/custom.py b/pyanaconda/ui/gui/spokes/custom.py
index 2793957..c76146a 100644
--- a/pyanaconda/ui/gui/spokes/custom.py
+++ b/pyanaconda/ui/gui/spokes/custom.py
@@ -41,7 +41,7 @@ from pyanaconda.storage import Root
 from pyanaconda.storage.partitioning import doPartitioning
 from pyanaconda.storage.errors import StorageError

-from pyanaconda.ui.gui import UIObject
+from pyanaconda.ui.gui import GUIObject
 from pyanaconda.ui.gui.spokes import NormalSpoke
 from pyanaconda.ui.gui.spokes.storage import StorageChecker
 from pyanaconda.ui.gui.spokes.lib.cart import SelectedDisksDialog
@@ -55,13 +55,13 @@ __all__ = ["CustomPartitioningSpoke"]

 new_install_name = _("New %s %s Installation") % (productName, productVersion)

-class AddDialog(UIObject):
+class AddDialog(GUIObject):
     builderObjects = ["addDialog"]
     mainWidgetName = "addDialog"
     uiFile = "spokes/custom.glade"

     def __init__(self, *args, **kwargs):
-        UIObject.__init__(self, *args, **kwargs)
+        GUIObject.__init__(self, *args, **kwargs)
         self.size = Size(bytes=0)
         self.mountpoint = ""

@@ -80,12 +80,12 @@ class AddDialog(UIObject):
         self.window.destroy()

     def refresh(self):
-        UIObject.refresh(self)
+        GUIObject.refresh(self)

     def run(self):
         return self.window.run()

-class ConfirmDeleteDialog(UIObject):
+class ConfirmDeleteDialog(GUIObject):
     builderObjects = ["confirmDeleteDialog"]
     mainWidgetName = "confirmDeleteDialog"
     uiFile = "spokes/custom.glade"
@@ -97,7 +97,7 @@ class ConfirmDeleteDialog(UIObject):
         self.window.destroy()

     def refresh(self, mountpoint, device):
-        UIObject.refresh(self)
+        GUIObject.refresh(self)
         label = self.builder.get_object("confirmLabel")

         if mountpoint:
diff --git a/pyanaconda/ui/gui/spokes/datetime_spoke.py b/pyanaconda/ui/gui/spokes/datetime_spoke.py
index 314a886..0e2ca77 100644
--- a/pyanaconda/ui/gui/spokes/datetime_spoke.py
+++ b/pyanaconda/ui/gui/spokes/datetime_spoke.py
@@ -25,7 +25,7 @@ N_ = lambda x: x

 from gi.repository import AnacondaWidgets, GLib, Gtk

-from pyanaconda.ui.gui import UIObject
+from pyanaconda.ui.gui import GUIObject
 from pyanaconda.ui.gui.spokes import NormalSpoke
 from pyanaconda.ui.gui.categories.localization import LocalizationCategory
 from pyanaconda.ui.gui.utils import enlightbox
@@ -43,13 +43,13 @@ SERVER_QUERY = 2

 POOL_SERVERS_NOTE = _("Note: pool servers may not be available all the time")

-class NTPconfigDialog(UIObject):
+class NTPconfigDialog(GUIObject):
     builderObjects = ["ntpConfigDialog", "addImage", "serversStore"]
     mainWidgetName = "ntpConfigDialog"
     uiFile = "spokes/datetime_spoke.glade"

     def __init__(self, *args):
-        UIObject.__init__(self, *args)
+        GUIObject.__init__(self, *args)

         #used to ensure uniqueness of the threads' names
         self._threads_counter = 0
diff --git a/pyanaconda/ui/gui/spokes/keyboard.py b/pyanaconda/ui/gui/spokes/keyboard.py
index 48d8f8c..0de562b 100644
--- a/pyanaconda/ui/gui/spokes/keyboard.py
+++ b/pyanaconda/ui/gui/spokes/keyboard.py
@@ -26,7 +26,7 @@ N_ = lambda x: x

 from gi.repository import GLib, Gkbd, Gtk

-from pyanaconda.ui.gui import UIObject
+from pyanaconda.ui.gui import GUIObject
 from pyanaconda.ui.gui.spokes import NormalSpoke
 from pyanaconda.ui.gui.categories.localization import LocalizationCategory
 from pyanaconda.ui.gui.utils import enlightbox
@@ -38,14 +38,14 @@ def _show_layout(column, renderer, model, itr, wrapper):
     value = wrapper.name_to_show_str[model[itr][0]]
     renderer.set_property("text", value)

-class AddLayoutDialog(UIObject):
+class AddLayoutDialog(GUIObject):
     builderObjects = ["addLayoutDialog", "newLayoutStore",
                       "newLayoutStoreFilter", "newLayoutStoreSort"]
     mainWidgetName = "addLayoutDialog"
     uiFile = "spokes/keyboard.glade"

     def __init__(self, *args):
-        UIObject.__init__(self, *args)
+        GUIObject.__init__(self, *args)
         self._xkl_wrapper = keyboard.XklWrapper.get_instance()

     def matches_entry(self, model, itr, user_data=None):
@@ -363,4 +363,3 @@ class KeyboardSpoke(NormalSpoke):
             layouts_list.append(row[0])

         self._xkl_wrapper.replace_layouts(layouts_list)
-
diff --git a/pyanaconda/ui/gui/spokes/lib/cart.py b/pyanaconda/ui/gui/spokes/lib/cart.py
index c40b7f5..3e77a36 100644
--- a/pyanaconda/ui/gui/spokes/lib/cart.py
+++ b/pyanaconda/ui/gui/spokes/lib/cart.py
@@ -21,7 +21,7 @@

 from gi.repository import Gtk

-from pyanaconda.ui.gui import UIObject
+from pyanaconda.ui.gui import GUIObject
 from pyanaconda.storage.size import Size

 import gettext
@@ -39,7 +39,7 @@ def size_str(mb):

     return str(Size(spec=spec)).upper()

-class SelectedDisksDialog(UIObject):
+class SelectedDisksDialog(GUIObject):
     builderObjects = ["selected_disks_dialog", "disk_store"]
     mainWidgetName = "selected_disks_dialog"
     uiFile = "spokes/lib/cart.glade"
diff --git a/pyanaconda/ui/gui/spokes/network.py b/pyanaconda/ui/gui/spokes/network.py
index e0f3792..9142ad0 100644
--- a/pyanaconda/ui/gui/spokes/network.py
+++ b/pyanaconda/ui/gui/spokes/network.py
@@ -34,7 +34,7 @@

 from gi.repository import Gtk, AnacondaWidgets

-from pyanaconda.ui.gui import UIObject
+from pyanaconda.ui.gui import GUIObject
 from pyanaconda.ui.gui.spokes import NormalSpoke, StandaloneSpoke
 from pyanaconda.ui.gui.categories.software import SoftwareCategory
 from pyanaconda.ui.gui.hubs.summary import SummaryHub
diff --git a/pyanaconda/ui/gui/spokes/source.py b/pyanaconda/ui/gui/spokes/source.py
index 4474052..fd40b5c 100644
--- a/pyanaconda/ui/gui/spokes/source.py
+++ b/pyanaconda/ui/gui/spokes/source.py
@@ -32,7 +32,7 @@ import os.path
 from gi.repository import AnacondaWidgets, GLib, Gtk

 from pyanaconda.image import opticalInstallMedia, potentialHdisoSources
-from pyanaconda.ui.gui import UIObject, communication
+from pyanaconda.ui.gui import GUIObject, communication
 from pyanaconda.ui.gui.spokes import NormalSpoke
 from pyanaconda.ui.gui.categories.software import SoftwareCategory
 from pyanaconda.ui.gui.utils import enlightbox, gdk_threaded
@@ -45,7 +45,7 @@ MOUNTPOINT = "/mnt/install/isodir"
 METADATA_DOWNLOAD_MESSAGE = _("Downloading package metadata...")
 METADATA_ERROR_MESSAGE = _("Error downloading package metadata...")

-class ProxyDialog(UIObject):
+class ProxyDialog(GUIObject):
     builderObjects = ["proxyDialog"]
     mainWidgetName = "proxyDialog"
     uiFile = "spokes/source.glade"
@@ -89,7 +89,7 @@ class ProxyDialog(UIObject):
     def refresh(self):
         import re

-        UIObject.refresh(self)
+        GUIObject.refresh(self)

         self._proxyCheck = self.builder.get_object("enableProxyCheck")
         self._proxyInfoBox = self.builder.get_object("proxyInfoBox")
@@ -126,7 +126,7 @@ class ProxyDialog(UIObject):
     def run(self):
         self.window.run()

-class MediaCheckDialog(UIObject):
+class MediaCheckDialog(GUIObject):
     builderObjects = ["mediaCheckDialog"]
     mainWidgetName = "mediaCheckDialog"
     uiFile = "spokes/source.glade"
@@ -193,13 +193,13 @@ class MediaCheckDialog(UIObject):
 #     result from run(), the file path you use is relative to the root of the
 #     mounted partition.  In other words, it will not contain the
 #     "/mnt/isodir/install" part.  This is consistent with the rest of anaconda.
-class IsoChooser(UIObject):
+class IsoChooser(GUIObject):
     builderObjects = ["isoChooserDialog", "isoFilter"]
     mainWidgetName = "isoChooserDialog"
     uiFile = "spokes/source.glade"

     def refresh(self, currentFile=""):
-        UIObject.refresh(self)
+        GUIObject.refresh(self)
         self._chooser = self.builder.get_object("isoChooser")
         self._chooser.connect("current-folder-changed", self.on_folder_changed)
         self._chooser.set_filename(MOUNTPOINT + "/" + currentFile)
@@ -235,7 +235,7 @@ class IsoChooser(UIObject):
         if not d.startswith(MOUNTPOINT):
             chooser.set_current_folder(MOUNTPOINT)

-class AdditionalReposDialog(UIObject):
+class AdditionalReposDialog(GUIObject):
     builderObjects = ["additionalReposDialog", "peopleRepositories", "peopleRepositoriesFilter"]
     mainWidgetName = "additionalReposDialog"
     uiFile = "spokes/source.glade"
@@ -243,7 +243,7 @@ class AdditionalReposDialog(UIObject):
     typingTimeout = 1

     def __init__(self, *args, **kwargs):
-        UIObject.__init__(self, *args, **kwargs)
+        GUIObject.__init__(self, *args, **kwargs)

         self._filterTimer = None
         self._urlTimer = None
@@ -279,7 +279,7 @@ class AdditionalReposDialog(UIObject):
         self._sourceSelectionUrl = self.builder.get_object("addRepositorySelectUrl")

     def refresh(self, currentFile=""):
-        UIObject.refresh(self)
+        GUIObject.refresh(self)

     def run(self):
         retval = None
diff --git a/pyanaconda/ui/gui/spokes/storage.py b/pyanaconda/ui/gui/spokes/storage.py
index 6e5df44..d8cccd8 100644
--- a/pyanaconda/ui/gui/spokes/storage.py
+++ b/pyanaconda/ui/gui/spokes/storage.py
@@ -41,7 +41,7 @@
 from gi.repository import Gdk, Gtk
 from gi.repository import AnacondaWidgets

-from pyanaconda.ui.gui import UIObject, communication
+from pyanaconda.ui.gui import GUIObject, communication
 from pyanaconda.ui.gui.spokes import NormalSpoke
 from pyanaconda.ui.gui.spokes.lib.cart import SelectedDisksDialog
 from pyanaconda.ui.gui.categories.storage import StorageCategory
@@ -110,7 +110,7 @@ def size_str(mb):

     return str(Size(spec=spec)).upper()

-class InstallOptions1Dialog(UIObject):
+class InstallOptions1Dialog(GUIObject):
     builderObjects = ["options1_dialog"]
     mainWidgetName = "options1_dialog"
     uiFile = "spokes/storage.glade"
diff --git a/pyanaconda/ui/gui/tools/run-spoke.py b/pyanaconda/ui/gui/tools/run-spoke.py
index 5f5a44a..afddbd1 100755
--- a/pyanaconda/ui/gui/tools/run-spoke.py
+++ b/pyanaconda/ui/gui/tools/run-spoke.py
@@ -44,13 +44,13 @@ initThreading()
 # And get the name of the module which represents it
 if os.path.basename(sys.argv[0]) == "run-spoke.py":
     spokeModuleName = "pyanaconda.ui.gui.spokes.%s" % sys.argv[1]
-    from pyanaconda.ui.gui.spokes import Spoke
+    from pyanaconda.ui.common import Spoke
     spokeBaseClass = Spoke
     spokeText = "spoke"
     SpokeText = "Spoke"
 elif os.path.basename(sys.argv[0]) == "run-hub.py":
     spokeModuleName = "pyanaconda.ui.gui.hubs.%s" % sys.argv[1]
-    from pyanaconda.ui.gui.hubs import Hub
+    from pyanaconda.ui.common import Hub
     spokeBaseClass = Hub
     spokeText = "hub"
     SpokeText = "Hub"
@@ -76,10 +76,9 @@ except IndexError:
             if issubclass(v, spokeBaseClass) and v != spokeBaseClass:
                 spokeClassName = k
                 spokeClass = v
-                break
         except TypeError:
             pass
-
+
 if not spokeClass:
     try:
         spokeClass = getattr(spokeModule, spokeClassName)
diff --git a/pyanaconda/ui/tui/Makefile.am b/pyanaconda/ui/tui/Makefile.am
new file mode 100644
index 0000000..9cc0ba1
--- /dev/null
+++ b/pyanaconda/ui/tui/Makefile.am
@@ -0,0 +1,24 @@
+# Copyright (C) 2011  Red Hat, Inc.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published
+# by the Free Software Foundation; either version 2.1 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 Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+# Author: Chris Lumens <clumens at redhat.com>
+
+SUBDIRS = hubs spokes simpleline tools
+
+MAINTAINERCLEANFILES = Makefile.in
+
+pkgpyexecdir = $(pyexecdir)/py$(PACKAGE_NAME)
+tuidir        = $(pkgpyexecdir)/ui/tui
+tui_PYTHON    = *.py
diff --git a/pyanaconda/ui/tui/__init__.py b/pyanaconda/ui/tui/__init__.py
new file mode 100644
index 0000000..845a4a2
--- /dev/null
+++ b/pyanaconda/ui/tui/__init__.py
@@ -0,0 +1,182 @@
+# The main file for anaconda TUI interface
+#
+# Copyright (C) (2012)  Red Hat, Inc.
+#
+# This copyrighted material is made available to anyone wishing to use,
+# modify, copy, or redistribute it subject to the terms and conditions of
+# the GNU General Public License v.2, or (at your option) any later version.
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY expressed or implied, including the implied warranties 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, write to the
+# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
+# source code or documentation are not subject to the GNU General Public
+# License and may only be used or replicated with the express permission of
+# Red Hat, Inc.
+#
+# Red Hat Author(s): Martin Sivak <msivak at redhat.com>
+#
+
+from pyanaconda import ui
+from pyanaconda.ui import common
+import simpleline as tui
+from hubs.summary import SummaryHub
+from spokes import StandaloneSpoke
+
+class ErrorDialog(tui.UIScreen):
+    """Dialog screen for reporting errors to user."""
+
+    title = u"Error"
+
+    def __init__(self, app, message):
+        """
+        :param app: the running application reference
+        :type app: instance of App class
+
+        :param message: the message to show to the user
+        :type message: unicode
+        """
+
+        tui.UIScreen.__init__(self, app)
+        self._message = message
+
+    def refresh(self, args = None):
+        tui.UIScreen.refresh(self, args)
+        text = tui.TextWidget(self._message)
+        self._window.append(tui.CenterWidget(text))
+
+    def prompt(self, args = None):
+        return u"Press enter to exit."
+
+    def input(self, args, key):
+        """This dialog is closed by any input."""
+        self.close()
+
+class YesNoDialog(tui.UIScreen):
+    """Dialog screen for Yes - No questions."""
+
+    title = u"Question"
+
+    def __init__(self, app, message):
+        """
+        :param app: the running application reference
+        :type app: instance of App class
+
+        :param message: the message to show to the user
+        :type message: unicode
+        """
+
+        tui.UIScreen.__init__(self, app)
+        self._message = message
+        self._response = None
+
+    def refresh(self, args = None):
+        tui.UIScreen.refresh(self, args)
+        text = tui.TextWidget(self._message)
+        self._window.append(tui.CenterWidget(text))
+        self._window.append(u"")
+        return True
+
+    def prompt(self, args):
+        return u"Please respond 'yes' or 'no': "
+
+    def input(self, args, key):
+        if key == "yes":
+            self._response = True
+            self.close()
+            return None
+
+        elif key == "no":
+            self._response = False
+            self.close()
+            return None
+
+        else:
+            return False
+
+    @property
+    def answer(self):
+        """The response can be True (yes), False (no) or None (no response)."""
+        return self._response
+
+class TextUserInterface(ui.UserInterface):
+    """This is the main class for Text user interface."""
+
+    def __init__(self, storage, payload, instclass):
+        """
+        For detailed description of the arguments see
+        the parent class.
+
+        :param storage: storage backend reference
+        :type storage: instance of pyanaconda.Storage
+
+        :param payload: payload (usually yum) reference
+        :type payload: instance of payload handler
+
+        :param instclass: install class reference
+        :type instclass: instance of install class
+        """
+
+        ui.UserInterface.__init__(self, storage, payload, instclass)
+        self._app = None
+
+    def setup(self, data):
+        """Construct all the objects required to implement this interface.
+           This method must be provided by all subclasses.
+        """
+        self._app = tui.App(u"Anaconda", yes_or_no_question = YesNoDialog)
+        self._hubs = [SummaryHub]
+
+        # First, grab a list of all the standalone spokes.
+        path = os.path.join(os.path.dirname(__file__), "spokes")
+        actionClasses = self.getActionClasses("pyanaconda.ui.tui.spokes.%s", path, self._hubs, StandaloneSpoke)
+
+        for klass in actionClasses:
+            obj = klass(self._app, data, self.storage, self.payload, self.instclass)
+
+            # If we are doing a kickstart install, some standalone spokes
+            # could already be filled out.  In taht case, we do not want
+            # to display them.
+            if isinstance(obj, StandaloneSpoke) and obj.completed:
+                del(obj)
+                continue
+
+            self._app.schedule_window(obj)
+
+    def run(self):
+        """Run the interface.  This should do little more than just pass
+           through to something else's run method, but is provided here in
+           case more is needed.  This method must be provided by all subclasses.
+        """
+        self._app.run()
+
+    ###
+    ### MESSAGE HANDLING METHODS
+    ###
+    def showError(self, message):
+        """Display an error dialog with the given message.  After this dialog
+           is displayed, anaconda will quit.  There is no return value.  This
+           method must be implemented by all UserInterface subclasses.
+
+           In the code, this method should be used sparingly and only for
+           critical errors that anaconda cannot figure out how to recover from.
+        """
+        error_window = ErrorDialog(self._app, message)
+        self._app.switch_window(error_window)
+
+    def showYesNoQuestion(self, message):
+        """Display a dialog with the given message that presents the user a yes
+           or no choice.  This method returns True if the yes choice is selected,
+           and False if the no choice is selected.  From here, anaconda can
+           figure out what to do next.  This method must be implemented by all
+           UserInterface subclasses.
+
+           In the code, this method should be used sparingly and only for those
+           times where anaconda cannot make a reasonable decision.  We don't
+           want to overwhelm the user with choices.
+        """
+        question_window = YesNoDialog(self._app, message)
+        self._app.switch_window_modal(question_window)
+        return question_window.answer
diff --git a/pyanaconda/ui/tui/hubs/Makefile.am b/pyanaconda/ui/tui/hubs/Makefile.am
new file mode 100644
index 0000000..f099b76
--- /dev/null
+++ b/pyanaconda/ui/tui/hubs/Makefile.am
@@ -0,0 +1,24 @@
+# Copyright (C) 2011  Red Hat, Inc.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published
+# by the Free Software Foundation; either version 2.1 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 Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+# Author: Chris Lumens <clumens at redhat.com>
+
+SUBDIRS =
+
+MAINTAINERCLEANFILES = Makefile.in
+
+pkgpyexecdir = $(pyexecdir)/py$(PACKAGE_NAME)
+hubsdir        = $(pkgpyexecdir)/ui/tui/hubs
+hubs_PYTHON    = *.py
diff --git a/pyanaconda/ui/tui/hubs/__init__.py b/pyanaconda/ui/tui/hubs/__init__.py
new file mode 100644
index 0000000..87e9460
--- /dev/null
+++ b/pyanaconda/ui/tui/hubs/__init__.py
@@ -0,0 +1,96 @@
+# The base classes for Anaconda TUI Hubs
+#
+# Copyright (C) (2012)  Red Hat, Inc.
+#
+# This copyrighted material is made available to anyone wishing to use,
+# modify, copy, or redistribute it subject to the terms and conditions of
+# the GNU General Public License v.2, or (at your option) any later version.
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY expressed or implied, including the implied warranties 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, write to the
+# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
+# source code or documentation are not subject to the GNU General Public
+# License and may only be used or replicated with the express permission of
+# Red Hat, Inc.
+#
+# Red Hat Author(s): Martin Sivak <msivak at redhat.com>
+#
+from .. import simpleline as tui
+from pyanaconda.ui.tui.tuiobject import TUIObject
+from pyanaconda.ui.tui.spokes import collect_spokes
+from pyanaconda.ui import common
+
+class TUIHub(TUIObject, common.Hub):
+    """Base Hub class implementing the pyanaconda.ui.common.Hub interface.
+    It uses text based categories to look for relevant Spokes and manages
+    all the spokes it finds to have the proper category.
+
+    :param categories: list all the spoke categories to be displayed in this Hub
+    :type categories: list of strings
+
+    :param title: title for this Hub
+    :type title: unicode
+
+    """
+
+    categories = []
+    title = "Default HUB title"
+
+    def __init__(self, app, data, storage, payload, instclass):
+        TUIObject.__init__(self, app, data)
+        common.Hub.__init__(self, data, storage, payload, instclass)
+
+        self._spokes = {}     # holds spokes referenced by their class name
+        self._keys = {}       # holds spokes referenced by their user input key
+        self._spoke_count = 0
+
+        # look for spokes having category present in self.categories
+        for c in self.categories:
+            spokes = collect_spokes(c)
+
+            # sort them according to their priority
+            for s in sorted(spokes, key = lambda s: s.priority):
+                spoke = s(app, data, storage, payload, instclass)
+                spoke.initialize()
+
+                if not spoke.showable:
+                    spoke.teardown()
+                    del spoke
+                    continue
+
+                self._spoke_count += 1
+                self._keys[self._spoke_count] = spoke
+                self._spokes[spoke.__class__.__name__] = spoke
+
+
+    def refresh(self, args = None):
+        """This methods fills the self._window list by all the objects
+        we want shown on this screen. Title and Spokes mostly."""
+        TUIObject.refresh(self, args)
+
+        def _prep(i, w):
+            number = tui.TextWidget("%2d)" % i)
+            return tui.ColumnWidget([(3, [number]), (None, [w])], 1)
+
+        # split spokes to two columns
+        left = [_prep(i, w) for i,w in self._keys.iteritems() if i % 2 == 1]
+        right = [_prep(i, w) for i,w in self._keys.iteritems() if i % 2 == 0]
+
+        c = tui.ColumnWidget([(39, left), (39, right)], 2)
+        self._window.append(c)
+
+        return True
+
+    def input(self, args, key):
+        """Handle user input. Numbers are used to show a spoke, the rest is passed
+        to the higher level for processing."""
+        try:
+            number = int(key)
+            self.app.switch_screen_with_return(self._keys[number])
+            return None
+
+        except (ValueError, KeyError):
+            return key
diff --git a/pyanaconda/ui/tui/hubs/summary.py b/pyanaconda/ui/tui/hubs/summary.py
new file mode 100644
index 0000000..25d33f2
--- /dev/null
+++ b/pyanaconda/ui/tui/hubs/summary.py
@@ -0,0 +1,8 @@
+from pyanaconda.ui.tui.hubs import TUIHub
+
+import gettext
+_ = lambda x: gettext.ldgettext("anaconda", x)
+
+class SummaryHub(TUIHub):
+    title = _("Install hub")
+    categories = ["source", "localization", "destination", "password"]
diff --git a/pyanaconda/ui/tui/simpleline/Makefile.am b/pyanaconda/ui/tui/simpleline/Makefile.am
new file mode 100644
index 0000000..555931b
--- /dev/null
+++ b/pyanaconda/ui/tui/simpleline/Makefile.am
@@ -0,0 +1,24 @@
+# Copyright (C) 2011  Red Hat, Inc.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published
+# by the Free Software Foundation; either version 2.1 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 Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+# Author: Chris Lumens <clumens at redhat.com>
+
+SUBDIRS =
+
+MAINTAINERCLEANFILES = Makefile.in
+
+pkgpyexecdir = $(pyexecdir)/py$(PACKAGE_NAME)
+simplelinedir        = $(pkgpyexecdir)/ui/tui/simpleline
+simpleline_PYTHON    = *.py
diff --git a/pyanaconda/ui/tui/simpleline/__init__.py b/pyanaconda/ui/tui/simpleline/__init__.py
new file mode 100644
index 0000000..e290e4a
--- /dev/null
+++ b/pyanaconda/ui/tui/simpleline/__init__.py
@@ -0,0 +1,23 @@
+# Library containing the TUI framework for Anaconda installer.
+#
+# Copyright (C) 2012  Red Hat, Inc.
+#
+# This copyrighted material is made available to anyone wishing to use,
+# modify, copy, or redistribute it subject to the terms and conditions of
+# the GNU General Public License v.2, or (at your option) any later version.
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY expressed or implied, including the implied warranties 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, write to the
+# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
+# source code or documentation are not subject to the GNU General Public
+# License and may only be used or replicated with the express permission of
+# Red Hat, Inc.
+#
+# Red Hat Author(s): Martin Sivak <msivak at redhat.com>
+#
+
+from base import *
+from widgets import *
diff --git a/pyanaconda/ui/tui/simpleline/base.py b/pyanaconda/ui/tui/simpleline/base.py
new file mode 100644
index 0000000..edcb08f
--- /dev/null
+++ b/pyanaconda/ui/tui/simpleline/base.py
@@ -0,0 +1,608 @@
+# Base classes for the Anaconda TUI framework.
+#
+# Copyright (C) 2012  Red Hat, Inc.
+#
+# This copyrighted material is made available to anyone wishing to use,
+# modify, copy, or redistribute it subject to the terms and conditions of
+# the GNU General Public License v.2, or (at your option) any later version.
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY expressed or implied, including the implied warranties 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, write to the
+# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
+# source code or documentation are not subject to the GNU General Public
+# License and may only be used or replicated with the express permission of
+# Red Hat, Inc.
+#
+# Red Hat Author(s): Martin Sivak <msivak at redhat.com>
+#
+
+__all__ = ["App", "UIScreen", "Widget"]
+
+import readline
+
+import gettext
+_ = lambda x: gettext.ldgettext("anaconda", x)
+
+class ExitAllMainLoops(Exception):
+    """This exception ends the whole App mainloop structure. App.run() quits
+       after it is processed."""
+    pass
+
+class ExitMainLoop(Exception):
+    """This exception ends the outermost mainloop. Used internally when dialogs
+       close."""
+    pass
+
+
+class App(object):
+    """This is the main class for TUI screen handling. It is responsible for
+       mainloop control and keeping track of the screen stack.
+
+       Screens are organized in stack structure so it is possible to return
+       to caller when dialog or sub-screen closes.
+
+       It supports four window transitions:
+       - show new screen replacing the current one (linear progression)
+       - show new screen keeping the current one in stack (hub & spoke)
+       - show new screen and wait for it to end (dialog)
+       - close current window and return to the next one in stack
+       """
+
+    START_MAINLOOP = True
+    STOP_MAINLOOP = False
+    NOP = None
+
+    def __init__(self, title, yes_or_no_question = None, width = 80):
+        """
+        :param title: application title for whenever we need to display app name
+        :type title: unicode
+
+        :param yes_or_no_question: UIScreen object class used for Quit dialog
+        :type yes_or_no_question: class UIScreen accepting additional message arg
+
+        :param width: screen width for rendering purposes
+        :type width: int
+        """
+
+        self._header = title
+        self._spacer = "\n".join(2*[width*"="])
+        self._width = width
+        self.quit_question = yes_or_no_question
+
+        # screen stack contains triplets
+        #  UIScreen to show
+        #  arguments for it's show method
+        #  value indicating whether new mainloop is needed
+        #   - None = do nothing
+        #   - True = execute new loop
+        #   - False = already running loop, exit when window closes
+        self._screens = []
+
+    def switch_screen(self, ui, args = None):
+        """Schedules a screen to replace the current one.
+
+        :param ui: screen to show
+        :type ui: instance of UIScreen
+
+        :param args: optional argument to pass to ui's refresh method (can be used to select what item should be displayed or so)
+        :type args: anything
+
+        """
+
+        oldscr, oldattr, oldloop = self._screens.pop()
+
+        # we have to keep the oldloop value so we stop
+        # dialog's mainloop if it ever uses switch_screen
+        self._screens.append((ui, args, oldloop))
+        self.redraw()
+
+    def switch_screen_with_return(self, ui, args = None):
+        """Schedules a screen to show, but keeps the current one in stack
+           to return to, when the new one is closed.
+
+        :param ui: screen to show
+        :type ui: UIScreen instance
+
+        :param args: optional argument, please see switch_screen for details
+        :type args: anything
+        """
+
+        self._screens.append((ui, args, self.NOP))
+        self.redraw()
+
+    def switch_screen_modal(self, ui, args = None):
+        """Starts a new screen right away, so the caller can collect data back.
+        When the new screen is closed, the caller is redisplayed.
+
+        This method does not return until the new screen is closed.
+
+        :param ui: screen to show
+        :type ui: UIScreen instance
+
+        :param args: optional argument, please see switch_screen for details
+        :type args: anything
+        """
+
+        # set the third item to True so new loop gets started
+        self._screens.append((ui, args, self.START_MAINLOOP))
+        self._do_redraw()
+
+    def schedule_screen(self, ui, args = None):
+        """Add screen to the bottom of the stack. This is mostly usefull
+        at the beginning to prepare the first screen hierarchy to display.
+
+        :param ui: screen to show
+        :type ui: UIScreen instance
+
+        :param args: optional argument, please see switch_screen for details
+        :type args: anything
+        """
+        self._screens.insert(0, (ui, args, self.NOP))
+
+    def close_screen(self, scr = None):
+        """Close the currently displayed screen and exit it's main loop
+        if necessary. Next screen from the stack is then displayed.
+
+        :param scr: if an UIScreen instance is passed it is checked to be the screen we are trying to close.
+        :type scr: UIScreen instance
+        """
+
+        oldscr, oldattr, oldloop = self._screens.pop()
+        if scr is not None:
+            assert oldscr == scr
+
+        # this cannot happen, if we are closing the window,
+        # the loop must have been running or not be there at all
+        assert oldloop != self.START_MAINLOOP
+
+        # we are in modal window, end it's loop
+        if oldloop == self.STOP_MAINLOOP:
+            raise ExitMainLoop()
+
+        if self._screens:
+            self.redraw()
+        else:
+            raise ExitMainLoop()
+
+    def _do_redraw(self):
+        """Draws the current screen and returns True if user input is requested.
+           If modal screen is requested, starts a new loop and initiates redraw after it ends.
+
+           :return: this method returns True if user input processing is requested
+           :rtype: bool
+           """
+
+        # there is nothing to display, exit
+        if not self._screens:
+            raise ExitMainLoop()
+
+        # get the screen from the top of the stack
+        screen, args, newloop = self._screens[-1]
+
+        # new mainloop is requested
+        if newloop == self.START_MAINLOOP:
+            # change the record to indicate mainloop is running
+            self._screens.pop()
+            self._screens.append((screen, args, self.STOP_MAINLOOP))
+            # start the mainloop
+            self._mainloop()
+            # after the mainloop ends, set the redraw flag
+            # and skip the input processing once, to redisplay the screen first
+            self.redraw()
+            input_needed = False
+        else:
+            # get the widget tree from the screen and show it in the screen
+            input_needed = screen.refresh(args)
+            screen.window.show_all()
+            self._redraw = False
+
+        return input_needed
+
+    def run(self):
+        """This methods starts the application. Do not use self.mainloop() directly
+        as run() handles all the required exceptions needed to keep nested mainloops
+        working."""
+
+        try:
+            self._mainloop()
+        except ExitAllMainLoops:
+            pass
+
+    def _mainloop(self):
+        """Single mainloop. Do not use directly, start the application using run()."""
+
+        # ask for redraw by default
+        self._redraw = True
+
+        # inital state
+        last_screen = None
+        error_counter = 0
+
+        # run until there is nothing else to display
+        while self._screens:
+            # if redraw is needed, separate the content on the screen from the
+            # stuff we are about to display now
+            if self._redraw:
+                print self._spacer
+
+            try:
+                # draw the screen if redraw is needed or the screen changed
+                # (unlikely to happen separately, but just be sure)
+                if self._redraw or last_screen != self._screens[-1]:
+                    # we have fresh screen, reset error counter
+                    error_counter = 0
+                    if not self._do_redraw():
+                        # if no input processing is requested, go for another cycle
+                        continue
+
+                last_screen = self._screens[-1][0]
+
+                # get the screen's prompt
+                prompt = last_screen.prompt(self._screens[-1][1])
+
+                # None means prompt handled the input by itself
+                # ask for redraw and continue
+                if prompt is None:
+                    self.redraw()
+                    continue
+
+                # get the input from user
+                c = self.raw_input(prompt)
+
+                # process the input, if it wasn't processed (valid)
+                # increment the error counter
+                if not self.input(self._screens[-1][1], c):
+                    error_counter += 1
+
+                # redraw the screen after 5 bad inputs
+                if error_counter >= 5:
+                    self.redraw()
+
+            # end just this loop
+            except ExitMainLoop:
+                break
+
+            # propagate higher to end all loops
+            # not really needed here, but we might need
+            # more processing in the future
+            except ExitAllMainLoops:
+                raise
+
+    def raw_input(self, prompt):
+        """This method reads one input from user. Its basic form has only one line,
+        but we might need to override it for more complex apps or testing."""
+        return raw_input(prompt)
+
+    def input(self, args, key):
+        """Method called internally to process unhandled input key presses.
+        Also handles the main quit and close commands.
+
+        :param args: optional argument passed from switch_screen calls
+        :type args: anything
+
+        :param key: the string entered by user
+        :type key: unicode
+
+        :return: True if key was processed, False if it was not recognized
+        :rtype: True|False
+
+        """
+
+        # delegate the handling to active screen first
+        if self._screens:
+            key = self._screens[-1][0].input(args, key)
+            if key is None:
+                return True
+
+        # global close command
+        if self._screens and (key == _('c')):
+            self.close_screen()
+            return True
+
+        # global quit command
+        elif self._screens and (key == _('q')):
+            if self.quit_question:
+                d = self.quit_question(self, _(u"Do you really want to quit?"))
+                self.switch_screen_modal(d)
+                if d.answer:
+                    raise ExitAllMainLoops()
+            return True
+
+        return False
+
+    def redraw(self):
+        """Set the redraw flag so the screen is refreshed as soon as possible."""
+        self._redraw = True
+
+    @property
+    def header(self):
+        return self._header
+
+    @property
+    def width(self):
+        """Return the total width of screen space we have available."""
+        return self._width
+
+class UIScreen(object):
+    """Base class representing one TUI Screen. Shares some API with anaconda's GUI
+    to make it easy for devs to create similar UI with the familiar API."""
+
+    # title line of the screen
+    title = u"Screen.."
+
+    def __init__(self, app):
+        """
+        :param app: reference to application main class
+        :type app: instance of class App
+        """
+
+        self._app = app
+
+        # list that holds the content to be printed out
+        self._window = []
+
+    def refresh(self, args = None):
+        """Method which prepares the content desired on the screen to self._window.
+
+        :param args: optional argument passed from switch_screen calls
+        :type args: anything
+
+        :return: has to return True if input processing is requested, otherwise
+                 the screen will get printed and the main loop will continue
+        :rtype: True|False
+        """
+
+        self._window = [self.title, u""]
+        return True
+
+    @property
+    def window(self):
+        """Return reference to the window instance. In TUI, just return self."""
+        return self
+
+    def show_all(self):
+        """Prepares all elements of self._window for output and then prints
+        them on the screen."""
+
+        for w in self._window:
+            if hasattr(w, "render"):
+                w.render(self.app.width)
+            print unicode(w)
+
+    show = show_all
+
+    def hide(self):
+        """This does nothing in TUI, it is here to make API similar."""
+        pass
+
+    def input(self, args, key):
+        """Method called to process input. If the input is not handled here, return it.
+
+        :param key: input string to process
+        :type key: unicode
+
+        :param args: optional argument passed from switch_screen calls
+        :type args: anything
+
+        :return: return True or None if key was handled, False if the screen should not
+                 process input on the App and key if you want it to.
+        :rtype: True|False|None|unicode
+        """
+
+        return key
+
+    def prompt(self, args = None):
+        """Return the text to be shown as prompt or handle the prompt and return None.
+
+        :param args: optional argument passed from switch_screen calls
+        :type args: anything
+
+        :return: returns text to be shown next to the prompt for input or None
+                 to skip further input processing
+        :rtype: unicode|None
+        """
+        return _(u"\tPlease make your choice from above ['q' to quit]: ")
+
+    @property
+    def app(self):
+        """The reference to this Screen's assigned App instance."""
+        return self._app
+
+    def close(self):
+        """Close the current screen."""
+        self.app.close_screen(self)
+
+class Widget(object):
+    def __init__(self, max_width = None, default = None):
+        """Initializes base Widgets buffer.
+
+           :param max_width: server as a hint about screen size to write method with default arguments
+           :type max_width: int
+
+           :param default: string containing the default content to fill the buffer with
+           :type default: string
+           """
+
+        self._buffer = []
+        if default:
+            self._buffer = [[c for c in l] for l in default.split("\n")]
+        self._max_width = max_width
+        self._cursor = (0, 0) # row, col
+
+    @property
+    def height(self):
+        """The current height of the internal buffer."""
+        return len(self._buffer)
+
+    @property
+    def width(self):
+        """The current width of the internal buffer
+           (id of the first empty column)."""
+        return reduce(lambda acc,l: max(acc, len(l)), self._buffer, 0)
+
+    def clear(self):
+        """Clears this widgets buffer and resets cursor."""
+        self._buffer = list()
+        self._cursor = (0, 0)
+
+    @property
+    def content(self):
+        """This has to return list (rows) of lists (columns) with one character elements."""
+        return self._buffer
+
+    def render(self, width = None):
+        """This method has to redraw the widget's self._buffer.
+
+           :param width: the width of buffer requested by the caller
+           :type width: int
+
+           This method will commonly call render of child widgets and then draw and write
+           methods to copy their contents to self._buffer
+           """
+        self.clear()
+
+    def __unicode__(self):
+        """Method to render the screen when printing as unicode string."""
+        return u"\n".join([u"".join(l) for l in self._buffer])
+
+    def setxy(self, row, col):
+        """Sets cursor position.
+
+        :param row: row id, starts with 0 at the top of the screen
+        :type row: int
+
+        :param col: column id, starts with 0 on the left side of the screen
+        :type col: int
+        """
+        self._cursor = (row, col)
+
+    @property
+    def cursor(self):
+        return self._cursor
+
+    def setend(self):
+        """Sets the cursor to first column in new line at the end."""
+        self._cursor = (self.height, 0)
+
+    def draw(self, w, row = None, col = None, block = False):
+        """This method copies w widget's content to this widget's buffer at row, col position.
+
+           :param w: widget to take content from
+           :type w: class Widget
+
+           :param row: row number to start at (default is at the cursor position)
+           :type row: int
+
+           :param col: column number to start at (default is at the cursor position)
+           :type col: int
+
+           :param block: when printing newline, start at column col (True) or at column 0 (False)
+           :type block: boolean
+           """
+
+        # if the starting row is not present, start at the cursor position
+        if row is None:
+            row = self._cursor[0]
+
+        # if the starting column is not present, start at the cursor position
+        if col is None:
+            col = self._cursor[1]
+
+        # fill up rows to accomodate for w.height
+        if self.height < row + w.height:
+            for i in range(row + w.height - self.height):
+                self._buffer.append(list())
+
+        # append columns to accomodate for w.width
+        for l in range(row, row + w.height):
+            l_len = len(self._buffer[l])
+            w_len = len(w.content[l - row])
+            if l_len < col + w_len:
+                self._buffer[l] += ((col + w_len - l_len) * list(u" "))
+            self._buffer[l][col:col + w_len] = w.content[l - row][:]
+
+        # move the cursor to new spot
+        if block:
+            self._cursor = (row + w.height, col)
+        else:
+            self._cursor = (row + w.height, 0)
+
+    def write(self, text, row = None, col = None, width = None, block = False):
+        """This method emulates typing machine writing to this widget's buffer.
+
+           :param text: text to type
+           :type text: unicode
+
+           :param row: row number to start at (default is at the cursor position)
+           :type row: int
+
+           :param col: column number to start at (default is at the cursor position)
+           :type col: int
+
+           :param width: wrap at "col" + "width" column (default is at self._max_width)
+           :type width: int
+
+           :param block: when printing newline, start at column col (True) or at column 0 (False)
+           :type block: boolean
+           """
+
+        if row is None:
+            row = self._cursor[0]
+
+        if col is None:
+            col = self._cursor[1]
+
+        if width is None and self._max_width:
+            width = self._max_width - col
+
+        x = row
+        y = col
+
+        # emulate typing machine
+        for c in text:
+            # process newline
+            if c == "\n":
+                x += 1
+                if block:
+                    y = col
+                else:
+                    y = 0
+                continue
+
+            # if the line is not in buffer, create it
+            if x >= len(self._buffer):
+                for i in range(x - len(self._buffer) + 1):
+                    self._buffer.append(list())
+
+            # if the line's length is not enough, fill it with spaces
+            if y >= len(self._buffer[x]):
+                self._buffer[x] += ((y - len(self._buffer[x]) + 1) * list(u" "))
+
+
+            # "type" character
+            self._buffer[x][y] = c
+
+            # shift to the next char
+            y += 1
+            if not width is None and y >= col + width:
+                x += 1
+                if block:
+                    y = col
+                else:
+                    y = 0
+
+        self._cursor = (x, y)
+
+if __name__ == "__main__":
+    class HelloWorld(UIScreen):
+        def show(self, args = None):
+            print """Hello World\nquit by typing 'quit'"""
+            return True
+
+    a = App("Hello World")
+    s = HelloWorld(a, None)
+    a.schedule_screen(s)
+    a.run()
diff --git a/pyanaconda/ui/tui/simpleline/base_test.py b/pyanaconda/ui/tui/simpleline/base_test.py
new file mode 100644
index 0000000..e5d8e44
--- /dev/null
+++ b/pyanaconda/ui/tui/simpleline/base_test.py
@@ -0,0 +1,122 @@
+# Test routines for the simpleline framework
+#
+# Copyright (C) (2012)  Red Hat, Inc.
+#
+# This copyrighted material is made available to anyone wishing to use,
+# modify, copy, or redistribute it subject to the terms and conditions of
+# the GNU General Public License v.2, or (at your option) any later version.
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY expressed or implied, including the implied warranties 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, write to the
+# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
+# source code or documentation are not subject to the GNU General Public
+# License and may only be used or replicated with the express permission of
+# Red Hat, Inc.
+#
+# Red Hat Author(s): Martin Sivak <msivak at redhat.com>
+#
+import base
+import unittest
+
+### dummy translation
+def _(t):
+    return t
+__builtins__._ = _
+
+
+class DummyScreen(base.UIScreen):
+    def refresh(self, args):
+        self._window = []
+        return True
+
+class TestApp(base.App):
+    def __init__(self, *args, **kwargs):
+        self.simulate_input = []
+        base.App.__init__(self, *args, **kwargs)
+
+    def simulate(self, input):
+        self.simulate_input.append(input)
+
+    def raw_input(self, prompt):
+        if not self.simulate_input:
+            raise Exception("No further input, the app probably failed the test")
+        return self.simulate_input.pop(0)
+
+class OKException(Exception):
+    pass
+
+def raiseOK():
+    raise OKException()
+
+class AppTests(unittest.TestCase):
+    def setUp(self):
+        self.app = TestApp("title")
+        self.screen = DummyScreen(self.app)
+
+    def test_schedule_adds_to_beginning_when_empty(self):
+        self.app.schedule_screen(base.UIScreen(self.app))
+        assert len(self.app._screens) == 1
+
+    def test_schedule_adds_to_beginning_when_not_empty(self):
+        self.app.schedule_screen(None)
+        self.app.schedule_screen(base.UIScreen(self.app))
+        assert len(self.app._screens) == 2
+        assert self.app._screens[0][0] is not None
+
+    def test_modal_starts_mainloop(self):
+        self.app._mainloop = raiseOK
+        self.assertRaises(OKException, self.app.switch_screen_modal, (self.screen,))
+
+    def test_exits_modal_screen(self):
+        self.app.simulate("c")
+        self.app.switch_screen_modal(self.screen)
+
+class WidgetTests(unittest.TestCase):
+    TEXT = u"test1\ntesting line\nte"
+    BLOCK = u"xxy\nxxx\nxxx"
+    BLOCK_TEXT = u"test1xxy\ntestixxxline\nte   xxx"
+
+    def setUp(self):
+        self.widget = base.Widget(default = self.TEXT)
+
+    def test_height(self):
+        self.assertEquals(self.widget.height, 3)
+
+    def test_width(self):
+        self.assertEquals(self.widget.width, 12)
+
+    def test_clear(self):
+        self.widget.clear()
+        self.assertEquals(self.widget.content, [])
+        self.assertEquals(self.widget.cursor, (0, 0))
+
+    def test_rendering(self):
+        self.assertEqual(unicode(self.widget), self.TEXT)
+
+    def test_draw(self):
+        target = base.Widget()
+        target.draw(self.widget)
+        self.assertEqual(unicode(target), self.TEXT)
+
+    def test_write(self):
+        self.widget.clear()
+        self.widget.write(self.TEXT)
+        self.assertEqual(unicode(self.widget), self.TEXT)
+
+    def test_block_write(self):
+        self.widget.setxy(0, 5)
+        self.widget.write(self.BLOCK, block = True)
+        self.assertEqual(unicode(self.widget), self.BLOCK_TEXT)
+
+    def test_block_draw(self):
+        source = base.Widget()
+        source.write(self.BLOCK)
+        self.widget.draw(source, row = 0, col = 5)
+        self.assertEqual(unicode(self.widget), self.BLOCK_TEXT)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pyanaconda/ui/tui/simpleline/widgets.py b/pyanaconda/ui/tui/simpleline/widgets.py
new file mode 100644
index 0000000..d6ef5c6
--- /dev/null
+++ b/pyanaconda/ui/tui/simpleline/widgets.py
@@ -0,0 +1,213 @@
+# encoding: utf-8
+#
+# Widgets for Anaconda TUI.
+#
+# Copyright (C) 2012  Red Hat, Inc.
+#
+# This copyrighted material is made available to anyone wishing to use,
+# modify, copy, or redistribute it subject to the terms and conditions of
+# the GNU General Public License v.2, or (at your option) any later version.
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY expressed or implied, including the implied warranties 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, write to the
+# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
+# source code or documentation are not subject to the GNU General Public
+# License and may only be used or replicated with the express permission of
+# Red Hat, Inc.
+#
+# Red Hat Author(s): Martin Sivak <msivak at redhat.com>
+#
+
+__all__ = ["TextWidget", "ColumnWidget", "CheckboxWidget", "CenterWidget"]
+
+import base
+
+class TextWidget(base.Widget):
+    """Class to handle wrapped text output."""
+
+    def __init__(self, text):
+        """
+        :param text: text to format
+        :type text: unicode
+        """
+
+        base.Widget.__init__(self)
+        self._text = text
+
+    def render(self, width):
+        """Renders the text widget limited to width number of columns
+        (wraps to the next line when the text is longer).
+
+        :param width: maximum width allocated to the string
+        :type width: int
+
+        :raises
+        """
+
+        base.Widget.render(self, width)
+        self.write(self._text, width = width)
+
+class CenterWidget(base.Widget):
+    """Class to handle horizontal centering of content."""
+
+    def __init__(self, w):
+        """
+        :param w: widget to center
+        :type w: base.Widget
+        """
+        base.Widget.__init__(self)
+        self._w = w
+
+    def render(self, width):
+        """
+        Render the centered widget to internal buffer.
+
+        :param width: maximum width the widget should use
+        :type width: int
+        """
+
+        base.Widget.render(self, width)
+        self._w.render(width)
+        self.draw(self._w, col = (width - self._w.width) / 2)
+
+class ColumnWidget(base.Widget):
+    def __init__(self, columns, spacing = 0):
+        """Create text columns
+
+           :param columns: list containing (column width, [list of widgets to put into this column])
+           :type columns: [(int, [...]), ...]
+
+           :param spacing: number of spaces to use between columns
+           :type spacing: int
+           """
+
+        base.Widget.__init__(self)
+        self._spacing = spacing
+        self._columns = columns
+
+    def render(self, width):
+        """Render the widget to it's internal buffer
+
+        :param width: the maximum width the widget can use
+        :type width: int
+
+        :return: nothing
+        :rtype: None
+        """
+
+        base.Widget.render(self, width)
+
+        # the lefmost empty column
+        x = 0
+
+        # iterate over tuples (column width, column content)
+        for col_width,col in self._columns:
+
+            # set cursor to first line and leftmost empty column
+            self.setxy(0, x)
+
+            # if requested width is None, limit the maximum to width
+            # and set minimum to 0
+            if col_width is None:
+                col_max_width = width - self.cursor[1]
+                col_width = 0
+            else:
+                col_max_width = col_width
+
+            # render and draw contents of column
+            for item in col:
+                item.render(col_max_width)
+                self.draw(item, block = True)
+
+            # recompute the leftmost empty column
+            x = max((x + col_width), self.width) + self._spacing
+
+class CheckboxWidget(base.Widget):
+    """Widget to show checkbox with (un)checked box, name and description."""
+
+    def __init__(self, key = "x", title = None, text = None, completed = None):
+        """
+        :param key: tick character to be used inside [ ]
+        :type key: character
+
+        :param title: the title next to the [ ] box
+        :type title: unicode
+
+        :param text: the description text to be shown on the second row in ()
+        :type text: unicode
+
+        :param completed: is the checkbox ticked or not?
+        :type completed: True|False
+        """
+
+        base.Widget.__init__(self)
+        self._key = key
+        self._title = title
+        self._text = text
+        self._completed = completed
+
+    def render(self, width):
+        """Render the widget to internal buffer. It should be max width
+           characters wide."""
+        base.Widget.render(self, width)
+
+        if self.completed:
+            checkchar = self._key
+        else:
+            checkchar = " "
+
+        # prepare the checkbox
+        checkbox = TextWidget("[%s]" % checkchar)
+
+        data = []
+
+        # append lines
+        if self.title:
+            data.append(TextWidget(self.title))
+
+        if self.text:
+            data.append(TextWidget("(%s)" % self.text))
+
+        # the checkbox has two columns
+        # [x] is one and is 3 chars wide
+        # text is second and can occupy width - 3 - 1 (for space) chars
+        cols = ColumnWidget([(3, [checkbox]), (width - 4, data)], 1)
+        cols.render(width)
+
+        # transfer the column widget rendered stuff to internal buffer
+        self.draw(cols)
+
+    @property
+    def title(self):
+        """Returns the first line (main title) of the checkbox."""
+        return self._title
+
+    @property
+    def completed(self):
+        """Returns the state of the checkbox, checked is True."""
+        return self._completed
+
+    @property
+    def text(self):
+        """Contains the description text from the second line."""
+        return self._text
+
+if __name__ == "__main__":
+    t1 = TextWidget(u"Můj krásný dlouhý text")
+    t2 = TextWidget(u"Test")
+    t3 = TextWidget(u"Test 2")
+    t4 = TextWidget(u"Krásný dlouhý text podruhé")
+    t5 = TextWidget(u"Test 3")
+
+    c = ColumnWidget([(15, [t1, t2, t3]), (10, [t4, t5])], spacing = 1)
+    c.render(80)
+    print unicode(c)
+
+    print 80*"-"
+
+    c = ColumnWidget([(20, [t1, t2, t3]), (25, [t4, t5]), (15, [t1, t2, t3])], spacing = 3)
+    c.render(80)
+    print unicode(c)
diff --git a/pyanaconda/ui/tui/spokes/Makefile.am b/pyanaconda/ui/tui/spokes/Makefile.am
new file mode 100644
index 0000000..96cdff9
--- /dev/null
+++ b/pyanaconda/ui/tui/spokes/Makefile.am
@@ -0,0 +1,24 @@
+# Copyright (C) 2011  Red Hat, Inc.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published
+# by the Free Software Foundation; either version 2.1 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 Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+# Author: Chris Lumens <clumens at redhat.com>
+
+SUBDIRS =
+
+MAINTAINERCLEANFILES = Makefile.in
+
+pkgpyexecdir = $(pyexecdir)/py$(PACKAGE_NAME)
+spokesdir        = $(pkgpyexecdir)/ui/tui/spokes
+spokes_PYTHON    = *.py
diff --git a/pyanaconda/ui/tui/spokes/__init__.py b/pyanaconda/ui/tui/spokes/__init__.py
new file mode 100644
index 0000000..ea85727
--- /dev/null
+++ b/pyanaconda/ui/tui/spokes/__init__.py
@@ -0,0 +1,90 @@
+# The base classes for Anaconda TUI Spokes
+#
+# Copyright (C) (2012)  Red Hat, Inc.
+#
+# This copyrighted material is made available to anyone wishing to use,
+# modify, copy, or redistribute it subject to the terms and conditions of
+# the GNU General Public License v.2, or (at your option) any later version.
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY expressed or implied, including the implied warranties 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, write to the
+# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
+# source code or documentation are not subject to the GNU General Public
+# License and may only be used or replicated with the express permission of
+# Red Hat, Inc.
+#
+# Red Hat Author(s): Martin Sivak <msivak at redhat.com>
+#
+from .. import simpleline as tui
+from pyanaconda.ui.tui.tuiobject import TUIObject
+from pyanaconda.ui.common import Spoke, StandaloneSpoke, NormalSpoke, PersonalizationSpoke, collect
+import os
+
+__all__ = ["TUISpoke", "StandaloneSpoke", "NormalSpoke", "PersonalizationSpoke",
+           "collect_spokes", "collect_categories"]
+
+class TUISpoke(TUIObject, tui.Widget, Spoke):
+    """Base TUI Spoke class implementing the pyanaconda.ui.common.Spoke API.
+    It also acts as a Widget so we can easily add it to Hub, where is shows
+    as a summary box with title, description and completed checkbox.
+
+    :param title: title of this spoke
+    :type title: unicode
+
+    :param category: category this spoke belongs to
+    :type category: string
+    """
+
+    title = u"Default spoke title"
+    category = u""
+
+    def __init__(self, app, data, storage, payload, instclass):
+        TUIObject.__init__(self, app, data)
+        tui.Widget.__init__(self)
+        Spoke.__init__(self, data, storage, payload, instclass)
+
+    @property
+    def status(self):
+        return "testing status..."
+
+    @property
+    def completed(self):
+        return True
+
+    def refresh(self, args = None):
+        TUIObject.refresh(self, args)
+        return True
+
+    def input(self, args, key):
+        """Handle the input, the base class just forwards it to the App level."""
+        return key
+
+    def render(self, width):
+        """Render the summary representation for Hub to internal buffer."""
+        tui.Widget.render(self, width)
+        c = tui.CheckboxWidget(completed = self.completed, title = self.title, text = self.status)
+        c.render(width)
+        self.draw(c)
+
+class StandaloneTUISpoke(TUISpoke, StandaloneSpoke):
+    pass
+
+class NormalTUISpoke(TUISpoke, NormalSpoke):
+    pass
+
+class PersonalizationTUISpoke(TUISpoke, PersonalizationSpoke):
+    pass
+
+def collect_spokes(category):
+    """Return a list of all spoke subclasses that should appear for a given
+       category.
+    """
+    return collect("pyanaconda.ui.tui.spokes.%s", os.path.dirname(__file__), lambda obj: hasattr(obj, "category") and obj.category != None and obj.category == category)
+
+def collect_categories():
+    classes = collect("pyanaconda.ui.tui.spokes.%s", os.path.dirname(__file__), lambda obj: hasattr(obj, "category") and obj.category != None and obj.category != "")
+    categories = set([c.category for c in classes])
+    return categories
diff --git a/pyanaconda/ui/tui/spokes/password.py b/pyanaconda/ui/tui/spokes/password.py
new file mode 100644
index 0000000..7f0bdce
--- /dev/null
+++ b/pyanaconda/ui/tui/spokes/password.py
@@ -0,0 +1,51 @@
+from pyanaconda.ui.tui.spokes import NormalTUISpoke
+from pyanaconda.ui.tui.simpleline import TextWidget
+import getpass
+
+import gettext
+_ = lambda x: gettext.ldgettext("anaconda", x)
+
+
+class PasswordSpoke(NormalTUISpoke):
+    title = _("Set root password")
+    category = "password"
+
+    def __init__(self, app, data, storage, payload, instclass):
+        NormalTUISpoke.__init__(self, app, data, storage, payload, instclass)
+        self._password = None
+
+    @property
+    def completed(self):
+        return self._password is not None
+
+    @property
+    def status(self):
+        if self._password is None:
+            return _("Password is not set.")
+        else:
+            return _("Password is set.")
+
+    def refresh(self, args = None):
+        NormalTUISpoke.refresh(self, args)
+
+        self._window += [TextWidget(_("Please select new root password. You will have to type it twice.")), ""]
+
+        return True
+
+    def prompt(self, args = None):
+        """Overriden prompt as password typing is special."""
+        p1 = getpass.getpass(_("Password: "))
+        p2 = getpass.getpass(_("Password (confirm): "))
+
+        if p1 != p2:
+            print _("Passwords do not match!")
+        else:
+            self._password = p1
+            self.apply()
+
+        self.close()
+        #return None
+
+    def apply(self):
+        self.data.rootpw.password = self._password
+        self.data.rootpw.isCrypted = False
diff --git a/pyanaconda/ui/tui/spokes/time.py b/pyanaconda/ui/tui/spokes/time.py
new file mode 100644
index 0000000..3c083b2
--- /dev/null
+++ b/pyanaconda/ui/tui/spokes/time.py
@@ -0,0 +1,113 @@
+from pyanaconda.ui.tui.spokes import NormalTUISpoke
+from pyanaconda.ui.tui.simpleline import TextWidget, ColumnWidget
+from pyanaconda import localization
+
+import gettext
+_ = lambda x: gettext.ldgettext("anaconda", x)
+
+class TimeZoneSpoke(NormalTUISpoke):
+    title = _("Timezone settings")
+    category = "localization"
+
+    def __init__(self, app, data, storage, payload, instclass):
+        NormalTUISpoke.__init__(self, app, data, storage, payload, instclass)
+
+    def initialize(self):
+        self._timezones = dict([(k, sorted(v)) for k,v in localization.get_all_regions_and_timezones().iteritems()])
+        self._regions = [r for r in self._timezones]
+        self._lower_regions = [r.lower() for r in self._timezones]
+
+        self._zones = ["%s/%s" % (region, z) for region in self._timezones for z in self._timezones[region]]
+        self._lower_zones = [z.lower() for region in self._timezones for z in self._timezones[region]] # for lowercase lookup
+
+        self._selection = ""
+
+    @property
+    def completed(self):
+        return self.data.timezone.timezone or self._selection
+
+    @property
+    def status(self):
+        if self.data.timezone.timezone:
+            return _("%s timezone") % self.data.timezone.timezone
+        elif self._selection:
+            return _("%s timezone") % self._selection
+        else:
+            return _("Timezone is not set.")
+
+
+    def refresh(self, args = None):
+        """args is None if we want a list of zones or "zone" to show all timezones in that zone."""
+        NormalTUISpoke.refresh(self, args)
+
+        if args and args in self._timezones:
+            self._window += [TextWidget(_("Available timezones in region %s") % args)]
+            displayed = [TextWidget(z) for z in self._timezones[args]]
+        else:
+            self._window += [TextWidget(_("Available regions"))]
+            displayed = [TextWidget(z) for z in self._regions]
+
+        def _prep(i, w):
+            number = TextWidget("%2d)" % i)
+            return ColumnWidget([(4, [number]), (None, [w])], 1)
+
+        # split zones to three columns
+        middle = len(displayed) / 3
+        left = [_prep(i, w) for i,w in enumerate(displayed) if i <= middle]
+        center = [_prep(i, w) for i,w in enumerate(displayed) if i > middle and i <= 2*middle]
+        right = [_prep(i, w) for i,w in enumerate(displayed) if i > 2*middle]
+
+        c = ColumnWidget([(24, left), (24, center), (24, right)], 3)
+        self._window.append(c)
+
+        return True
+
+    def input(self, args, key):
+        try:
+            keyid = int(key)
+            if args:
+                self._selection = "%s/%s" % (args, self._timezones[args][keyid])
+                self.apply()
+                self.close()
+            else:
+                if len(self._timezones[self._regions[keyid]]) == 1:
+                    self._selection = "%s/%s" % (self._regions[keyid],
+                                                 self._timezones[self._regions[keyid]][0])
+                    self.apply()
+                    self.close()
+                else:
+                    self.app.switch_screen(self, self._regions[keyid])
+            return True
+        except ValueError:
+            pass
+
+        if key.lower() in self._lower_zones:
+            id = self._lower_zones.index(key.lower())
+            self._selection = self._zones[id]
+            self.apply()
+            self.close()
+            return True
+
+        elif key.lower() in self._lower_regions:
+            id = self._lower_regions.index(key.lower())
+            if len(self._timezones[self._regions[id]]) == 1:
+                self._selection = "%s/%s" % (self._regions[id],
+                                             self._timezones[self._regions[id]][0])
+                self.apply()
+                self.close()
+            else:
+                self.app.switch_screen(self, self._regions[id])
+            return True
+
+        elif key.lower() == "b":
+            self.app.switch_screen(self, None)
+            return True
+
+        else:
+            return key
+
+    def prompt(self, args):
+        return _("Please select the timezone.\nUse numbers or type names directly [b to region list, q to quit]: ")
+
+    def apply(self):
+        self.data.timezone.timezone = self._selection
diff --git a/pyanaconda/ui/tui/tools/Makefile.am b/pyanaconda/ui/tui/tools/Makefile.am
new file mode 100644
index 0000000..abd5472
--- /dev/null
+++ b/pyanaconda/ui/tui/tools/Makefile.am
@@ -0,0 +1,18 @@
+# Copyright (C) 2011  Red Hat, Inc.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published
+# by the Free Software Foundation; either version 2.1 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 Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+# Author: Chris Lumens <clumens at redhat.com>
+
+EXTRA_DIST = run-text-hub.py run-text-spoke.py
diff --git a/pyanaconda/ui/tui/tools/run-text-hub.py b/pyanaconda/ui/tui/tools/run-text-hub.py
new file mode 120000
index 0000000..506993e
--- /dev/null
+++ b/pyanaconda/ui/tui/tools/run-text-hub.py
@@ -0,0 +1 @@
+run-text-spoke.py
\ No newline at end of file
diff --git a/pyanaconda/ui/tui/tools/run-text-spoke.py b/pyanaconda/ui/tui/tools/run-text-spoke.py
new file mode 100755
index 0000000..b542fcb
--- /dev/null
+++ b/pyanaconda/ui/tui/tools/run-text-spoke.py
@@ -0,0 +1,105 @@
+#!/usr/bin/python
+
+import sys, os
+import os.path
+
+# Check command line arguments
+if len(sys.argv)<2:
+    print "Usage: $0 <spoke module name> [<spoke widget class>]"
+    sys.exit(1)
+
+# Logging always needs to be set up first thing, or there'll be tracebacks.
+from pyanaconda import anaconda_log
+anaconda_log.init()
+
+from pyanaconda.installclass import DefaultInstall
+from pyanaconda.storage import Storage
+from pyanaconda.threads import initThreading
+from pyanaconda.packaging.yumpayload import YumPayload
+from pyanaconda.platform import getPlatform
+from pykickstart.version import makeVersion
+from pyanaconda.ui.tui.simpleline import App
+from pyanaconda.ui.tui import YesNoDialog
+
+# Don't worry with fcoe, iscsi, dasd, any of that crud.
+from pyanaconda.flags import flags
+flags.imageInstall = True
+flags.testing = True
+
+initThreading()
+
+# Figure out the part we are about to show: hub/spoke?
+# And get the name of the module which represents it
+if os.path.basename(sys.argv[0]) == "run-text-spoke.py":
+    spokeModuleName = "pyanaconda.ui.tui.spokes.%s" % sys.argv[1]
+    from pyanaconda.ui.common import Spoke
+    spokeBaseClass = Spoke
+    spokeText = "spoke"
+    SpokeText = "Spoke"
+elif os.path.basename(sys.argv[0]) == "run-text-hub.py":
+    spokeModuleName = "pyanaconda.ui.tui.hubs.%s" % sys.argv[1]
+    from pyanaconda.ui.common import Hub
+    spokeBaseClass = Hub
+    spokeText = "hub"
+    SpokeText = "Hub"
+else:
+    print "You have to run this command as run-spoke.py or run-hub.py."
+    sys.exit(1)
+
+# Set default spoke class
+spokeClass = None
+spokeClassName = None
+
+# Load spoke specified on the command line
+# If the spoke module was specified, but the spoke class was not,
+# try to find it using class hierarchy
+try:
+    spokeClassName = sys.argv[2]
+    __import__(spokeModuleName, fromlist = [spokeClassName])
+    spokeModule = sys.modules[spokeModuleName]
+except IndexError:
+    __import__(spokeModuleName)
+    spokeModule = sys.modules[spokeModuleName]
+    for k,v in vars(spokeModule).iteritems():
+        try:
+            print k,v
+            if issubclass(v, spokeBaseClass) and v != spokeBaseClass:
+                spokeClassName = k
+                spokeClass = v
+        except TypeError:
+            pass
+
+if not spokeClass:
+    try:
+        spokeClass = getattr(spokeModule, spokeClassName)
+    except KeyError:
+        print "%s %s could not be found in %s" % (SpokeText, spokeClassName, spokeModuleName)
+        sys.exit(1)
+
+
+print "Running %s %s from %s" % (spokeText, spokeClass, spokeModule)
+
+platform = getPlatform()
+ksdata = makeVersion()
+storage = Storage(data=ksdata, platform=platform)
+storage.reset()
+instclass = DefaultInstall()
+app = App("TEST HARNESS", yes_or_no_question = YesNoDialog)
+
+payload = YumPayload(ksdata)
+payload.setup(storage)
+payload.install_log = sys.stdout
+
+spoke = spokeClass(app, ksdata, storage, payload, instclass)
+
+if not spoke.showable:
+    print "This %s is not showable, but I'll continue anyway." % spokeText
+
+app.schedule_screen(spoke)
+app.run()
+
+if hasattr(spoke, "status"):
+    print "%s status:\n%s\n" % (SpokeText, spoke.status)
+if hasattr(spoke, "completed"):
+    print "%s completed:\n%s\n" % (SpokeText, spoke.completed)
+print "%s kickstart fragment:\n%s" % (SpokeText, ksdata)
diff --git a/pyanaconda/ui/tui/tuiobject.py b/pyanaconda/ui/tui/tuiobject.py
new file mode 100644
index 0000000..35c920f
--- /dev/null
+++ b/pyanaconda/ui/tui/tuiobject.py
@@ -0,0 +1,59 @@
+# base TUIObject for Anaconda TUI
+#
+# Copyright (C) 2012  Red Hat, Inc.
+#
+# This copyrighted material is made available to anyone wishing to use,
+# modify, copy, or redistribute it subject to the terms and conditions of
+# the GNU General Public License v.2, or (at your option) any later version.
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY expressed or implied, including the implied warranties 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, write to the
+# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
+# source code or documentation are not subject to the GNU General Public
+# License and may only be used or replicated with the express permission of
+# Red Hat, Inc.
+#
+# Red Hat Author(s): Martin Sivak <msivak at redhat.com>
+#
+
+from pyanaconda.ui import common
+import simpleline as tui
+
+class TUIObject(tui.UIScreen, common.UIObject):
+    """Base class for Anaconda specific TUI screens. Implements the
+    common pyanaconda.ui.common.UIObject interface"""
+
+    title = u"Default title"
+
+    def __init__(self, app, data):
+        tui.UIScreen.__init__(self, app)
+        common.UIObject.__init__(self, data)
+
+    @property
+    def showable(self):
+        return True
+
+    def teardown(self):
+        pass
+
+    def initialize(self):
+        """This method gets called whenever Hub or UserInterface prepares
+        all found objects for use. It is called only once and has no direct
+        connection to rendering."""
+        pass
+
+    def refresh(self, args = None):
+        """Put everything to display into self.window list."""
+        tui.UIScreen.refresh(self, args)
+
+    def retranslate(self):
+        """After language is changed, this method ensures that all the
+        texts on screen are translated. It only needs to refresh the
+        screen in text mode, as translation will happen automatically
+        and there is no way to change labels on previously displayed content."""
+
+        # redraw
+        self.app.switch_screen(self)


More information about the anaconda-patches mailing list