# Disk resizing dialog # # Copyright (C) 2012-2013 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 # from __future__ import division from collections import namedtuple from gi.repository import Gdk, Gtk from pyanaconda.i18n import _, C_, N_, P_ from pyanaconda.ui.gui import GUIObject from pyanaconda.ui.gui.utils import blockedHandler, escape_markup, timed_action from blivet.size import Size __all__ = ["ResizeDialog"] DEVICE_ID_COL = 0 DESCRIPTION_COL = 1 FILESYSTEM_COL = 2 RECLAIMABLE_COL = 3 ACTION_COL = 4 EDITABLE_COL = 5 TYPE_COL = 6 TOOLTIP_COL = 7 RESIZE_TARGET_COL = 8 NAME_COL = 9 TY_NORMAL = 0 TY_FREE_SPACE = 1 TY_PROTECTED = 2 PartStoreRow = namedtuple("PartStoreRow", ["id", "desc", "fs", "reclaimable", "action", "editable", "ty", "tooltip", "target", "name"]) PRESERVE = N_("Preserve") SHRINK = N_("Shrink") DELETE = N_("Delete") NOTHING = "" class ResizeDialog(GUIObject): builderObjects = ["actionStore", "diskStore", "resizeDialog", "resizeAdjustment"] mainWidgetName = "resizeDialog" uiFile = "spokes/lib/resize.glade" def __init__(self, data, storage, payload): GUIObject.__init__(self, data) self.storage = storage self.payload = payload self._initialFreeSpace = Size(0) self._selectedReclaimableSpace = Size(0) self._actionStore = self.builder.get_object("actionStore") self._diskStore = self.builder.get_object("diskStore") self._selection = self.builder.get_object("diskView-selection") self._view = self.builder.get_object("diskView") self._diskStore = self.builder.get_object("diskStore") self._reclaimable_label = self.builder.get_object("reclaimableSpaceLabel") self._selected_label = self.builder.get_object("selectedSpaceLabel") self._required_label = self.builder.get_object("requiredSpaceLabel") markup = self._required_label.get_label() self._required_label.set_markup(markup % escape_markup(str(self.payload.spaceRequired))) self._reclaimDescLabel = self.builder.get_object("reclaimDescLabel") self._resizeButton = self.builder.get_object("resizeButton") self._preserveButton = self.builder.get_object("preserveButton") self._shrinkButton = self.builder.get_object("shrinkButton") self._deleteButton = self.builder.get_object("deleteButton") self._resizeSlider = self.builder.get_object("resizeSlider") def _description(self, part): # First, try to find the partition in some known Root. If we find # it, return the mountpoint as the description. for root in self.storage.roots: for (mount, device) in root.mounts.items(): if device == part: return "%s (%s)" % (mount, root.name) # Otherwise, fall back on increasingly vague information. if not part.isleaf: return self.storage.devicetree.getChildren(part)[0].name if getattr(part.format, "label", None): return part.format.label elif getattr(part.format, "name", None): return part.format.name else: return "" def _get_tooltip(self, device): if device.protected: return _("This device contains the installation source.") else: return None def populate(self, disks): totalDisks = 0 totalReclaimableSpace = Size(0) self._initialFreeSpace = Size(0) self._selectedReclaimableSpace = Size(0) canShrinkSomething = False free_space = self.storage.getFreeSpace(disks=disks) for disk in disks: # First add the disk itself. editable = not disk.protected if disk.partitioned: fstype = "" diskReclaimableSpace = Size(0) else: fstype = disk.format.name diskReclaimableSpace = disk.size itr = self._diskStore.append(None, [disk.id, "%s %s" % (disk.size.humanReadable(max_places=1), disk.description), fstype, "%s total", _(PRESERVE), editable, TY_NORMAL, self._get_tooltip(disk), int(disk.size), disk.name]) if disk.partitioned: # Then add all its partitions. for dev in self.storage.devicetree.getChildren(disk): if dev.isExtended and disk.format.logicalPartitions: continue # Devices that are not resizable are still deletable. if dev.resizable: freeSize = dev.size - dev.minSize resizeString = _("%(freeSize)s of %(devSize)s") \ % {"freeSize": freeSize.humanReadable(max_places=1), "devSize": dev.size.humanReadable(max_places=1)} if not dev.protected: canShrinkSomething = True else: freeSize = dev.size resizeString = "%s" % \ escape_markup(_("Not resizeable")) if dev.protected: ty = TY_PROTECTED else: ty = TY_NORMAL self._diskStore.append(itr, [dev.id, self._description(dev), dev.format.name, resizeString, _(PRESERVE), not dev.protected, ty, self._get_tooltip(dev), int(dev.size), dev.name]) diskReclaimableSpace += freeSize # And then add another uneditable line that lists how much space is # already free in the disk. diskFree = free_space[disk.name][0] if diskFree >= Size("1MiB"): freeSpaceString = "%s" % \ escape_markup(_("Free space")) self._diskStore.append(itr, [disk.id, freeSpaceString, "", "%s" % escape_markup(diskFree.humanReadable(max_places=1)), NOTHING, False, TY_FREE_SPACE, self._get_tooltip(disk), diskFree, ""]) self._initialFreeSpace += diskFree # And then go back and fill in the total reclaimable space for the # disk, now that we know what each partition has reclaimable. self._diskStore[itr][RECLAIMABLE_COL] = self._diskStore[itr][RECLAIMABLE_COL] % diskReclaimableSpace totalDisks += 1 totalReclaimableSpace += diskReclaimableSpace self._update_labels(totalDisks, totalReclaimableSpace, 0) description = _("You can remove existing file systems you no longer need to free up space " "for this installation. Removing a file system will permanently delete all " "of the data it contains.") if canShrinkSomething: description += "\n\n" description += _("There is also free space available in pre-existing file systems. " "While it's risky and we recommend you back up your data first, you " "can recover that free disk space and make it available for this " "installation below.") self._reclaimDescLabel.set_text(description) self._update_reclaim_button(Size(0)) def _update_labels(self, nDisks=None, totalReclaimable=None, selectedReclaimable=None): if nDisks is not None and totalReclaimable is not None: text = P_("%(count)s disk; %(size)s reclaimable space (in file systems)", "%(count)s disks; %(size)s reclaimable space (in file systems)", nDisks) % {"count" : escape_markup(str(nDisks)), "size" : escape_markup(totalReclaimable)} self._reclaimable_label.set_markup(text) if selectedReclaimable is not None: text = _("Total selected space to reclaim: %s") % \ escape_markup(selectedReclaimable) self._selected_label.set_markup(text) def _setup_slider(self, device, value): """Set up the slider for this device, pulling out any previously given shrink value as the default. This also sets up the ticks on the slider and keyboard support. Any devices that are not resizable will not have a slider displayed, so they do not need to be worried with here. :param device: The device :type device: PartitionDevice :param value: default value to set :type value: Size """ # Convert the Sizes to ints minSize = int(device.minSize) size = int(device.size) default_value = int(value) # The slider needs to be keyboard-accessible. We'll make small movements change in # 1% increments, and large movements in 5% increments. distance = size - minSize onePercent = int(distance / 100) fivePercent = int(distance / 20) twentyPercent = int(distance / 5) with blockedHandler(self._resizeSlider, self.on_resize_value_changed): self._resizeSlider.set_range(minSize, size) self._resizeSlider.set_value(default_value) adjustment = self.builder.get_object("resizeAdjustment") adjustment.configure(default_value, minSize, size, onePercent, fivePercent, 0) # And then the slider needs a couple tick marks for easier navigation. self._resizeSlider.clear_marks() for i in range(1, 5): self._resizeSlider.add_mark(minSize + i * twentyPercent, Gtk.PositionType.BOTTOM, None) # Finally, add tick marks for the ends. self._resizeSlider.add_mark(minSize, Gtk.PositionType.BOTTOM, str(device.minSize)) self._resizeSlider.add_mark(size, Gtk.PositionType.BOTTOM, str(device.size)) def _update_action_buttons(self, row): obj = PartStoreRow(*row) device = self.storage.devicetree.getDeviceByID(obj.id) # Disks themselves may be editable in certain ways, but they are never # shrinkable. self._preserveButton.set_sensitive(obj.editable) self._shrinkButton.set_sensitive(obj.editable and not device.isDisk) self._deleteButton.set_sensitive(obj.editable) self._resizeSlider.set_visible(False) if not obj.editable: return # If the selected filesystem does not support shrinking, make that # button insensitive. self._shrinkButton.set_sensitive(device.resizable) if device.resizable: self._setup_slider(device, Size(obj.target)) # Then, disable the button for whatever action is currently selected. # It doesn't make a lot of sense to allow clicking that. if obj.action == _(PRESERVE): self._preserveButton.set_sensitive(False) elif obj.action == _(SHRINK): self._shrinkButton.set_sensitive(False) self._resizeSlider.set_visible(True) elif obj.action == _(DELETE): self._deleteButton.set_sensitive(False) def _update_reclaim_button(self, got): self._resizeButton.set_sensitive(got+self._initialFreeSpace >= self.payload.spaceRequired) # pylint: disable=arguments-differ def refresh(self, disks): super(ResizeDialog, self).refresh() # clear out the store and repopulate it from the devicetree self._diskStore.clear() self.populate(disks) self._view.expand_all() def run(self): rc = self.window.run() self.window.destroy() return rc # Signal handlers. def on_key_pressed(self, window, event, *args): # Handle any keyboard events. Right now this is just delete for # removing a partition, but it could include more later. if not event or event and event.type != Gdk.EventType.KEY_RELEASE: return if event.keyval == Gdk.KEY_Delete and self._deleteButton.get_sensitive(): self._deleteButton.emit("clicked") def _sumReclaimableSpace(self, model, path, itr, *args): obj = PartStoreRow(*model[itr]) device = self.storage.devicetree.getDeviceByID(obj.id) if device.isDisk and device.partitioned: return False if obj.action == _(PRESERVE): return False if obj.action == _(SHRINK): self._selectedReclaimableSpace += device.size - Size(obj.target) elif obj.action == _(DELETE): self._selectedReclaimableSpace += int(device.size) return False def on_preserve_clicked(self, button): itr = self._selection.get_selected()[1] self._actionChanged(itr, PRESERVE) def on_shrink_clicked(self, button): itr = self._selection.get_selected()[1] self._actionChanged(itr, SHRINK) def on_delete_clicked(self, button): itr = self._selection.get_selected()[1] self._actionChanged(itr, DELETE) def _actionChanged(self, itr, newAction): if not itr: return # Handle the row selected when a button was pressed. selectedRow = self._diskStore[itr] selectedRow[ACTION_COL] = _(newAction) # If that row is a disk header, we need to process all the partitions # it contains. device = self.storage.devicetree.getDeviceByID(selectedRow[DEVICE_ID_COL]) if device.isDisk and device.partitioned: partItr = self._diskStore.iter_children(itr) while partItr: # Immutable entries are those that we can't do anything to - like # the free space lines. We just want to leave them in the display # for information, but you can't choose to preserve/delete/shrink # them. if self._diskStore[partItr][TYPE_COL] in [TY_FREE_SPACE, TY_PROTECTED]: partItr = self._diskStore.iter_next(partItr) continue self._diskStore[partItr][ACTION_COL] = _(newAction) # If the user marked a whole disk for deletion, they can't go in and # un-delete partitions under it. if newAction == DELETE: self._diskStore[partItr][EDITABLE_COL] = False elif newAction == PRESERVE: part = self.storage.devicetree.getDeviceByID(self._diskStore[partItr][DEVICE_ID_COL]) self._diskStore[partItr][EDITABLE_COL] = not part.protected partItr = self._diskStore.iter_next(partItr) # And then we're keeping a running tally of how much space the user # has selected to reclaim, so reflect that in the UI. self._selectedReclaimableSpace = Size(0) self._diskStore.foreach(self._sumReclaimableSpace, None) self._update_labels(selectedReclaimable=self._selectedReclaimableSpace) self._update_reclaim_button(self._selectedReclaimableSpace) self._update_action_buttons(selectedRow) def _recursiveRemove(self, device): """ Remove a device, or if it has protected children, just remove the unprotected children. """ if not any(d for d in self.storage.devices \ if device in d.parents and d.protected): # No protected children, remove the device self.storage.recursiveRemove(device) else: # Only remove unprotected children unprotected = (d for d in self.storage.devices \ if device in d.parents and not d.protected) for child in unprotected: if child in self.storage.devices: self.storage.recursiveRemove(child) def _scheduleActions(self, model, path, itr, *args): obj = PartStoreRow(*model[itr]) device = self.storage.devicetree.getDeviceByID(obj.id) if not obj.editable: return False if obj.action == _(PRESERVE): return False elif obj.action == _(SHRINK) and int(device.size) != int(obj.target): if device.resizable: aligned = device.alignTargetSize(Size(obj.target)) self.storage.resizeDevice(device, aligned) else: self._recursiveRemove(device) elif obj.action == _(DELETE): self._recursiveRemove(device) return False def on_resize_clicked(self, *args): self._diskStore.foreach(self._scheduleActions, None) def on_delete_all_clicked(self, button, *args): if button.get_label() == C_("GUI|Reclaim Dialog", "Delete _all"): action = DELETE button.set_label(C_("GUI|Reclaim Dialog", "Preserve _all")) else: action = PRESERVE button.set_label(C_("GUI|Reclaim Dialog", "Delete _all")) itr = self._diskStore.get_iter_first() while itr: obj = PartStoreRow(*self._diskStore[itr]) if not obj.editable: itr = self._diskStore.iter_next(itr) continue device = self.storage.devicetree.getDeviceByID(obj.id) if device.isDisk: self._actionChanged(itr, action) itr = self._diskStore.iter_next(itr) def on_row_clicked(self, view, path, column): # This handles when the user clicks on a row in the view. We use it # only for expanding/collapsing disk headers. if view.row_expanded(path): view.collapse_row(path) else: view.expand_row(path, True) def on_selection_changed(self, selection): # This handles when the selection changes. It's very similar to what # on_row_clicked above does, but this handler only deals with changes in # selection. Thus, clicking on a disk header to collapse it and then # immediately clicking on it again to expand it would not work when # dealt with here. itr = selection.get_selected()[1] if not itr: return self._update_action_buttons(self._diskStore[itr]) @timed_action(delay=200, threshold=500, busy_cursor=False) def on_resize_value_changed(self, rng): (model, itr) = self._selection.get_selected() old_delta = Size(rng.get_adjustment().get_upper()) - int(model[itr][RESIZE_TARGET_COL]) self._selectedReclaimableSpace -= old_delta # Update the target size in the store. model[itr][RESIZE_TARGET_COL] = Size(rng.get_value()) # Update the "Total selected space" label. delta = Size(rng.get_adjustment().get_upper()) - int(rng.get_value()) self._selectedReclaimableSpace += delta self._update_labels(selectedReclaimable=self._selectedReclaimableSpace) # And then the reclaim button, in case they've made enough space. self._update_reclaim_button(self._selectedReclaimableSpace) def resize_slider_format(self, scale, value): # This makes the value displayed under the slider prettier than just a # single number. return str(Size(value))