diff --git a/README.md b/README.md index 73f6974..583d764 100644 --- a/README.md +++ b/README.md @@ -200,21 +200,25 @@ You can create your own custom layout by specifying which views you'd like to see, and where they go. The basic window layout supports eight "zones", which are laid out as follows: +``` +---------------+--------------+ | zone1 zone2 | zone3 zone4 | + + + | zone5 zone6 | zone7 zone8 | +---------------+--------------+ +``` If a zone has free space below it or to the right of it, it will try to use it. Stretching downwards takes precedence over stretching rightwards. E.g. suppose your layout is only non-empty in zones 1, 4, 5, 6 and 7: +``` +---------------+--------------+ | zone1 | zone4 | + + + | zone5 zone6 | zone7 | +---------------+--------------+ +``` Then zone1 will stretch right-wards to make a three-zone view. Zone4 will stretch downwards to make a long two-zone view. diff --git a/spikeinterface_gui/backend_panel.py b/spikeinterface_gui/backend_panel.py index 32fb954..55e4f1e 100644 --- a/spikeinterface_gui/backend_panel.py +++ b/spikeinterface_gui/backend_panel.py @@ -5,8 +5,7 @@ from .viewlist import possible_class_views from .layout_presets import get_layout_description -from .utils_global import get_size_bottom_row, get_size_top_row - +from .utils_global import fill_unnecessary_space, get_present_zones_in_half_of_layout # Used by views to emit/trigger signals class SignalNotifier(param.Parameterized): spike_selection_changed = param.Event() @@ -283,26 +282,64 @@ def create_main_layout(self): allow_drag=False, ) - gs = self.make_half_layout(gs, ['zone1', 'zone2', 'zone5', 'zone6'], layout_zone, 0) - gs = self.make_half_layout(gs, ['zone3', 'zone4', 'zone7', 'zone8'], layout_zone, 2) + gs = self.make_half_layout(gs, layout_zone, "left") + gs = self.make_half_layout(gs, layout_zone, "right") self.main_layout = gs - def make_half_layout(self, gs, all_zones, layout_zone, shift): - - is_zone = [(layout_zone.get(zone) is not None) and (len(layout_zone.get(zone)) > 0) for zone in all_zones] - is_zone_array = np.reshape(is_zone, (2,2)) - original_zone_array = copy(is_zone_array) - - for zone_index, zone_name in enumerate(all_zones): - row = zone_index // 2 - col = zone_index % 2 - if row == 0: - num_rows, num_cols = get_size_top_row(row, col, is_zone_array, original_zone_array) - elif row == 1: - num_rows, num_cols = get_size_bottom_row(row, col, is_zone_array, original_zone_array) - if num_rows > 0 and num_cols > 0: - gs[slice(row, row + num_rows), slice(col+shift,col+num_cols+shift)] = layout_zone.get(zone_name) + def make_half_layout(self, gs, layout_zone, left_or_right): + """ + Function contains the logic for the greedy layout. Given the 2x2 box of zones + + 1 2 3 4 + 5 6 or 7 8 + + Then depending on which zones are non-zero, a different layout is generated using splits. + + The zone indices in the second box (34,78) are equal to the zone indices first box (12,56) + shifted by 2. We take advantage of this fact. + """ + + shift = 0 if left_or_right == "left" else 2 + + layout_zone = fill_unnecessary_space(layout_zone, shift) + present_zones = get_present_zones_in_half_of_layout(layout_zone, shift) + + # `fill_unnecessary_space` ensures that zone{1+shift} always exists + if present_zones == set([f'zone{1+shift}']): + gs[0,0] = layout_zone.get(f'zone{1+shift}') + + # Layouts with two non-zero zones + if present_zones == set([f'zone{1+shift}', f'zone{2+shift}']): + gs[slice(0, 1), slice(0+shift,1+shift)] = layout_zone.get(f'zone{1+shift}') + gs[slice(0, 1), slice(1+shift,2+shift)] = layout_zone.get(f'zone{2+shift}') + elif present_zones == set([f'zone{1+shift}', f'zone{5+shift}']): + gs[slice(0, 1), slice(0+shift,2+shift)] = layout_zone.get(f'zone{1+shift}') + gs[slice(1, 2), slice(0+shift,2+shift)] = layout_zone.get(f'zone{5+shift}') + elif present_zones == set([f'zone{1+shift}', f'zone{6+shift}']): + gs[slice(0, 1), slice(0+shift,1+shift)] = layout_zone.get(f'zone{1+shift}') + gs[slice(0, 1), slice(1+shift,2+shift)] = layout_zone.get(f'zone{6+shift}') + + # Layouts with three non-zero zones + elif present_zones == set([f'zone{1+shift}', f'zone{2+shift}', f'zone{5+shift}']): + gs[slice(0, 1), slice(0+shift,1+shift)] = layout_zone.get(f'zone{1+shift}') + gs[slice(0, 2), slice(1+shift,2+shift)] = layout_zone.get(f'zone{2+shift}') + gs[slice(1, 2), slice(0+shift,1+shift)] = layout_zone.get(f'zone{5+shift}') + elif present_zones == set([f'zone{1+shift}', f'zone{2+shift}', f'zone{6+shift}']): + gs[slice(0, 2), slice(0+shift,1+shift)] = layout_zone.get(f'zone{1+shift}') + gs[slice(0, 1), slice(1+shift,2+shift)] = layout_zone.get(f'zone{2+shift}') + gs[slice(1, 2), slice(1+shift,1+shift)] = layout_zone.get(f'zone{6+shift}') + elif present_zones == set([f'zone{1+shift}', f'zone{5+shift}', f'zone{6+shift}']): + gs[slice(0, 1), slice(0+shift,2+shift)] = layout_zone.get(f'zone{1+shift}') + gs[slice(1, 2), slice(0+shift,1+shift)] = layout_zone.get(f'zone{5+shift}') + gs[slice(1, 2), slice(1+shift,2+shift)] = layout_zone.get(f'zone{6+shift}') + + # Layouts with four non-zero zones + elif present_zones == set([f'zone{1+shift}', f'zone{2+shift}', f'zone{5+shift}', f'zone{6+shift}']): + gs[slice(0, 1), slice(0+shift,1+shift)] = layout_zone.get(f'zone{1+shift}') + gs[slice(0, 1), slice(1+shift,2+shift)] = layout_zone.get(f'zone{2+shift}') + gs[slice(1, 2), slice(0+shift,1+shift)] = layout_zone.get(f'zone{5+shift}') + gs[slice(1, 2), slice(1+shift,2+shift)] = layout_zone.get(f'zone{6+shift}') return gs diff --git a/spikeinterface_gui/backend_qt.py b/spikeinterface_gui/backend_qt.py index b9f8986..fd924c8 100644 --- a/spikeinterface_gui/backend_qt.py +++ b/spikeinterface_gui/backend_qt.py @@ -8,7 +8,7 @@ from .viewlist import possible_class_views from .layout_presets import get_layout_description -from .utils_global import get_size_bottom_row, get_size_top_row +from .utils_global import fill_unnecessary_space, get_present_zones_in_half_of_layout from .utils_qt import qt_style, add_stretch_to_qtoolbar @@ -200,9 +200,9 @@ def create_main_layout(self): view_names = [view_name for view_name in view_names if view_name in self.views.keys()] widgets_zone[zone] = view_names - self.make_dock(widgets_zone, ['zone1', 'zone2', 'zone5', 'zone6'], "left", col_shift=0) - self.make_dock(widgets_zone, ['zone3', 'zone4', 'zone7', 'zone8'], "right", col_shift=2) - + self.make_half_layout(widgets_zone, "left") + self.make_half_layout(widgets_zone, "right") + # make tabs for zone, view_names in widgets_zone.items(): n = len(widgets_zone[zone]) @@ -217,103 +217,70 @@ def create_main_layout(self): # make visible the first of each zone self.docks[view_name0].raise_() - def make_dock(self, widgets_zone, all_zones, side_of_window, col_shift): - - all_zones_array = np.transpose(np.reshape(all_zones, (2,2))) - is_zone = np.array([(widgets_zone.get(zone) is not None) and (len(widgets_zone.get(zone)) > 0) for zone in all_zones]) - is_zone_array = np.reshape(is_zone, (2,2)) - - # If the first non-zero zero (from left to right) is on the bottom, move it up - for column_index, zones_in_columns in enumerate(is_zone_array): - if np.any(zones_in_columns): - first_is_top = zones_in_columns[0] - if not first_is_top: - top_zone = f"zone{column_index+1+col_shift}" - bottom_zone = f"zone{column_index+5+col_shift}" - widgets_zone[top_zone] = widgets_zone[bottom_zone] - widgets_zone[bottom_zone] = [] - continue - - is_zone = np.array([(widgets_zone.get(zone) is not None) and (len(widgets_zone.get(zone)) > 0) for zone in all_zones]) - is_zone_array = np.reshape(is_zone, (2,2)) - original_zone_array = copy(is_zone_array) - - # First we split horizontally any columns which are two rows long. - # For later, group the zones between these splits - all_groups = [] - group = [] - for col_index, zones in enumerate(all_zones_array): - col = col_index % 2 - is_a_zone = original_zone_array[:,col] - num_row_0, _ = get_size_top_row(0, col, is_zone_array, original_zone_array) - # this function affects is_zone_array so must be run - _, _ = get_size_bottom_row(1, col, is_zone_array, original_zone_array) - - if num_row_0 == 2: - if len(group) > 0: - all_groups.append(group) - group = [] - allowed_zones = zones[is_a_zone] - all_groups.append(allowed_zones) - else: - for zone in zones[is_a_zone]: - group.append(zone) - - if len(group) > 0: - all_groups.append(group) - - if len(all_groups) == 0: - return - - first_zone = all_groups[0][0] - first_dock = widgets_zone[first_zone][0] - dock = self.docks[first_dock] - self.addDockWidget(areas[side_of_window], dock) - - for group in reversed(all_groups[1:]): - digits = np.array([int(s[-1]) for s in group]) - sorted_indices = np.argsort(digits) - sorted_arr = np.array(group)[sorted_indices] - view_name = widgets_zone[sorted_arr[0]][0] - dock = self.docks[view_name] - self.splitDockWidget(self.docks[first_dock], dock, orientations['horizontal']) - - # Now take each sub-group, and split vertically if appropriate - new_all_groups = [] - for group in all_groups: - - if len(group) == 1: - # if only one in group, not need to split - continue - - top_zones = [zone for zone in group if zone in ['zone1', 'zone2', 'zone3', 'zone4']] - bottom_zones = [zone for zone in group if zone in ['zone5', 'zone6', 'zone7', 'zone8']] - new_all_groups.append([top_zones, bottom_zones]) + def make_half_layout(self, widgets_zone, left_or_right): + """ + Function contains the logic for the greedy layout. Given the 2x2 box of zones - if len(top_zones) > 0 and len(bottom_zones) > 0: + 1 2 3 4 + 5 6 or 7 8 - top_view_name = widgets_zone[top_zones[0]][0] - top_dock = self.docks[top_view_name] + Then depending on which zones are non-zero, a different layout is generated using splits. - bottom_view_name = widgets_zone[bottom_zones[0]][0] - bottom_dock = self.docks[bottom_view_name] + The zone indices in the second box (34,78) are equal to the zone indices first box (12,56) + shifted by 2. We take advantage of this fact. + """ - self.splitDockWidget(top_dock, bottom_dock, orientations['vertical']) + shift = 0 if left_or_right == "left" else 2 - # Finally, split all the sub-sub-groups horizontally - for top_bottom_groups in new_all_groups: - for group in top_bottom_groups: - - if len(group) <= 1: - # if only one in group, no need to split - continue - - first_zone_name = widgets_zone[group[0]][0] - for zone in reversed(group[1:]): - zone_name = widgets_zone[zone][0] - self.splitDockWidget(self.docks[first_zone_name], self.docks[zone_name], orientations['horizontal']) + widgets_zone = fill_unnecessary_space(widgets_zone, shift) + present_zones = get_present_zones_in_half_of_layout(widgets_zone, shift) + if len(present_zones) == 0: + return + # The movements from earlier guarantee that the top-left zone is non-zero. Make this the initial zone + view_name = widgets_zone[f"zone{1+shift}"][0] + dock = self.docks[view_name] + self.addDockWidget(areas[left_or_right], dock) + + # The main logic: apply splittings between different zones and in + # different orders, depending on which zones are present. + + # Layouts with two non-zero zones + if present_zones == set([f'zone{1+shift}', f'zone{2+shift}']): + self.make_split(1,2,"horizontal", widgets_zone, shift) + elif present_zones == set([f'zone{1+shift}', f'zone{5+shift}']): + self.make_split(1,5,"vertical", widgets_zone, shift) + elif present_zones == set([f'zone{1+shift}', f'zone{6+shift}']): + self.make_split(1,6,"horizontal", widgets_zone, shift) + + # Layouts with three non-zero zones + elif present_zones == set([f'zone{1+shift}', f'zone{2+shift}', f'zone{5+shift}']): + self.make_split(1,2,"horizontal", widgets_zone, shift) + self.make_split(1,5,"vertical", widgets_zone, shift) + elif present_zones == set([f'zone{1+shift}', f'zone{2+shift}', f'zone{6+shift}']): + self.make_split(1,2,"horizontal", widgets_zone, shift) + self.make_split(2,6,"vertical", widgets_zone, shift) + elif present_zones == set([f'zone{1+shift}', f'zone{5+shift}', f'zone{6+shift}']): + self.make_split(1,5,"vertical", widgets_zone, shift) + self.make_split(5,6,"horizontal", widgets_zone, shift) + + # Layout with four non-zero zones + elif present_zones == set([f'zone{1+shift}', f'zone{2+shift}', f'zone{5+shift}', f'zone{6+shift}']): + self.make_split(1,5,"vertical", widgets_zone, shift) + self.make_split(1,2,"horizontal", widgets_zone, shift) + self.make_split(5,6,"horizontal", widgets_zone, shift) + + + def make_split(self, zone_index_1, zone_index_2, orientation, widgets_zone, shift): + """ + Splits the zone at `zone_{zone_index_1+shift}` into two zones + (`zone_{zone_index_1+shift}` and `zone_{zone_index_2+shift}`) + with an `orientation` split. + """ + widget_1 = widgets_zone[f"zone{zone_index_1+shift}"][0] + widget_2 = widgets_zone[f"zone{zone_index_2+shift}"][0] + self.splitDockWidget(self.docks[widget_1], self.docks[widget_2], orientations[orientation]) # used by to tell the launcher this is closed def closeEvent(self, event): diff --git a/spikeinterface_gui/utils_global.py b/spikeinterface_gui/utils_global.py index 881763b..48c807a 100644 --- a/spikeinterface_gui/utils_global.py +++ b/spikeinterface_gui/utils_global.py @@ -1,44 +1,48 @@ import numpy as np -def get_size_top_row(initial_row, initial_col, is_zone_array, original_zone_array): - - if original_zone_array[initial_row][initial_col] == False: - return 0,0 - - num_rows = is_zone_array[initial_row][initial_col]*1 - num_cols = num_rows - - num_rows += (not is_zone_array[1][initial_col])*1 - - if num_rows == 1: - for zone in is_zone_array[0,1+initial_col:]: - if zone == True: - break - num_cols += 1 - elif num_rows == 2: - for zone1, zone2 in np.transpose(is_zone_array[:,1+initial_col:]): - if zone1 == True or zone2 == True: - break - num_cols += 1 - - is_zone_array[initial_row:initial_row+num_rows,initial_col:initial_col+num_cols] = True - - return num_rows, num_cols - -def get_size_bottom_row(initial_row, initial_col, is_zone_array, original_zone_array): - - if original_zone_array[initial_row][initial_col] == False: - return 0,0 - - num_rows = is_zone_array[initial_row][initial_col]*1 - if num_rows == 0: - return 0, 0 - num_cols = num_rows - - for zone in is_zone_array[1,1+initial_col:]: - if zone == True: - break - else: - num_cols += 1 - - return num_rows, num_cols \ No newline at end of file +# Functions for the layout + +def fill_unnecessary_space(layout_zone, shift): + """ + Used when making layouts. In the zoning algorithm, + certain layouts are equivalent to each other e.g. + + zone1 zone2 . . + . . is equivalent to zone5 zone6 + + and + + . zone2 zone1 . + . zone6 is equivalent to zone5 . + + This function moves zones left-wards and upwards in a way that preserves + the layouts and ensures that the top-left zone is non-zero. + """ + + # Move the right hand column leftwards if the left-hand column is missing + if len(layout_zone[f'zone{1+shift}']) == 0 and len(layout_zone[f'zone{5+shift}']) == 0: + layout_zone[f'zone{1+shift}'] = layout_zone[f'zone{2+shift}'] + layout_zone[f'zone{5+shift}'] = layout_zone[f'zone{6+shift}'] + layout_zone[f'zone{2+shift}'] = [] + layout_zone[f'zone{6+shift}'] = [] + + # Move the bottom-left zone to the top-left, if the top-left is missing + # These steps reduce the number of layouts we have to consider + if len(layout_zone[f'zone{1+shift}']) == 0: + layout_zone[f'zone{1+shift}'] = layout_zone[f'zone{5+shift}'] + layout_zone[f'zone{5+shift}'] = [] + + return layout_zone + + +def get_present_zones_in_half_of_layout(layout_zone, shift): + """ + Returns the zones which contain at least one view, for either: + left-hand zones 1,2,5,6 (shift=0) + right-hand zones 3,4,7,8 (shift=2) + """ + zones_in_half = [f'zone{1+shift}', f'zone{2+shift}', f'zone{5+shift}', f'zone{6+shift}'] + half_dict = {key: value for key, value in layout_zone.items() if key in zones_in_half} + is_present = [views is not None and len(views) > 0 for views in half_dict.values()] + present_zones = set(np.array(list(half_dict.keys()))[np.array(is_present)]) + return present_zones \ No newline at end of file