From 89d5abc416d1fe25809e991cf8f2f08636d1e414 Mon Sep 17 00:00:00 2001 From: David Lehman Date: Mon, 19 Dec 2011 13:58:32 -0600 Subject: [PATCH 5/9] Add storage category, spoke, and builder file. Initial implementation of the disk selection part of the storage spoke. some limitations (not an exhaustive list): - uses fake disk data due to non-trivial problems with non-root usage - can't switch other spokes yet (eg: software) - don't know how to get required space for current package set - no real handling of "custom" checkbutton's setting - no advanced/specialized devices --- pyanaconda/ui/gui/categories/storage.py | 30 + pyanaconda/ui/gui/spokes/storage.py | 522 +++++++++++++++++ pyanaconda/ui/gui/spokes/storage.ui | 964 +++++++++++++++++++++++++++++++ 3 files changed, 1516 insertions(+), 0 deletions(-) create mode 100644 pyanaconda/ui/gui/categories/storage.py create mode 100644 pyanaconda/ui/gui/spokes/storage.py create mode 100644 pyanaconda/ui/gui/spokes/storage.ui diff --git a/pyanaconda/ui/gui/categories/storage.py b/pyanaconda/ui/gui/categories/storage.py new file mode 100644 index 0000000..bc94447 --- /dev/null +++ b/pyanaconda/ui/gui/categories/storage.py @@ -0,0 +1,30 @@ +# Storage category classes +# +# Copyright (C) 2011 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): Chris Lumens +# David Lehman +# + +from pyanaconda.ui.gui.categories import SpokeCategory +from pyanaconda.ui.gui.hubs.summary import SummaryHub + +__all__ = ["StorageCategory"] + +class StorageCategory(SpokeCategory): + displayOnHub = SummaryHub + title = "STORAGE" diff --git a/pyanaconda/ui/gui/spokes/storage.py b/pyanaconda/ui/gui/spokes/storage.py new file mode 100644 index 0000000..ad3d0b8 --- /dev/null +++ b/pyanaconda/ui/gui/spokes/storage.py @@ -0,0 +1,522 @@ +# Storage configuration spoke classes +# +# Copyright (C) 2011 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): David Lehman +# + +""" + TODO: + + - add button within sw_needs text in options dialogs 2,3 + - udev data gathering + - udev fwraid, mpath would sure be nice + - status/completed + - what are noteworthy status events? + - disks selected + - exclusiveDisks non-empty + - sufficient space for software selection + - autopart selected + - custom selected + - performing custom configuration + - storage configuration complete + - spacing and border width always 6 + +""" + +from gi.repository import Gtk +from gi.repository import AnacondaWidgets + +from pyanaconda.ui.gui import UIObject +from pyanaconda.ui.gui.spokes import NormalSpoke +from pyanaconda.ui.gui.categories.storage import StorageCategory + +from pyanaconda.storage.size import Size + +# these are all temporary +from pyanaconda.storage.partitioning import getFreeRegions +from pyanaconda.storage.partitioning import sectorsToSize +from pyanaconda.storage.devices import DiskDevice +from pyanaconda.storage.formats import getFormat + +from pyanaconda.product import productName + +import gettext + +_ = lambda x: gettext.ldgettext("anaconda", x) +P_ = lambda x, y, z: gettext.ldngettext("anaconda", x, y, z) + +__all__ = ["StorageSpoke"] + +class FakeDisk(object): + def __init__(self, name, size=0, free=0, partitioned=True, vendor=None, + model=None, removable=False): + self.name = name + self.size = size + self.free = free + self.partitioned = partitioned + self.vendor = vendor + self.model = model + self.removable = removable + +def getDisks(devicetree, fake=False): + if not fake: + disks = [d for d in devicetree.devices if d.isDisk and + not d.format.hidden and + d.partitioned] + else: + disks = [] + disks.append(FakeDisk("sda", size=300000, free=10000, + vendor="Seagate", model="Monster")) + disks.append(FakeDisk("sdb", size=300000, free=300000, + vendor="Seagate", model="Monster")) + disks.append(FakeDisk("sdc", size=8000, free=2100, removable=True, + vendor="SanDisk", model="Cruzer")) + + return disks + +# XXX move these into storage +def fsFreeSpace(device): + free = 0 + if device.format.exists: + current_size = getattr(device.format, "currentSize", None) + min_size = getattr(device.format, "minSize", None) + if current_size and min_size and current_size != min_size: + free = int(current_size - min_size) # truncate + + return free + +def diskFreeSpace(disk): + free = 0 + if disk.partitioned: + parted_disk = disk.format.partedDisk + sector_size = disk.partedDevice.sectorSize + + free_geoms = getFreeRegions([parted_disk]) + free_sizes = [sectorsToSize(f.length, sector_size) for f in free_geoms] + free = sum(free_sizes) + + return free + +def get_free_space_info(disks, devicetree): + disk_free = 0 + fs_free = 0 + for disk in disks: + if hasattr(disk, "free"): + disk_free += disk.free + continue + + disk_free += diskFreeSpace(disk) + for partition in devicetree.getChildren(disk): + fs_free += fsFreeSpace(partition) + + print("disks %s have %d free, plus %s in filesystems" + % ([d.name for d in disks], disk_free, fs_free)) + return (disk_free, fs_free) + + +class SelectedDisksDialog(UIObject): + builderObjects = ["selected_disks_dialog", "summary_label", + "disk_view", "disk_store", "disk_selection"] + mainWidgetName = "selected_disks_dialog" + uiFile = "spokes/storage.ui" + + def populate(self, disks): + for disk in disks: + if disk.name not in self.data.ignoredisk.onlyuse: + continue + + self._store.append(["%s-%s" % (disk.vendor, disk.model), + Size(spec="%s mb" % disk.size).humanReadable().upper(), + Size(spec="%s mb" % disk.free).humanReadable().upper(), + str(disks.index(disk))]) + self.disks = disks[:] + self._update_summary() + + def setup(self, disks): + print "SETUP selected disks dialog" + super(SelectedDisksDialog, self).setup() + + self._view = self.builder.get_object("disk_view") + self._store = self.builder.get_object("disk_store") + self._selection = self.builder.get_object("disk_selection") + self._summary_label = self.builder.get_object("summary_label") + + # clear out the store and repopulate it from the devicetree + self._store.clear() + self.populate(disks) + + def run(self): + rc = self.window.run() + self.window.destroy() + return rc + + def _get_selection_refs(self): + selected_refs = [] + if self._selection.count_selected_rows(): + model, selected_paths = self._selection.get_selected_rows() + selected_refs = [Gtk.TreeRowReference() for p in selected_paths] + + return selected_refs + + def _update_summary(self): + count = 0 + size = 0 + free = 0 + itr = self._store.get_iter_first() + while itr: + count += 1 + size += Size(spec=self._store.get_value(itr, 1)) + free += Size(spec=self._store.get_value(itr, 2)) + itr = self._store.iter_next(itr) + + size = Size(bytes=long(size)).humanReadable().upper() + free = Size(bytes=long(free)).humanReadable().upper() + + text = P_(("%d disk; %s capacity; %s free space " + "(unpartitioned and in filesystems)"), + ("%d disks; %s capacity; %s free space " + "(unpartitioned and in filesystems)"), + count) % (count, size, free) + self._summary_label.set_text(text) + + # signal handlers + def on_remove_clicked(self, button): + print "REMOVE CLICKED"#: %s" % self._selection.get_selected().get_value(3) + # remove the selected disk(s) from the list and update the summary label + #selected_refs = self._get_selection_refs() + #for ref in selected_refs: + # path = ref.get_path() + # itr = model.get_iter_from_string(path) + # self._store.remove(itr) + model, itr = self._selection.get_selected() + if itr: + idx = int(model.get_value(itr, 3)) + name = self.disks[idx].name + print "removing %s" % name + self._store.remove(itr) + self.data.ignoredisk.onlyuse.remove(name) + self._update_summary() + + def on_close_clicked(self, button): + print "CLOSE CLICKED" + + def on_selection_changed(self, *args): + print "SELECTION CHANGED" + model, itr = self._selection.get_selected() + if itr: + print "new selection: %s" % model.get_value(itr, 3) + + +class InstallOptions1Dialog(UIObject): + builderObjects = ["options1_dialog", + "options1_label", "options1_custom_check"] + mainWidgetName = "options1_dialog" + uiFile = "spokes/storage.ui" + + RESPONSE_CANCEL = 0 + RESPONSE_CONTINUE = 1 + RESPONSE_MODIFY_SW = 2 + RESPONSE_RECLAIM = 3 + RESPONSE_QUIT = 4 + + def run(self): + rc = self.window.run() + self.window.destroy() + return rc + + def setup(self, required_space, disk_free, fs_free): + custom = not self.data.autopart.autopart + custom_checkbutton = self.builder.get_object("options1_custom_check") + custom_checkbutton.set_active(custom) + + options_label = self.builder.get_object("options1_label") + + options_text = (_("You have plenty of space to install %s, so " + "we can automatically configure the rest of the " + "installation for you.\n\nYou're all set!") + % productName) + options_label.set_markup(options_text) + + def _set_free_space_labels(self, disk_free, fs_free): + disk_free_text = Size(spec="%dmb" % disk_free).humanReadable().upper() + self.disk_free_label.set_text(disk_free_text) + + fs_free_text = Size(spec="%dmb" % fs_free).humanReadable().upper() + self.fs_free_label.set_text(fs_free_text) + + def _get_sw_needs_text(self, required_space): + required_space_text = Size(spec="%dmb" % required_space).humanReadable().upper() + sw_text = (_("Your current %s software selection requires " + "%s of available space.") + % (productName, required_space_text)) + return sw_text + + # signal handlers + def on_cancel_clicked(self, button): + # return to the spoke without making any changes + print "CANCEL CLICKED" + + def on_quit_clicked(self, button): + print "QUIT CLICKED" + + def on_modify_sw_clicked(self, button): + # switch to the software selection hub + print "MODIFY SOFTWARE CLICKED" + + def on_reclaim_clicked(self, button): + # show reclaim screen/dialog + print "RECLAIM CLICKED" + + def on_continue_clicked(self, button): + # TODO: handle custom checkbutton + print "CONTINUE CLICKED" + +class InstallOptions2Dialog(InstallOptions1Dialog): + builderObjects = ["options2_dialog", + "options2_label1", "options2_label2", + "options2_disk_free_label", "options2_fs_free_label", + "options2_custom_check"] + mainWidgetName = "options2_dialog" + + def setup(self, required_space, disk_free, fs_free): + custom = not self.data.autopart.autopart + custom_checkbutton = self.builder.get_object("options2_custom_check") + custom_checkbutton.set_active(custom) + + sw_text = self._get_sw_needs_text(required_space) + label_text = _("%s\nThe disks you've selected have the following " + "amounts of free space:") % sw_text + self.builder.get_object("options2_label1").set_markup(label_text) + + self.disk_free_label = self.builder.get_object("options2_disk_free_label") + self.fs_free_label = self.builder.get_object("options2_fs_free_label") + self._set_free_space_labels(disk_free, fs_free) + + label_text = (_("You don't have enough space available to install " + "%s, but we can help you reclaim space by " + "shrinking or removing existing partitions.") + % productName) + self.builder.get_object("options2_label2").set_markup(label_text) + +class InstallOptions3Dialog(InstallOptions1Dialog): + builderObjects = ["options3_dialog", + "options3_label1", "options3_label2", + "options3_disk_free_label", "options3_fs_free_label"] + mainWidgetName = "options3_dialog" + + def setup(self, required_space, disk_free, fs_free): + sw_text = self._get_sw_needs_text(required_space) + label_text = _("%s\nYou don't have enough space available to install " + "%s, even if you used all of the free space available " + "on the selected disks.") % (sw_text, productName) + self.builder.get_object("options3_label1").set_markup(label_text) + + self.disk_free_label = self.builder.get_object("options3_disk_free_label") + self.fs_free_label = self.builder.get_object("options3_fs_free_label") + self._set_free_space_labels(disk_free, fs_free) + + label_text = _("You don't have enough space available to install " + "%s, even if you used all of the free space " + "available on the selected disks. You could add more " + "disks for additional space, modify your software " + "selection to install a smaller version of %s, " + "or quit the installer.") % (productName, productName) + self.builder.get_object("options3_label2").set_markup(label_text) + +class StorageSpoke(NormalSpoke): + builderObjects = ["storageWindow", "local_disks_box", "summary_button"] + mainWidgetName = "storageWindow" + uiFile = "spokes/storage.ui" + + category = StorageCategory + + # other candidates: computer-symbolic, folder-symbolic + icon = "drive-harddisk-symbolic" + title = "STORAGE CONFIGURATION" + + def apply(self): + pass + + @property + def completed(self): + return False + + @property + def status(self): + """ A short string describing the current status of storage setup. """ + msg = "no disks selected" + if self.data.ignoredisk.onlyuse: + msg = "%d disks selected" % len(self.data.ignoredisk.onlyuse) + + if self.data.autopart.autopart: + msg = "automatic partitioning selected" + + # if we had a storage instance we could check for a defined root + + return msg + + def _on_disk_clicked(self, overview, *args): + print "DISK CLICKED: %s" % overview.get_property("popup-info") + self._update_disk_list() + self._update_summary() + + def populate(self): + NormalSpoke.populate(self) + + local_disks_box = self.builder.get_object("local_disks_box") + #specialized_disks_box = self.builder.get_object("specialized_disks_box") + + print self.data.ignoredisk.onlyuse + self.disks = getDisks(self.devicetree, fake=True) + + # properties: kind, description, capacity, os, popup-info + for disk in self.disks: + if disk.removable: + kind = "drive-removable" + else: + kind = "drive-harddisk" + + description = "%s %s" % (disk.vendor, disk.model) + size = Size(spec="%dmb" % disk.size).humanReadable().upper() + overview = AnacondaWidgets.DiskOverview(description, + kind, + size, + popup=disk.name) + local_disks_box.add(overview) + + # FIXME: this will need to get smarter + # + # maybe a little function that resolves each item in onlyuse using + # udev_resolve_devspec and compares that to the DiskDevice? + overview.set_selected(disk.name in self.data.ignoredisk.onlyuse) + overview.connect("button-press-event", self._on_disk_clicked) + + self._update_summary() + + def setup(self): + # XXX this is called every time we switch to this spoke + NormalSpoke.setup(self) + + def _update_summary(self): + """ Update the summary based on the UI. """ + print "UPDATING SUMMARY" + count = 0 + capacity = 0 + free = 0 + + overviews = self.builder.get_object("local_disks_box").get_children() + for overview in overviews: + name = overview.get_property("popup-info") + selected = overview.get_selected() + if selected: + disk = None + for _disk in self.disks: + if _disk.name == name: + disk = _disk + break + + capacity += disk.size + free += disk.free + count += 1 + + summary = (P_(("%d disk selected; %s capacity; %s free ..."), + ("%d disks selected; %s capacity; %s free ..."), + count) + % (count, + Size(spec="%dmb" % capacity).humanReadable().upper(), + Size(spec="%dmb" % free).humanReadable().upper())) + self.builder.get_object("summary_button").set_label(summary) + + def _update_disk_list(self): + """ Update ignoredisk.onlyuse based on the UI. """ + print "UPDATING DISK LIST" + overviews = self.builder.get_object("local_disks_box").get_children() + for overview in overviews: + name = overview.get_property("popup-info") + selected = overview.get_selected() + if selected and name not in self.data.ignoredisk.onlyuse: + self.data.ignoredisk.onlyuse.append(name) + + if not selected and name in self.data.ignoredisk.onlyuse: + self.data.ignoredisk.onlyuse.remove(name) + + # signal handlers + def on_summary_clicked(self, button): + # show the selected disks dialog + dialog = SelectedDisksDialog(self.data) + dialog.setup(self.disks) + rc = self.run_lightbox_dialog(dialog) + + # update the UI to reflect changes to self.data.ignoredisk.onlyuse + overviews = self.builder.get_object("local_disks_box").get_children() + print "onlyuse is %s" % self.data.ignoredisk.onlyuse + for overview in overviews: + name = overview.get_property("popup-info") + print "looking at DO for %s" % name + overview.set_selected(name in self.data.ignoredisk.onlyuse) + self._update_summary() + + def run_lightbox_dialog(self, dialog): + lightbox = AnacondaWidgets.lb_show_over(self.window) + dialog.window.set_transient_for(lightbox) + rc = dialog.run() + lightbox.destroy() + return rc + + def on_continue_clicked(self, button): + # show the installation options dialog + disks = [d for d in self.disks if d.name in self.data.ignoredisk.onlyuse] + (disk_free, fs_free) = get_free_space_info(disks, self.devicetree) + required_space = 10000 # TODO: find out where to get this + if disk_free >= required_space: + dialog = InstallOptions1Dialog(self.data) + elif disk_free + fs_free >= required_space: + dialog = InstallOptions2Dialog(self.data) + else: + dialog = InstallOptions3Dialog(self.data) + + dialog.setup(required_space, disk_free, fs_free) + rc = self.run_lightbox_dialog(dialog) + if rc == dialog.RESPONSE_CONTINUE: + # depending on custom/autopart, either set up autopart or show + # custom partitioning ui + print "user chose to continue to partitioning" + Gtk.main_quit() + elif rc == dialog.RESPONSE_CANCEL: + # stay on this spoke + print "user chose to continue disk selection" + pass + elif rc == dialog.RESPONSE_MODIFY_SW: + # go to software spoke + print "user chose to modify software selection" + Gtk.main_quit() + pass + elif rc == dialog.RESPONSE_RECLAIM: + # go to tug-of-war + print "user chose to reclaim space" + Gtk.main_quit() + pass + elif rc == dialog.RESPONSE_QUIT: + raise SystemExit("user-selected exit") + + def on_add_disk_clicked(self, button): + print "ADD DISK CLICKED" + + def on_back_clicked(self, window): + self.window.hide() + Gtk.main_quit() diff --git a/pyanaconda/ui/gui/spokes/storage.ui b/pyanaconda/ui/gui/spokes/storage.ui new file mode 100644 index 0000000..b4b6eca --- /dev/null +++ b/pyanaconda/ui/gui/spokes/storage.ui @@ -0,0 +1,964 @@ + + + + + + + + + + + + + + + + + + 500 + 200 + False + 5 + popup + True + center-on-parent + True + dialog + False + + + False + vertical + 6 + + + False + + + Cancel & _add more disks + False + True + True + True + 6 + False + True + + + + False + True + 0 + + + + + _Continue + False + True + True + True + 6 + False + True + + + + False + True + end + 1 + + + + + False + True + end + 0 + + + + + True + False + vertical + 6 + + + True + False + 0 + INSTALLATION OPTIONS + + + + + + False + True + 0 + + + + + True + False + 0 + Here we'll describe what your options are. + True + + + False + True + 1 + + + + + Let me review & customize the partitioning of the disks anyway. + False + True + True + False + False + 0 + True + + + False + True + end + 2 + + + + + False + True + 1 + + + + + + options1_cancel_button + options_continue_button1 + + + + 600 + 400 + False + 5 + popup + True + center-on-parent + True + dialog + False + + + False + vertical + 6 + + + False + + + Cancel & _add more disks + False + True + True + True + 6 + False + True + + + + False + True + 0 + + + + + _Modify software selection + False + True + True + True + 6 + False + True + + + + False + True + end + 1 + + + + + _Reclaim space + False + True + True + True + 6 + False + True + + + + False + True + 2 + + + + + False + True + end + 0 + + + + + True + False + vertical + 6 + + + True + False + 0 + INSTALLATION OPTIONS + + + + + + False + True + 0 + + + + + I don't need help; let me review & customize disk partitioning to reclaim space. + False + True + True + False + False + 0 + True + + + False + True + end + 1 + + + + + True + False + 0 + Here we'll describe how much space is needed for the current software selection. + True + + + False + True + 2 + + + + + True + False + 6 + 6 + True + + + True + False + 1 + disk free + + + + + + 0 + 0 + 1 + 1 + + + + + True + False + 0 + Free space available for use. + + + 1 + 0 + 1 + 1 + + + + + True + False + 1 + fs free + + + + + + 0 + 1 + 1 + 1 + + + + + True + False + 0 + Free space unavailable but reclaimable from existing partitions. + + + 1 + 1 + 1 + 1 + + + + + + + + + + + + + + + + + + + + False + True + 3 + + + + + True + False + 0 + Here we'll describe what your options are. + True + + + False + True + 4 + + + + + False + True + 1 + + + + + + options2_cancel_button + options2_modify_sw_button + options2_reclaim_button + + + + 400 + 300 + False + 5 + popup + True + center-on-parent + True + dialog + False + + + False + vertical + 6 + + + False + + + _Quit Installer + False + True + True + True + 6 + False + True + + + + False + True + 0 + + + + + _Modify software selection + False + True + True + True + 6 + False + True + + + + False + True + end + 1 + + + + + _Cancel + False + True + True + True + 6 + False + True + + + + False + True + end + 2 + + + + + False + True + end + 0 + + + + + True + False + vertical + 6 + + + True + False + 0 + INSTALLATION OPTIONS + + + + + + False + True + 0 + + + + + True + False + 0 + Here we'll describe how much space is needed for the current software selection. + True + 100 + + + False + True + 1 + + + + + True + False + 6 + 6 + True + + + True + False + 1 + disk free + + + + + + 0 + 0 + 1 + 1 + + + + + True + False + 0 + Free space available for use. + + + 1 + 0 + 1 + 1 + + + + + True + False + 1 + fs free + + + + + + 0 + 1 + 1 + 1 + + + + + True + False + 0 + Free space unavailable but reclaimable from existing partitions. + + + 1 + 1 + 1 + 1 + + + + + + + + + + + + + + + + + + + + False + True + 2 + + + + + True + False + 0 + Here we'll describe what your options are. + True + 100 + + + False + True + 3 + + + + + False + True + 1 + + + + + + options3_quit_button + options3_modify_sw_button + options3_cancel_button + + + + 500 + 400 + False + 5 + popup + SELECTED DISKS + True + center-on-parent + True + dialog + False + + + False + vertical + 6 + + + False + end + + + _Close + False + True + True + True + False + True + + + + False + True + 0 + + + + + False + True + end + 0 + + + + + True + False + vertical + 6 + + + True + False + 0 + SELECTED DISKS + + + False + True + 0 + + + + + True + True + disk_store + + + + + + + + 6 + Description + True + + + + 0 + + + + + + + 6 + Capacity + True + + + + 1 + + + + + + + 6 + Free + + + + 2 + + + + + + + 6 + Id + + + + 3 + + + + + + + False + True + 1 + + + + + True + False + 6 + start + + + _Remove + False + True + True + True + False + True + + + + False + True + 0 + + + + + False + True + 2 + + + + + True + False + 0 + Disk summary goes here + + + False + True + end + 3 + + + + + True + True + 1 + + + + + + close_button + + + + + + + + + + filler + False + 6 + filler + False + + + False + vertical + 6 + + + True + False + 6 + vertical + 6 + + + True + False + 0 + LOCAL STANDARD DISKS + False + + + False + True + 0 + + + + + True + True + never + in + + + True + False + + + True + False + 6 + True + + + + + + + + + + False + True + 1 + + + + + True + False + 6 + + + summary + False + True + True + True + False + none + True + False + 0 + + + + False + True + 0 + + + + + _Continue + False + True + True + True + False + True + 1 + + + + False + True + end + 1 + + + + + False + True + 2 + + + + + False + True + 1 + + + + + + -- 1.7.8