Source code for dipy.viz.skyline.UI.manager

"""Skyline sidebar window: section grouping, file dialogs, and font setup."""

from collections.abc import Callable

from dipy.utils.optpkg import optional_package
from dipy.viz.skyline.UI.elements import (
    color_picker,
    colors_equal,
    loading,
    render_file_dialog,
    render_section_header,
)
from dipy.viz.skyline.UI.theme import FONT, FONT_AWESOME, THEME

imgui_bundle, has_imgui, _ = optional_package(
    "imgui_bundle", min_version="1.92.600", max_version="1.92.801"
)
if has_imgui:
    imgui = imgui_bundle.imgui
    icons_fontawesome_6 = imgui_bundle.icons_fontawesome_6


_GROUP_ORDER = ["image", "tractography", "peak", "sh_glyph", "roi", "surface"]
_GROUP_LABELS = {
    "image": "Images",
    "tractography": "Tractograms",
    "peak": "Peaks",
    "sh_glyph": "ODFs",
    "roi": "ROIs",
    "surface": "Surfaces",
}


[docs] class UIManager: """Represent ``UIManager`` in Skyline.""" def __init__(self): """Represent ``UIManager`` in Skyline.""" self.windows = {}
[docs] def add_window(self, window_name, window_instance): """Register ``window_instance`` under ``window_name``. Parameters ---------- window_name : str Dictionary key for later lookup. window_instance : UIWindow Arbitrary window object owned by the caller. """ self.windows[window_name] = window_instance
[docs] class UIWindow: """Represent ``UIWindow`` in Skyline. Parameters ---------- title : str Title text shown in the UI. default_open : bool, optional Value for ``default open``. flags : int, optional Value for ``flags``. pos : tuple(int, int), optional Value for ``pos``. size : tuple(int, int), optional Value for ``size``. logo_tex_ref : TextureId, optional, optional Value for ``logo tex ref``. render_callback : callable, optional Callback used to request a render/update. file_dialog_callback : callable, optional Callback invoked after file selection. bg_color_callback : callable, optional Callback invoked when background color changes. """ def __init__( self, title, *, default_open=True, flags=0, pos=(0, 0), size=(400, 400), logo_tex_ref=None, render_callback=None, file_dialog_callback=None, bg_color_callback=None, snapshot_callback=None, ): """Represent ``UIWindow`` in Skyline. Parameters ---------- title : str Title text shown in the UI. default_open : bool, optional Value for ``default open``. flags : int, optional Value for ``flags``. pos : tuple(int, int), optional Value for ``pos``. size : tuple(int, int), optional Value for ``size``. logo_tex_ref : TextureId, optional, optional Value for ``logo tex ref``. render_callback : callable, optional Callback used to request a render/update. file_dialog_callback : callable, optional Callback invoked after file selection. bg_color_callback : callable, optional Callback invoked when background color changes. snapshot_callback : callable, optional Callback invoked when a snapshot path is selected. """ self.title = title self.is_open = default_open self.flags = flags self.pos = pos self.size = size self._sections = {} self._section_open = {} self._group_open = {} self._group_visible = {} self._render_callback = render_callback self.logo_tex_ref = logo_tex_ref self.logo_size = (32, 32) self._title_text = "DIPY SKYLINE" self._show_loader = False self._loading_message = "Loading..." self.render_callback = render_callback if render_callback is None or not isinstance(render_callback, Callable): self.render_callback = lambda: None # ``render`` calls this after dropping a closed section; keep it in sync # with the normalized callback so a missing one is a no-op, not a crash. self._render_callback = self.render_callback self.file_dialog_callback = file_dialog_callback if file_dialog_callback is None or not isinstance( file_dialog_callback, Callable ): self.file_dialog_callback = lambda: None self.bg_color_callback = bg_color_callback if bg_color_callback is None or not isinstance(bg_color_callback, Callable): self.bg_color_callback = lambda: None self.snapshot_callback = snapshot_callback if snapshot_callback is None or not isinstance(snapshot_callback, Callable): self.snapshot_callback = lambda: None self._bg_color = (0.1, 0.1, 0.1) self._draft_color = self._bg_color self._color_picker_open = False self._color_picker_popup_id = f"bg_color_picker_popup##{title}" self._is_dialog_open = False # Use direct imgui API to avoid font atlas rebuilds that cause # texture ID collisions on macOS io = imgui.get_io() font_size = 16.0 io.fonts.clear() io.fonts.add_font_from_file_ttf(str(FONT), font_size) fa_cfg = imgui.ImFontConfig() fa_cfg.merge_mode = True fa_cfg.pixel_snap_h = True fa_cfg.glyph_min_advance_x = font_size io.fonts.add_font_from_file_ttf(str(FONT_AWESOME), font_size, fa_cfg) self.request_file_dialog = False
[docs] def add(self, name, section_renderer, *, viz_type=None): """Register a visualization section callable and optional grouping type. Parameters ---------- name : str Unique key, usually ``f"{path}:{display_name}"``. section_renderer : callable ``renderer(is_open, group_visible=...)`` from :class:`Visualization`. viz_type : str or None, optional One of the keys in ``_GROUP_ORDER`` used to cluster the sidebar. """ self._sections[name] = (section_renderer, viz_type) self._section_open.setdefault(name, False)
[docs] def remove(self, name): """Drop a section and its open-state bookkeeping. Parameters ---------- name : str Key previously passed to :meth:`add`. """ if name in self._sections: del self._sections[name] if name in self._section_open: del self._section_open[name]
[docs] def render(self): """Draw the full sidebar for the current frame.""" imgui.push_style_color( imgui.Col_.window_bg, imgui.get_color_u32(THEME["background"]) ) if self.pos is not None: imgui.set_next_window_pos(self.pos) if self.size is not None: imgui.set_next_window_size(self.size, imgui.Cond_.always) computed_flags = ( self.flags | imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_resize | imgui.WindowFlags_.no_title_bar | imgui.WindowFlags_.no_scroll_with_mouse | imgui.WindowFlags_.no_scrollbar ) imgui.begin(self.title, None, computed_flags) imgui.push_id("logo") imgui.push_font(None, 36) draw_list = imgui.get_foreground_draw_list() spacing = 10 available_width = imgui.get_content_region_avail().x org_start = imgui.get_cursor_screen_pos() start = (org_start.x, org_start.y + spacing) text_width, text_height = imgui.calc_text_size(self._title_text) total_width = self.logo_size[0] + spacing + text_width offset_x = max((available_width - total_width) * 0.5, 0.0) start = (start[0] + offset_x, start[1]) draw_list.add_image( self.logo_tex_ref, start, (start[0] + self.logo_size[0], start[1] + self.logo_size[1]), ) title_text_pos = ( start[0] + self.logo_size[0] + spacing, start[1] + (self.logo_size[1] - text_height) * 0.5, ) text_color = imgui.get_color_u32(THEME["text"]) shadow_color = imgui.get_color_u32(THEME["shadow"]) draw_list.add_text(title_text_pos, text_color, self._title_text) rect_min = (start[0] - spacing, start[1] + self.logo_size[1] + spacing) rect_max = (start[0] + total_width + spacing, rect_min[1] + 1) draw_list.add_rect_filled(rect_min, rect_max, shadow_color, 0, 0) imgui.pop_font() file_icon = icons_fontawesome_6.ICON_FA_FILE_CIRCLE_PLUS file_icon_size = imgui.calc_text_size(file_icon) file_icon_pos = (start[0], rect_max[1] + spacing) draw_list.add_text(file_icon_pos, text_color, file_icon) imgui.set_cursor_screen_pos(file_icon_pos) imgui.invisible_button("add_visualization", file_icon_size) if imgui.is_item_hovered(): imgui.set_item_tooltip("Add Visualization") if imgui.is_item_clicked(imgui.MouseButton_.left) or self.request_file_dialog: if self.request_file_dialog: imgui.set_next_window_pos((self.size[0], 0)) imgui.open_popup("my_popup") if imgui.begin_popup("my_popup"): imgui.text("Select Visualization Type") imgui.separator() if imgui.menu_item("3D/4D Images", "", False)[0]: self.request_file_dialog = False self._is_dialog_open = True render_file_dialog( title="Select Image File(s)", name="Image Files (*.nii *.nii.gz)", extensions="*.nii *.gz", callback=self._file_dialog_closed, type="viz", ) if imgui.menu_item("Peaks", "", False)[0]: self.request_file_dialog = False self._is_dialog_open = True render_file_dialog( title="Select Peak File(s)", name="Peak Files (*.pam5)", extensions="*.pam5", callback=self._file_dialog_closed, type="viz", ) if imgui.menu_item("ODFs", "", False)[0]: self.request_file_dialog = False self._is_dialog_open = True render_file_dialog( title="Select Spherical Harmonics ODFs File(s)", name="ODFs Files (*.pam5)", extensions="*.pam5", callback=self._file_dialog_closed, type="shm_coeff", ) if imgui.menu_item("Surfaces", "", False)[0]: self.request_file_dialog = False self._is_dialog_open = True render_file_dialog( title="Select Surface File(s)", name="Surface Files (*.pial *.gii *.gii.gz)", extensions="*.pial *.gii *.gz", callback=self._file_dialog_closed, type="viz", ) if imgui.menu_item("Tractograms", "", False)[0]: self.request_file_dialog = False self._is_dialog_open = True render_file_dialog( title="Select Tractogram File(s)", name="Tractogram Files (*.trx *.trk *.tck *.fib *.dpy *.vtp *.vtk)", extensions="*.trx *.trk *.tck *.fib *.dpy *.vtp *.vtk", callback=self._file_dialog_closed, type="viz", ) if imgui.menu_item("ROIs", "", False)[0]: self.request_file_dialog = False self._is_dialog_open = True render_file_dialog( title="Select ROI File(s)", name="ROI Files (*.nii *.nii.gz)", extensions="*.nii *.gz", callback=self._file_dialog_closed, type="roi", ) imgui.end_popup() imgui.same_line(0, 8) selected_color = ( self._draft_color if self._color_picker_open else self._bg_color ) changed, color, is_open = color_picker( selected_color=selected_color, tooltip="Change Background Color", popup_id=self._color_picker_popup_id, ) if is_open and not self._color_picker_open: self._draft_color = self._bg_color if changed: self._draft_color = color if self._color_picker_open and not is_open: if not colors_equal(self._draft_color, self._bg_color): self._update_bg_color(self._draft_color) self._draft_color = self._bg_color self._color_picker_open = is_open imgui.same_line(0, 8) snapshot_icon = icons_fontawesome_6.ICON_FA_CAMERA imgui.text_colored(THEME["text"], snapshot_icon) if imgui.is_item_hovered(): imgui.set_item_tooltip("Take Snapshot") if imgui.is_item_clicked(imgui.MouseButton_.left): render_file_dialog( title="Save Snapshot", name="PNG Files (*.png)", extensions="*.png", multiselect=False, callback=self._snapshot_dialog_closed, dialog_type="save", file_name="snapshot.png", type="viz", ) imgui.set_cursor_screen_pos(org_start) imgui.dummy((available_width, self.logo_size[1] + spacing * 5 + 1)) imgui.pop_id() imgui.begin_child( "sections", None, 0, imgui.WindowFlags_.no_title_bar | imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_resize, ) names_to_remove = [] # Group sections by viz_type grouped = {t: [] for t in _GROUP_ORDER} ungrouped = [] for name, (renderer, viz_type) in self._sections.items(): if viz_type in grouped: grouped[viz_type].append((name, renderer)) else: ungrouped.append((name, renderer)) # Render each type group in order for viz_type in _GROUP_ORDER: items = grouped[viz_type] if not items: continue group_label = _GROUP_LABELS[viz_type] imgui.push_id(f"group_{viz_type}") group_is_open = self._group_open.get(viz_type, True) group_is_visible = self._group_visible.get(viz_type, True) group_is_open, group_is_visible, _, _ = render_section_header( group_label, is_open=group_is_open, is_visible=group_is_visible, type=viz_type, show_close=False, show_info=False, ) self._group_open[viz_type] = group_is_open self._group_visible[viz_type] = group_is_visible if group_is_open: child_flags = imgui.ChildFlags_.auto_resize_y if imgui.begin_child(f"group_{viz_type}_child", (0, 0), child_flags): for name, renderer in items: imgui.push_id(name) is_open = self._section_open.get(name, False) is_open, is_removed, should_enable_group = renderer( is_open, group_visible=group_is_visible ) self._section_open[name] = is_open if should_enable_group: self._group_visible[viz_type] = True group_is_visible = True # Only show the clicked item, hide others in this group for other_name, ( other_renderer, other_type, ) in self._sections.items(): if other_type == viz_type and other_name != name: other_renderer.__self__._visible = False if is_removed: names_to_remove.append(name) imgui.pop_id() imgui.end_child() imgui.pop_id() # Render ungrouped items (fallback for unrecognised types) for name, renderer in ungrouped: imgui.push_id(name) is_open = self._section_open.get(name, False) is_open, is_removed, _ = renderer(is_open) self._section_open[name] = is_open if is_removed: names_to_remove.append(name) imgui.pop_id() imgui.end_child() for name in names_to_remove: self.remove(name) self._render_callback() imgui.end() imgui.pop_style_color(1) if self._show_loader and not imgui.is_popup_open("LoadingOverlay"): imgui.open_popup("LoadingOverlay") loading("LoadingOverlay", self._loading_message, self._show_loader)
@property def sections(self): """Map section id to ``(renderer_callable, viz_type)`` tuples.""" return self._sections @property def section_open_states(self): """Collapsed/open flags for each registered section id.""" return self._section_open def _file_dialog_closed(self, *, filenames=None, rois=None, shm_coeffs=None): """Forward dialog results to :attr:`file_dialog_callback` if present. Parameters ---------- filenames : list or None, optional Selected visualization paths. rois : list or None, optional Selected ROI paths. shm_coeffs : list or None, optional Selected SH coefficient paths. """ self._is_dialog_open = False if self.file_dialog_callback is not None: self.file_dialog_callback( filenames=filenames, rois=rois, shm_coeffs=shm_coeffs ) def _update_bg_color(self, new_color): """Store the sidebar's background picker color and notify the host scene. Parameters ---------- new_color : tuple of float RGB triplet in ``[0, 1]``. """ self._bg_color = new_color if self.bg_color_callback is not None: self.bg_color_callback(new_color) def _snapshot_dialog_closed(self, *, filenames=None, rois=None, shm_coeffs=None): """Forward snapshot path to :attr:`snapshot_callback` when selected. Parameters ---------- filenames : list, str, or None, optional Selected save target path(s) from the dialog. rois : list or None, optional Unused; kept for callback signature compatibility. shm_coeffs : list or None, optional Unused; kept for callback signature compatibility. """ if self.snapshot_callback is None or filenames is None: return if isinstance(filenames, str): snapshot_path = filenames elif len(filenames) > 0: snapshot_path = filenames[0] else: return self.snapshot_callback(snapshot_path)
[docs] def update_loader(self, *, show, message=None): """Toggle the modal loading overlay. Parameters ---------- show : bool When True, ensure the loading popup is opened. message : str, optional User-facing status string. """ self._show_loader = show if message is not None: self._loading_message = message