from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
import warnings
import numpy as np
from dipy.testing.decorators import warning_for_keywords
from dipy.utils.optpkg import optional_package
from dipy.viz.horizon.util import show_ellipsis
fury, has_fury, setup_module = optional_package("fury", min_version="0.10.0")
if has_fury:
from fury import ui
from fury.data import read_viz_icons
[docs]
@dataclass
class HorizonUIElement:
"""Dataclass to define properties of horizon ui elements."""
visibility: bool
selected_value: Any
obj: Any
position = (0, 0)
size = ("auto", "auto")
[docs]
class HorizonTab(ABC):
"""Base for different tabs available in horizon."""
def __init__(self):
self._elements = []
self.hide = lambda *args: None
self.show = lambda *args: None
[docs]
@abstractmethod
def build(self, tab_id):
"""Build all the elements under the tab.
Parameters
----------
tab_id : int
Id of the tab.
"""
def _register_elements(self, *args):
"""Register elements for rendering.
Parameters
----------
*args : HorizonUIElement(s)
Elements to be register for rendering.
"""
self._elements += list(args)
def _toggle_actors(self, checkbox):
"""Toggle actors in the scene. This helps removing the actor to
interact with actors behind this actor.
Parameters
----------
checkbox : CheckBox2D
"""
if "" in checkbox.checked_labels:
self.show(*self.actors)
else:
self.hide(*self.actors)
[docs]
def on_tab_selected(self):
"""Implement if require to update something while the tab becomes
active.
"""
if hasattr(self, "_actor_toggle"):
self._toggle_actors(self._actor_toggle.obj)
@property
@abstractmethod
def name(self):
"""Name of the tab."""
@property
@abstractmethod
def actors(self):
"""Name of the tab."""
@property
def tab_id(self):
"""Id of the tab. Reference for Tab Manager to identify the tab.
Returns
-------
int
"""
return self._tab_id
@property
def elements(self):
"""list of underlying FURY ui elements in the tab."""
return self._elements
[docs]
class TabManager:
"""
A Manager for tabs of the table panel.
Attributes
----------
tab_ui : TabUI
Underlying FURY TabUI object.
"""
@warning_for_keywords()
def __init__(
self,
tabs,
win_size,
on_tab_changed,
add_to_scene,
*,
remove_from_scene,
sync_slices=False,
sync_volumes=False,
sync_peaks=False,
):
num_tabs = len(tabs)
self._tabs = tabs
self._add_to_scene = add_to_scene
self._remove_from_scene = remove_from_scene
self._synchronize_slices = sync_slices
self._synchronize_volumes = sync_volumes
self._synchronize_peaks = sync_peaks
win_width, _win_height = win_size
self._tab_size = (1280, 240)
x_pad = np.rint((win_width - self._tab_size[0]) / 2)
self._active_tab_id = num_tabs - 1
self._tab_ui = ui.TabUI(
position=(x_pad, 5),
size=self._tab_size,
nb_tabs=num_tabs,
active_color=(1, 1, 1),
inactive_color=(0.5, 0.5, 0.5),
draggable=True,
startup_tab_id=self._active_tab_id,
)
self._tab_ui.on_change = self._tab_selected
self.tab_changed = on_tab_changed
slices_tabs = list(
filter(lambda x: x.__class__.__name__ == "SlicesTab", self._tabs)
)
if not self._synchronize_slices and slices_tabs:
msg = (
"Images are of different dimensions, "
+ "synchronization of slices will not work"
)
warnings.warn(msg, stacklevel=2)
for tab_id, tab in enumerate(tabs):
self._tab_ui.tabs[tab_id].title_font_size = 18
tab.hide = self._hide_elements
tab.show = self._show_elements
tab.build(tab_id)
if tab.__class__.__name__ == "SlicesTab":
tab.on_volume_change = self.synchronize_volumes
if tab.__class__.__name__ in ["SlicesTab", "PeaksTab"]:
tab.on_slice_change = self.synchronize_slices
self._render_tab_elements(tab.tab_id, tab.elements)
[docs]
def handle_text_overflows(self):
for tab_id, tab in enumerate(self._tabs):
self._handle_title_overflow(tab.name, self._tab_ui.tabs[tab_id].text_block)
if tab.__class__.__name__ == "SlicesTab":
self._handle_label_text_overflow(tab.elements)
def _handle_label_text_overflow(self, elements):
for element in elements:
if (
not element.size[0] == "auto"
and element.obj.__class__.__name__ == "TextBlock2D"
and isinstance(element.position, tuple)
):
element.obj.message = show_ellipsis(
element.selected_value, element.obj.size[0], element.size[0]
)
def _handle_title_overflow(self, title_text, title_block):
"""Handle overflow of the tab title and show ellipsis if required.
Parameters
----------
title_text : str
Text to be shown on the tab.
title_block : TextBlock2D
Fury UI element for holding the title of the tab.
"""
tab_text = title_text.split(".", 1)[0]
title_block.message = tab_text
available_space, _ = self._tab_size
text_size = title_block.size[0]
max_width = (available_space / len(self._tabs)) - 15
title_block.message = show_ellipsis(tab_text, text_size, max_width)
def _render_tab_elements(self, tab_id, elements):
for element in elements:
if isinstance(element.position, list):
for i, position in enumerate(element.position):
self._tab_ui.add_element(tab_id, element.obj[i], position)
else:
self._tab_ui.add_element(tab_id, element.obj, element.position)
def _hide_elements(self, *args):
"""Hide elements from the scene.
Parameters
----------
*args : HorizonUIElement or FURY actors
Elements to be hidden.
"""
self._remove_from_scene(*self._get_vtkActors(*args))
def _show_elements(self, *args):
"""Show elements in the scene.
Parameters
----------
*args : HorizonUIElement or FURY actors
Elements to be hidden.
"""
self._add_to_scene(*self._get_vtkActors(*args))
def _get_vtkActors(self, *args):
elements = []
vtk_actors = []
for element in args:
if element.__class__.__name__ == "HorizonUIElement":
if isinstance(element.obj, list):
for obj in element.obj:
elements.append(obj)
else:
elements.append(element.obj)
else:
elements.append(element)
for element in elements:
if hasattr(element, "_get_actors") and callable(element._get_actors):
vtk_actors += element.actors
else:
vtk_actors.append(element)
return vtk_actors
def _tab_selected(self, tab_ui):
if self._active_tab_id == tab_ui.active_tab_idx:
self._active_tab_id = -1
return
self._active_tab_id = tab_ui.active_tab_idx
current_tab = self._tabs[self._active_tab_id]
self.tab_changed(current_tab.actors)
current_tab.on_tab_selected()
[docs]
def reposition(self, win_size):
"""
Reposition the tabs panel.
Parameters
----------
win_size : (float, float)
size of the horizon window.
"""
win_width, _win_height = win_size
x_pad = np.rint((win_width - self._tab_size[0]) / 2)
self._tab_ui.position = (x_pad, 5)
[docs]
def synchronize_slices(self, active_tab_id, x_value, y_value, z_value):
"""
Synchronize slicers for all the images and peaks.
Parameters
----------
active_tab_id: int
tab_id of the action performing tab
x_value: float
x-value of the active slicer
y_value: float
y-value of the active slicer
z_value: float
z-value of the active slicer
"""
if not self._synchronize_slices and not self._synchronize_peaks:
return
for tab in self._get_non_active_tabs(active_tab_id, ["SlicesTab", "PeaksTab"]):
tab.update_slices(x_value, y_value, z_value)
[docs]
def synchronize_volumes(self, active_tab_id, value):
"""Synchronize volumes for all the images with volumes.
Parameters
----------
active_tab_id : int
tab_id of the action performing tab
value : float
volume value of the active volume slider
"""
if not self._synchronize_volumes:
return
for slices_tab in self._get_non_active_tabs(active_tab_id):
slices_tab.update_volume(value)
def _get_non_active_tabs(self, active_tab_id, types=("SlicesTab",)):
"""Get tabs which are not active and slice tabs.
Parameters
----------
active_tab_id : int
types : list(str), optional
Returns
-------
list
"""
return list(
filter(
lambda x: x.__class__.__name__ in types
and not x.tab_id == active_tab_id,
self._tabs,
)
)
@property
def tab_ui(self):
"""FURY TabUI object."""
return self._tab_ui
[docs]
@warning_for_keywords()
def build_label(text, *, font_size=16, bold=False):
"""Simple utility function to build labels.
Parameters
----------
text : str
font_size : int, optional
bold : bool, optional
Returns
-------
label : TextBlock2D
"""
label = ui.TextBlock2D()
label.message = text
label.font_size = font_size
label.font_family = "Arial"
label.justification = "left"
label.bold = bold
label.italic = False
label.shadow = False
label.actor.GetTextProperty().SetBackgroundColor(0, 0, 0)
label.actor.GetTextProperty().SetBackgroundOpacity(0.0)
label.color = (0.7, 0.7, 0.7)
return HorizonUIElement(True, text, label)
[docs]
@warning_for_keywords()
def build_slider(
initial_value,
max_value,
*,
min_value=0,
length=450,
line_width=3,
radius=8,
font_size=16,
text_template="{value:.1f} ({ratio:.0%})",
on_moving_slider=lambda _slider: None,
on_value_changed=lambda _slider: None,
on_change=lambda _slider: None,
on_handle_released=lambda _istyle, _obj, _slider: None,
label="",
label_font_size=16,
label_style_bold=False,
is_double_slider=False,
):
"""Create a horizon theme based disk-knob slider.
Parameters
----------
initial_value : float, (float, float)
Initial value(s) of the slider.
max_value : float
Maximum value of the slider.
min_value : float, optional
Minimum value of the slider.
length : int, optional
Length of the slider.
line_width : int, optional
Width of the line on which the disk will slide.
radius : int, optional
Radius of the disk handle.
font_size : int, optional
Size of the text to display alongside the slider (pt).
text_template : str, callable, optional
If str, text template can contain one or multiple of the
replacement fields: `{value:}`, `{ratio:}`.
If callable, this instance of `:class:LineSlider2D` will be
passed as argument to the text template function.
on_moving_slider : callable, optional
When the slider is interacted by the user.
on_value_changed : callable, optional
When value of the slider changed programmatically.
on_change : callable, optional
When value of the slider changed.
on_handle_released: callable, optional
When handle released.
label : str, optional
Label to ui element for slider
label_font_size : int, optional
Size of label text to display with slider
label_style_bold : bool, optional
Is label should have bold style.
is_double_slider : bool, optional
True if the slider allows to adjust two values.
Returns
-------
label : HorizonUIElement
Slider label.
HorizonUIElement
Slider.
"""
if is_double_slider and "ratio" in text_template:
warnings.warn("Double slider only support values and not ratio", stacklevel=2)
return
slider_label = build_label(label, font_size=label_font_size, bold=label_style_bold)
if not is_double_slider:
slider = ui.LineSlider2D(
initial_value=initial_value,
max_value=max_value,
min_value=min_value,
length=length,
line_width=line_width,
outer_radius=radius,
font_size=font_size,
text_template=text_template,
)
else:
slider = ui.LineDoubleSlider2D(
initial_values=initial_value,
max_value=max_value,
min_value=min_value,
length=length,
line_width=line_width,
outer_radius=radius,
font_size=font_size,
text_template=text_template,
)
slider.on_moving_slider = on_moving_slider
slider.on_value_changed = on_value_changed
slider.on_change = on_change
if not is_double_slider:
slider.handle_events(slider.handle.actor)
slider.on_left_mouse_button_released = on_handle_released
slider.default_color = (1.0, 0.5, 0.0)
slider.track.color = (0.8, 0.3, 0.0)
slider.active_color = (0.9, 0.4, 0.0)
if not is_double_slider:
slider.handle.color = (1.0, 0.5, 0.0)
else:
slider.handles[0].color = (1.0, 0.5, 0.0)
slider.handles[1].color = (1.0, 0.5, 0.0)
return slider_label, HorizonUIElement(True, initial_value, slider)
[docs]
@warning_for_keywords()
def build_checkbox(
*,
labels=None,
checked_labels=None,
padding=1,
font_size=16,
on_change=lambda _checkbox: None,
):
"""Create horizon theme checkboxes.
Parameters
----------
labels : list(str), optional
List of labels of each option.
checked_labels: list(str), optional
List of labels that are checked on setting up.
padding : float, optional
The distance between two adjacent options element
font_size : int, optional
Size of the text font.
on_change : callback, optional
When checkbox value changed
Returns
-------
checkbox : HorizonUIElement
"""
if labels is None or not labels:
warnings.warn(
"At least one label needs to be to create checkboxes", stacklevel=2
)
return
if checked_labels is None:
checked_labels = ()
checkboxes = ui.Checkbox(
labels=labels,
checked_labels=checked_labels,
padding=padding,
font_size=font_size,
)
checkboxes.on_change = on_change
return HorizonUIElement(True, checked_labels, checkboxes)
[docs]
@warning_for_keywords()
def build_switcher(
*,
items=None,
label="",
initial_selection=0,
on_prev_clicked=lambda _selected_value: None,
on_next_clicked=lambda _selected_value: None,
on_value_changed=lambda _selected_idx, _selected_value: None,
):
"""Create horizon theme switcher.
Parameters
----------
items : list, optional
dictionaries with keys 'label' and 'value'. Label will be used to show
it to user and value will be used for selection.
label : str, optional
label for the switcher.
initial_selection : int, optional
index of the selected item initially.
on_prev_clicked : callback, optional
method providing a callback when prev value is selected in switcher.
on_next_clicked : callback, optional
method providing a callback when next value is selected in switcher.
on_value_changed : callback, optional
method providing a callback when either prev or next value selected in
switcher.
Returns
-------
HorizonCombineElement(
label: HorizonUIElement,
element(switcher): HorizonUIElement)
Notes
-----
switcher: consists 'obj' which is an array providing FURY UI elements used.
"""
if items is None:
warnings.warn("No items passed in switcher", stacklevel=2)
return
num_items = len(items)
if initial_selection >= num_items:
initial_selection = 0
switch_label = build_label(text=label)
selection_label = build_label(text=items[initial_selection]["label"]).obj
left_button = ui.Button2D(
icon_fnames=[("left", read_viz_icons(fname="circle-left.png"))], size=(25, 25)
)
right_button = ui.Button2D(
icon_fnames=[("right", read_viz_icons(fname="circle-right.png"))], size=(25, 25)
)
switcher = HorizonUIElement(
True,
[initial_selection, items[initial_selection]["value"]],
[left_button, selection_label, right_button],
)
def left_clicked(_i_ren, _obj, _button):
selected_id = switcher.selected_value[0] - 1
if selected_id < 0:
selected_id = num_items - 1
value_changed(selected_id)
on_prev_clicked(items[selected_id]["value"])
on_value_changed(selected_id, items[selected_id]["value"])
def right_clicked(_i_ren, _obj, _button):
selected_id = switcher.selected_value[0] + 1
if selected_id >= num_items:
selected_id = 0
value_changed(selected_id)
on_next_clicked(items[selected_id]["value"])
on_value_changed(selected_id, items[selected_id]["value"])
def value_changed(selected_id):
switcher.selected_value[0] = selected_id
switcher.selected_value[1] = items[selected_id]["value"]
selection_label.message = items[selected_id]["label"]
left_button.on_left_mouse_button_clicked = left_clicked
right_button.on_left_mouse_button_clicked = right_clicked
return (switch_label, switcher)