"""Reusable ImGui controls styled for the Skyline sidebar."""
import math
from pathlib import Path
import numpy as np
from dipy.utils.logging import logger
from dipy.utils.optpkg import optional_package
from dipy.viz.skyline.UI.theme import (
DROPDOWN_THEME,
SLIDER_THEME,
SWITCH_THEME,
THEME,
WINDOW_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
hello_imgui = imgui_bundle.hello_imgui
icons_fontawesome_6 = imgui_bundle.icons_fontawesome_6
imspinner = imgui_bundle.imspinner
pfd = imgui_bundle.portable_file_dialogs
_NUMERIC_INPUT_EDITING = {}
_NUMERIC_INPUT_DRAFT = {}
_LAST_DIR = Path("~").expanduser() / ".dipy"
def _ensure_last_dir():
"""Return a valid directory for native file dialogs.
Ensures ``_LAST_DIR`` points to an existing directory. If the path points to
a file, its parent directory is used. If directory creation fails, falls
back to the user home directory.
Returns
-------
pathlib.Path
Existing directory to use as dialog start location.
"""
global _LAST_DIR
try:
if _LAST_DIR.is_file():
_LAST_DIR = _LAST_DIR.parent
_LAST_DIR.mkdir(parents=True, exist_ok=True)
except OSError as exc:
logger.warning(
f"Could not initialize skyline dialog directory at {_LAST_DIR}: {exc}"
)
_LAST_DIR = Path.home()
return _LAST_DIR
def _set_last_dir(path):
"""Update ``_LAST_DIR`` using a selected file path.
Parameters
----------
path : str or pathlib.Path
Selected file path returned by the file dialog.
"""
global _LAST_DIR
_LAST_DIR = Path(path).parent
[docs]
def colors_equal(color_a, color_b):
"""Return True when two RGB-like values represent the same color.
This function ignores the alpha channel.
Parameters
----------
color_a : tuple
First color to compare.
color_b : tuple
Second color to compare.
Returns
-------
bool
True when the two colors are equal, False otherwise.
"""
if isinstance(color_a, str) and isinstance(color_b, str):
return color_a == color_b
elif isinstance(color_a, str) or isinstance(color_b, str):
return False
color_a_arr = np.asarray(color_a)
color_b_arr = np.asarray(color_b)
if color_a_arr.ndim != 1 or color_b_arr.ndim != 1:
return False
return np.array_equal(color_a_arr[:3], color_b_arr[:3])
[docs]
def normalize_picker_color(color, *, fallback=(1.0, 0.0, 0.0)):
"""Return an RGB tuple suitable for ImGui color picker widgets.
Parameters
----------
color : tuple
Color to normalize.
fallback : tuple, optional
Fallback color to return if the input color is not valid.
Returns
-------
tuple
An RGB tuple suitable for ImGui color picker widgets.
"""
if isinstance(color, str):
return fallback
color_arr = np.asarray(color)
if color_arr.ndim != 1 or color_arr.size < 3:
return fallback
return tuple(float(channel) for channel in color_arr[:3])
[docs]
def render_file_dialog(
*,
title="Select File(s)",
name="All File(s)",
extensions="*.*",
multiselect=True,
callback=None,
dialog_type="open",
file_name="save_file",
type="viz",
):
"""Open a native file dialog and forward the result to ``callback``.
Parameters
----------
title : str, optional
Dialog window title.
name : str, optional
Filter label shown in the dialog.
extensions : str, optional
Extension filter string (platform-specific, e.g. ``"*.nii *.gz"``).
multiselect : bool, optional
Allow multiple paths when ``dialog_type`` is ``"open"``.
callback : callable or None, optional
Invoked with keyword arguments matching the selected ``type``, or with
``None`` when the dialog is cancelled (BUAN branch uses a single argument).
dialog_type : {"open", "save"}, optional
Whether to pick existing files or choose a save location.
file_name : str, optional
Default file name for save dialogs.
type : str, optional
Callback convention: ``"viz"`` (``filenames=``), ``"roi"`` (``rois=``),
``"shm_coeff"`` (``shm_coeffs=``), or ``"buan_pvals"`` (raw list/None).
"""
dialog_dir = _ensure_last_dir()
if dialog_type == "open":
dialog = pfd.open_file(
title,
str(dialog_dir),
[name, extensions],
pfd.opt.multiselect if multiselect else pfd.opt.none,
)
elif dialog_type == "save":
dialog = pfd.save_file(
title,
str(dialog_dir / file_name),
[name, extensions],
)
if dialog.result():
selected_files = dialog.result()
if callback is not None:
if type == "viz":
callback(filenames=selected_files)
elif type == "roi":
callback(rois=selected_files)
elif type == "shm_coeff":
callback(shm_coeffs=selected_files)
elif type == "buan_pvals":
callback(selected_files)
_set_last_dir(selected_files[0])
if not dialog.result() and dialog.kill():
if callback is not None:
if type == "buan_pvals":
callback(None)
else:
callback(filenames=None, rois=None, shm_coeffs=None)
def _calculate_hit_box(pos, size, *, padding=4):
"""Calculate hit box for given size and position.
Parameters
----------
pos : imgui.ImVec2Like
Position of the top-left corner.
size : imgui.ImVec2Like
Size of the box.
padding : int, optional
Padding around the box.
Returns
-------
tuple
A tuple containing the minimum and maximum positions of the hit box.
"""
min_pos = imgui.ImVec2(pos[0] - padding, pos[1] - padding)
max_pos = imgui.ImVec2(pos[0] + size[0] + padding, pos[1] + size[1] + padding)
return min_pos, max_pos
[docs]
def open_confirmation_dialog(
title,
message,
*,
okay_text="Okay",
cancel_text="Cancel",
):
"""Drive a modal confirmation popup for the current frame.
Parameters
----------
title : str
Popup identifier; must be stable across frames.
message : str
Body text shown inside the modal.
okay_text : str, optional
Label for the confirm button.
cancel_text : str, optional
Label for the dismiss button.
Returns
-------
str
One of ``"open"``, ``"already_open"``, ``"okay"``, or ``"cancel"`` depending
on popup and button state for this frame.
"""
state = "open" if not imgui.is_popup_open(title) else "already_open"
imgui.push_style_color(
imgui.Col_.title_bg_active, imgui.get_color_u32(THEME["primary"])
)
opened, _ = imgui.begin_popup_modal(title, None, imgui.WindowFlags_.no_resize)
if opened:
imgui.text(message)
style = imgui.get_style()
button_width = (
imgui.calc_text_size(okay_text).x
+ imgui.calc_text_size(cancel_text).x
+ (style.frame_padding.x * 2.0)
)
window_width = imgui.get_window_width()
imgui.push_style_color(imgui.Col_.button, imgui.get_color_u32(THEME["primary"]))
imgui.push_style_color(
imgui.Col_.button_hovered, imgui.get_color_u32(THEME["primary"])
)
imgui.spacing()
imgui.set_cursor_pos_x((window_width - button_width) * 0.5)
if imgui.button(okay_text):
state = "okay"
imgui.close_current_popup()
imgui.same_line(0, 8)
if imgui.button(cancel_text):
state = "cancel"
imgui.close_current_popup()
imgui.pop_style_color(2)
imgui.end_popup()
imgui.pop_style_color(1)
return state
[docs]
def loading(title, message, show):
"""Show or hide a centered modal loading indicator.
Parameters
----------
title : str
ImGui popup id used for the overlay modal.
message : str
Status line shown under the spinner.
show : bool
When False the modal is closed on the next draw.
None
"""
text_width = imgui.calc_text_size(message).x
spinner_radius = 16.0
padding = 32.0
min_width = max(text_width, spinner_radius * 2) + padding
flags = (
imgui.WindowFlags_.no_title_bar
| imgui.WindowFlags_.no_resize
| imgui.WindowFlags_.always_auto_resize
)
imgui.set_next_window_size_constraints(
(min_width, 0),
(float("inf"), float("inf")),
)
opened, _ = imgui.begin_popup_modal(title, None, flags)
if opened:
window_width = imgui.get_window_width()
imgui.set_cursor_pos_x((window_width - spinner_radius * 2) * 0.5)
color = imgui.ImColor(THEME["primary"])
imspinner.spinner_arc_rotation("spinner_id", spinner_radius, 4.0, color)
imgui.dummy((0, 10))
imgui.set_cursor_pos_x((window_width - text_width) * 0.5)
imgui.text_colored(THEME["primary"], message)
if not show:
imgui.close_current_popup()
imgui.end_popup()
[docs]
def warning_message(message):
"""Draw a warning icon with primary-colored text.
Parameters
----------
message : str
Warning to display on the current ImGui line.
None
"""
warning_icon = icons_fontawesome_6.ICON_FA_TRIANGLE_EXCLAMATION
imgui.text_colored(THEME["primary"], warning_icon)
imgui.same_line(0, 4)
imgui.text_colored(THEME["primary"], message)
[docs]
def color_picker(
*,
label="",
selected_color=(0, 0, 0),
tooltip="Pick color",
popup_id="color_picker_popup",
):
"""Create color picker from selected color.
Parameters
----------
label : str, optional
Text to put next to the icon.
selected_color : tuple, optional
Previously selected color.
tooltip : str, optional
Tooltip to show when hovering the color picker.
popup_id : str, optional
Stable popup identifier for this color picker instance.
Returns
-------
tuple
A tuple containing the changed state, the color, and the open state.
``changed`` is True if the user edited the color this frame,
``color`` contains RGB values in ``[0, 1]`` after any edit, and
``is_open`` is True when the color picker popup is currently open.
"""
changed = False
color = selected_color
is_open = False
color_palette_icon = icons_fontawesome_6.ICON_FA_PALETTE
imgui.text_colored(THEME["text"], f"{color_palette_icon} {label}")
if imgui.is_item_hovered():
imgui.set_tooltip(tooltip)
if imgui.is_item_clicked():
imgui.open_popup(popup_id)
if imgui.begin_popup(popup_id):
is_open = True
changed, color = imgui.color_picker3(
"",
imgui.ImVec4(selected_color[0], selected_color[1], selected_color[2], 1.0),
imgui.ColorEditFlags_.no_side_preview,
)
color = np.array([color[0], color[1], color[2]])
imgui.end_popup()
return changed, color, is_open
[docs]
def downloader(label, callback, *, extension="*.*", type="viz", file_name="save.txt"):
"""Render a themed file downloader button.
Parameters
----------
label : str
Text to display on the button.
callback : function
Function to call when the button is clicked. Should return the content to
be saved.
extension : str, optional
File extension for the saved file.
type : str, optional
Type of file being downloaded, used to determine callback behavior.
- "viz": Visualization files (default)
- "roi": Region of Interest files
- "shm_coeff": Spherical Harmonics Coefficients files
- "buan_colors": BUAN color mapping files
file_name : str, optional
Default file name suggested in the save dialog.
None
"""
download_icon = icons_fontawesome_6.ICON_FA_DOWNLOAD
imgui.text_colored(THEME["text"], f"{download_icon} {label}")
if imgui.is_item_hovered():
imgui.set_mouse_cursor(imgui.MouseCursor_.hand)
if imgui.is_item_clicked():
render_file_dialog(
title=f"Save {label}",
name=f"{label} ({extension})",
extensions=extension,
multiselect=False,
callback=callback,
dialog_type="save",
file_name=file_name,
type=type,
)
[docs]
def uploader(
label,
callback,
*,
extension="*.*",
multiselect=False,
selected=False,
type="viz",
):
"""Render a themed file uploader button.
Parameters
----------
label : str
Text to display on the button.
callback : function
Function to call with the selected file(s) when the button is clicked.
extension : str, optional
File extension filter for the file dialog.
multiselect : bool, optional
Whether to allow selecting multiple files.
selected : bool or string, optional
Whether the uploader is in a selected state, affecting its appearance.
type : str, optional
Type of file being uploaded, used to determine callback behavior.
- "viz": Visualization files (default)
- "roi": Region of Interest files
- "shm_coeff": Spherical Harmonics Coefficients files
- "buan_colors": BUAN color mapping files
None
"""
upload_icon = icons_fontawesome_6.ICON_FA_UPLOAD
imgui.text_colored(
THEME["text"] if not selected else THEME["primary"],
f"{upload_icon} {label}" if not selected else f"{upload_icon} {selected}",
)
if imgui.is_item_hovered():
imgui.set_mouse_cursor(imgui.MouseCursor_.hand)
if imgui.is_item_clicked():
render_file_dialog(
title=f"Select {label}",
name=f"{label} ({extension})",
extensions=extension,
multiselect=multiselect,
callback=callback,
type=type,
)
[docs]
def render_group(label, items, *, row_height=26, label_width=36, line_indent=8):
"""Render a grouped list with a tree-like label column and custom rows.
Parameters
----------
label : str
Group heading shown above the table.
items : list of tuple
Sequence of items where each entry is
``(render_fn)`` or ``(render_fn, args, kwargs)``.
``render_fn`` is called with the provided args/kwargs in the content column.
row_height : int, optional
Height of each row in pixels.
label_width : int, optional
Width of the label column in pixels.
line_indent : int, optional
Horizontal indent for the guide line from the row start.
Returns
-------
list of tuple or None
A list of values returned from each ``render_fn`` call, or ``None`` when
``items`` is empty.
"""
if not items:
return
label_color = THEME["text"]
line_color = imgui.get_color_u32(label_color)
imgui.text_colored(label_color, label)
imgui.spacing()
flags = imgui.TableFlags_.sizing_fixed_fit | imgui.TableFlags_.pad_outer_x
if imgui.begin_table(f"group_{label}", 2, flags):
imgui.table_setup_column(
"labels", imgui.TableColumnFlags_.width_fixed, label_width
)
imgui.table_setup_column("content", imgui.TableColumnFlags_.width_stretch)
draw_list = imgui.get_window_draw_list()
text_height = imgui.get_text_line_height()
render_data = []
total_items = len(items)
for idx, item in enumerate(items):
render_fn, *rest = item
args, kwargs = (), {}
if rest:
args = rest[0] if len(rest) >= 1 else ()
kwargs = rest[1] if len(rest) >= 2 else {}
imgui.table_next_row(imgui.TableRowFlags_.none, row_height)
imgui.table_set_column_index(0)
row_pos = imgui.get_cursor_screen_pos()
line_x = row_pos.x + line_indent
text_x = line_x + 16
center_y = row_pos.y + row_height * 0.4
is_last = idx == total_items - 1
vertical_end_y = center_y if is_last else row_pos.y + row_height
draw_list.add_line(
imgui.ImVec2(line_x, row_pos.y),
imgui.ImVec2(line_x, vertical_end_y),
line_color,
1.0,
)
draw_list.add_line(
imgui.ImVec2(line_x, center_y),
imgui.ImVec2(text_x, center_y),
line_color,
1.0,
)
text_y = center_y - text_height * 0.5
imgui.set_cursor_screen_pos((text_x, text_y))
imgui.dummy((1, text_height))
imgui.table_set_column_index(1)
data = render_fn(*args, **kwargs)
render_data.append(data)
imgui.end_table()
return render_data
[docs]
def segmented_switch(label, options, value, *, width=0, height=28):
"""Render a segmented switch control.
Parameters
----------
label : str
Text rendered next to the switch.
options : list of str
Labels for each segment in the switch.
value : str
Currently selected option. If not found in ``options``, the first option
is used.
width : int, optional
Total width for the switch. If 0 or negative, uses the available width.
height : int, optional
Height for each segment in pixels.
Returns
-------
tuple(bool, str)
Whether the selection changed and the resulting option value.
"""
if not options:
return False, value
imgui.push_id(label)
value_options = [option.title() for option in options]
current_value = value if value in value_options else options[0]
label_color = THEME["text"]
imgui.push_style_var(imgui.StyleVar_.frame_padding, (12.0, 6.0))
imgui.align_text_to_frame_padding()
imgui.text_colored(label_color, label)
imgui.same_line(0, 28)
available_width = imgui.get_content_region_avail().x
total_width = width if width and width > 0 else available_width
count = len(options)
button_width = total_width / count if total_width > 0 else 80.0
selected_bg = imgui.get_color_u32(SWITCH_THEME["active_color"])
selected_text = SWITCH_THEME["active_text_color"]
inactive_text = SWITCH_THEME["inactive_text_color"]
container_bg = SWITCH_THEME["background_color"]
border_color = imgui.get_color_u32(SWITCH_THEME["border_color"])
container_rounding = 6.0
imgui.push_style_var(imgui.StyleVar_.item_spacing, (0.0, 0.0))
imgui.push_style_var(imgui.StyleVar_.frame_border_size, 0.0)
imgui.push_style_var(imgui.StyleVar_.frame_rounding, container_rounding)
imgui.push_style_color(imgui.Col_.button, (0, 0, 0, 0))
imgui.push_style_color(imgui.Col_.button_hovered, (0, 0, 0, 0))
imgui.push_style_color(imgui.Col_.button_active, (0, 0, 0, 0))
changed = False
new_value = current_value
button_height = max(height, imgui.get_frame_height())
start = imgui.get_cursor_screen_pos()
end = (start.x + button_width * count, start.y + button_height)
draw_list = imgui.get_window_draw_list()
draw_list.add_rect_filled(
start, end, imgui.get_color_u32(container_bg), container_rounding
)
for idx, option in enumerate(value_options):
if idx > 0:
imgui.same_line(0, 0)
segment_start = (start.x + idx * button_width, start.y)
segment_end = (segment_start[0] + button_width, start.y + button_height)
is_selected = option == current_value
if is_selected:
if count == 1:
corner_flags = imgui.ImDrawFlags_.round_corners_all
elif idx == 0:
corner_flags = imgui.ImDrawFlags_.round_corners_left
elif idx == count - 1:
corner_flags = imgui.ImDrawFlags_.round_corners_right
else:
corner_flags = imgui.ImDrawFlags_.round_corners_none
draw_list.add_rect_filled(
segment_start,
segment_end,
selected_bg,
container_rounding,
corner_flags,
)
imgui.push_style_color(
imgui.Col_.text, selected_text if is_selected else inactive_text
)
if imgui.button(options[idx], (button_width, button_height)):
if option != current_value:
changed = True
new_value = option
imgui.pop_style_color(1)
draw_list.add_rect(start, end, border_color, container_rounding, thickness=1.5)
imgui.pop_style_color(3)
imgui.pop_style_var(4)
imgui.pop_id()
return changed, new_value
[docs]
def dropdown(label, options, value, *, width=0, height=0):
"""Render a themed dropdown/combobox control.
Parameters
----------
label : str
Text rendered next to the dropdown.
options : list[str]
Available options displayed in the dropdown.
value : str
Currently selected option. If not present in ``options``, the first
option is used.
width : int, optional
Width of the dropdown in pixels. If 0 or negative, uses available space.
height : int, optional
Height of the dropdown control in pixels. If 0 or negative, uses the
default style height.
Returns
-------
tuple(bool, str)
Whether the selection changed and the resulting option value.
"""
if not options:
return False, value
imgui.push_id(label)
label_color = THEME["text"]
imgui.align_text_to_frame_padding()
imgui.text_colored(label_color, label)
imgui.same_line(0, 16)
current_value = value if value in options else options[0]
available_width = imgui.get_content_region_avail().x
combo_width = width if width and width > 0 else max(140.0, available_width)
padding_x = 10.0
arrow_icon = icons_fontawesome_6.ICON_FA_ANGLE_DOWN
frame_bg = DROPDOWN_THEME["background_color"]
border_color = DROPDOWN_THEME["border_color"]
text_color = DROPDOWN_THEME["selected_color"]
highlight = DROPDOWN_THEME["hover_color"]
arrow_color = DROPDOWN_THEME["arrow_color"]
imgui.set_next_item_width(combo_width)
style_var_count = 3
imgui.push_style_var(imgui.StyleVar_.frame_rounding, 6.0)
imgui.push_style_var(imgui.StyleVar_.frame_border_size, 1.0)
imgui.push_style_var(imgui.StyleVar_.item_spacing, (0.0, 10.0))
if height and height > 0:
default_padding = imgui.get_style().frame_padding.x
text_height = imgui.get_text_line_height()
vertical_padding = max(0.0, (float(height) - text_height) * 0.5)
imgui.push_style_var(
imgui.StyleVar_.frame_padding, (default_padding, vertical_padding)
)
style_var_count += 1
imgui.push_style_color(imgui.Col_.frame_bg, frame_bg)
imgui.push_style_color(imgui.Col_.frame_bg_hovered, frame_bg)
imgui.push_style_color(imgui.Col_.frame_bg_active, frame_bg)
imgui.push_style_color(imgui.Col_.border, border_color)
imgui.push_style_color(imgui.Col_.text, text_color)
imgui.push_style_color(imgui.Col_.header, highlight)
imgui.push_style_color(imgui.Col_.header_hovered, highlight)
imgui.push_style_color(imgui.Col_.header_active, highlight)
combo_flags = imgui.ComboFlags_.height_regular | imgui.ComboFlags_.no_arrow_button
changed = False
new_value = current_value
opened = imgui.begin_combo(f"##{label}_dropdown", "", combo_flags)
frame_min = imgui.get_item_rect_min()
frame_max = imgui.get_item_rect_max()
draw_list = imgui.get_window_draw_list()
arrow_size = imgui.calc_text_size(arrow_icon)
arrow_pos = (
frame_max.x - padding_x - arrow_size.x,
frame_min.y + (frame_max.y - frame_min.y - arrow_size.y) * 0.52,
)
preview_size = imgui.calc_text_size(current_value)
preview_min_x = frame_min.x + padding_x
preview_max_x = frame_max.x - (padding_x * 2.0 + arrow_size.x)
preview_width = max(0.0, preview_max_x - preview_min_x)
preview_x = preview_min_x + max(0.0, (preview_width - preview_size.x) * 0.5)
preview_y = frame_min.y + (frame_max.y - frame_min.y - preview_size.y) * 0.5
draw_list.add_text(
(preview_x, preview_y), imgui.get_color_u32(text_color), current_value
)
draw_list.add_text(arrow_pos, imgui.get_color_u32(arrow_color), arrow_icon)
if opened:
for option in options:
is_selected = option == current_value
selectable_result = imgui.selectable(option, is_selected)
pressed = (
selectable_result[0]
if isinstance(selectable_result, tuple)
else selectable_result
)
if pressed:
new_value = option
if is_selected:
imgui.set_item_default_focus()
changed = new_value != current_value
imgui.end_combo()
current_value = new_value
imgui.pop_style_color(8)
imgui.pop_style_var(style_var_count)
imgui.pop_id()
return changed, new_value
[docs]
def thin_slider(
label,
value,
min_value,
max_value,
*,
width=0,
step=1.0,
track_height=2.0,
thumb_radius=7.0,
hitbox_height=20.0,
text_format=".3f",
value_type="float",
value_unit=None,
show_toggle=False,
toggle=False,
):
"""Render a compact slider with a thin track and circular thumb.
Parameters
----------
label : str
Text rendered next to the slider.
value : float
Current slider value.
min_value : float
Lower bound for the slider.
max_value : float
Upper bound for the slider.
width : int, optional
Widget width in pixels. Negative values use the available width.
step : float, optional
Increment applied when using keyboard arrows.
track_height : float, optional
Thickness of the slider track in pixels.
thumb_radius : float, optional
Radius of the circular thumb in pixels.
hitbox_height : float, optional
Height of the invisible button capturing pointer interactions.
text_format : str, optional
Format specification passed when displaying float values.
value_type : {"float", "int"}, optional
Numeric type enforced for the slider value.
value_unit : str or None, optional
Optional unit suffix appended to the value display.
show_toggle : bool, optional
When True, prefix a clickable visibility icon before the label.
toggle : bool, optional
Current state for the visibility icon when ``show_toggle`` is True.
Returns
-------
tuple
``(changed, value)`` or ``(changed, value, toggle)`` when ``show_toggle``
is True.
"""
if value_type not in {"float", "int"}:
raise ValueError("value_type must be either 'float' or 'int'")
if value_type == "int" and isinstance(value, float):
logger.warning(
"Value converted to int for integer slider."
" Please provide value_type as 'float' if float is intended."
)
imgui.push_id(label)
if width > 0:
imgui.push_item_width(width)
if show_toggle:
show_icon = (
icons_fontawesome_6.ICON_FA_CIRCLE_DOT
if toggle
else icons_fontawesome_6.ICON_FA_CIRCLE
)
color = THEME["primary"] if toggle else THEME["text"]
imgui.text_colored(color, show_icon)
if imgui.is_item_clicked():
toggle = not toggle
imgui.same_line(0, 8)
label_color = SLIDER_THEME["label_color"]
imgui.text_colored(label_color, label)
imgui.same_line(0, 16)
total_h = max(hitbox_height, thumb_radius * 2 + 4)
available_size = (
(imgui.get_content_region_avail().x, total_h)
if width <= 0
else (width, total_h)
)
imgui.invisible_button(
f"#thin_slider_btn_{label}", (available_size[0] - 50, available_size[1]), 0
)
bb_min = imgui.get_item_rect_min()
bb_max = imgui.get_item_rect_max()
draw_list = imgui.get_window_draw_list()
x0 = bb_min.x + thumb_radius
x1 = bb_max.x - thumb_radius
y_center = (bb_min.y + bb_max.y) / 2.0
cur_val = float(value)
min_numeric = float(min_value)
max_numeric = float(max_value)
cur_val = max(min_numeric, min(max_numeric, cur_val))
original_val = cur_val
hovered = imgui.is_item_hovered()
active = imgui.is_item_active()
focused = imgui.is_item_focused()
track_y = y_center
if active:
mx, _my = imgui.get_mouse_pos()
new_ratio = (mx - x0) / max(1.0, (x1 - x0))
new_ratio = min(max(new_ratio, 0.0), 1.0)
cur_val = min_numeric + new_ratio * (max_numeric - min_numeric)
step_amount = float(step)
if value_type == "int":
step_amount = max(1.0, round(step_amount))
if focused and imgui.is_key_pressed(imgui.Key.left_arrow):
cur_val = max(min_numeric, cur_val - step_amount)
if focused and imgui.is_key_pressed(imgui.Key.right_arrow):
cur_val = min(max_numeric, cur_val + step_amount)
def convert_value(val):
if value_type == "int":
rounded = int(round(val))
lower = math.ceil(min_numeric)
upper = math.floor(max_numeric)
return max(int(lower), min(int(upper), rounded))
return float(val)
typed_original = convert_value(original_val)
typed_value = convert_value(cur_val)
if value_type == "int":
cur_val = float(typed_value)
ratio = (
(cur_val - min_numeric) / (max_numeric - min_numeric)
if max_numeric != min_numeric
else 0.0
)
ratio = min(max(ratio, 0.0), 1.0)
thumb_x = x0 + (x1 - x0) * ratio
radius = thumb_radius
if hovered:
radius = thumb_radius * 1.08
if active:
radius = thumb_radius * 1.18
track_color = imgui.get_color_u32(SLIDER_THEME["track_color"])
track_covered_color = imgui.get_color_u32(SLIDER_THEME["track_covered_color"])
draw_list.add_rect_filled(
(x0, track_y - track_height / 2.0),
(x1, track_y + track_height / 2.0),
track_color,
min(track_height / 2.0, 4.0),
)
draw_list.add_rect_filled(
(x0, track_y - track_height / 2.0),
(thumb_x, track_y + track_height / 2.0),
track_covered_color,
min(track_height / 2.0, 4.0),
)
thumb_color = imgui.get_color_u32(SLIDER_THEME["thumb_color"])
shadow_color = imgui.get_color_u32(SLIDER_THEME["shadow_color"])
draw_list.add_circle_filled((thumb_x, track_y), radius + 2.0, shadow_color)
draw_list.add_circle_filled((thumb_x, track_y), radius, thumb_color)
value_color = SLIDER_THEME["value_color"]
if value_unit is not None:
display_text = f"{typed_value:{text_format}}{value_unit}"
else:
display_text = f"{typed_value:{text_format}}"
max_text_size = 50
text_size = imgui.calc_text_size(display_text)
imgui.same_line(
0, max_text_size - text_size.x if text_size.x < max_text_size else 8
)
imgui.text_colored(value_color, display_text)
if width > 0:
imgui.pop_item_width()
imgui.pop_id()
value_changed = typed_value != typed_original
if show_toggle:
return value_changed, typed_value, toggle
return value_changed, typed_value
[docs]
def two_disk_slider(
label,
values,
min_value,
max_value,
*,
width=0,
step=1.0,
track_height=2.0,
thumb_radius=7.0,
hitbox_height=20.0,
text_format=".3f",
value_type="float",
value_unit=None,
min_gap=0.0,
display_values=None,
):
"""Render a range slider with two circular thumbs on a thin track.
Parameters
----------
label : str
Text rendered next to the slider.
values : tuple[float, float]
Current lower and upper values for the range.
min_value : float
Lower bound for the slider.
max_value : float
Upper bound for the slider.
width : int, optional
Widget width in pixels. Negative values use the available width.
step : float, optional
Increment applied when using keyboard arrows.
track_height : float, optional
Thickness of the slider track in pixels.
thumb_radius : float, optional
Radius of each circular thumb in pixels.
hitbox_height : float, optional
Height of the invisible button capturing pointer interactions.
text_format : str, optional
Format specification passed when displaying float values.
value_type : {"float", "int"}, optional
Numeric type enforced for the slider values.
value_unit : str or None, optional
Optional unit suffix appended to the value display.
min_gap : float, optional
Minimum allowed gap between the two thumbs.
display_values : tuple[float, float] or None, optional
Optional values shown in the text readout instead of the slider values.
Useful for displaying absolute values while the thumbs operate on
percentiles.
Returns
-------
tuple(bool, tuple[float or int, float or int])
Whether the slider values changed and the resulting numeric range.
"""
if value_type not in {"float", "int"}:
raise ValueError("value_type must be either 'float' or 'int'")
if len(values) != 2:
raise ValueError("values must be a 2-item iterable (low, high)")
low_val, high_val = values
if value_type == "int" and any(isinstance(v, float) for v in values):
logger.warning(
"Values converted to int for integer slider."
" Please provide value_type as 'float' if float is intended."
)
imgui.push_id(label)
if width > 0:
imgui.push_item_width(width)
label_color = SLIDER_THEME["label_color"]
imgui.text_colored(label_color, label)
imgui.same_line(0, 16)
total_h = max(hitbox_height, thumb_radius * 2 + 4)
available_size = (
(imgui.get_content_region_avail().x, total_h)
if width <= 0
else (width, total_h)
)
slider_width = max(40, available_size[0])
imgui.invisible_button(
f"#two_disk_slider_btn_{label}", (slider_width, available_size[1]), 0
)
bb_min = imgui.get_item_rect_min()
bb_max = imgui.get_item_rect_max()
draw_list = imgui.get_window_draw_list()
min_numeric = float(min_value)
max_numeric = float(max_value)
low_val = max(min_numeric, min(max_numeric, float(low_val)))
high_val = max(min_numeric, min(max_numeric, float(high_val)))
if low_val > high_val:
low_val, high_val = high_val, low_val
original_low = low_val
original_high = high_val
hovered = imgui.is_item_hovered()
active = imgui.is_item_active()
def convert_value(val):
if value_type == "int":
rounded = int(round(val))
lower = math.ceil(min_numeric)
upper = math.floor(max_numeric)
return max(int(lower), min(int(upper), rounded))
return float(val)
step_amount = float(step)
if value_type == "int":
step_amount = max(1.0, round(step_amount))
state = imgui.get_state_storage()
active_key = imgui.get_id(f"{label}_two_disk_active")
active_thumb = state.get_int(active_key, -1)
value_display_width = 50
text_padding = 6.0
track_left = bb_min.x + value_display_width + text_padding + thumb_radius
track_right = bb_max.x - value_display_width - text_padding - thumb_radius
if track_right <= track_left:
track_right = track_left + 1.0
def ratio_from_value(val):
if max_numeric == min_numeric:
return 0.0
return min(max((val - min_numeric) / (max_numeric - min_numeric), 0.0), 1.0)
left_ratio = ratio_from_value(low_val)
right_ratio = ratio_from_value(high_val)
left_x = track_left + (track_right - track_left) * left_ratio
right_x = track_left + (track_right - track_left) * right_ratio
if not imgui.is_mouse_down(imgui.MouseButton_.left):
active_thumb = -1
state.set_int(active_key, -1)
if imgui.is_item_clicked(imgui.MouseButton_.left):
mouse_x, _mouse_y = imgui.get_mouse_pos()
dist_left = abs(mouse_x - left_x)
dist_right = abs(mouse_x - right_x)
active_thumb = 0 if dist_left <= dist_right else 1
state.set_int(active_key, active_thumb)
if active and active_thumb != -1:
mouse_x, _mouse_y = imgui.get_mouse_pos()
new_ratio = (mouse_x - track_left) / max(1.0, (track_right - track_left))
new_ratio = min(max(new_ratio, 0.0), 1.0)
new_val = min_numeric + new_ratio * (max_numeric - min_numeric)
if active_thumb == 0:
low_val = min(new_val, high_val - min_gap)
else:
high_val = max(new_val, low_val + min_gap)
focused = imgui.is_item_focused()
if focused and imgui.is_key_pressed(imgui.Key.left_arrow):
low_val = max(min_numeric, low_val - step_amount)
if focused and imgui.is_key_pressed(imgui.Key.right_arrow):
low_val = min(high_val - min_gap, low_val + step_amount)
if focused and imgui.is_key_pressed(imgui.Key.down_arrow):
high_val = max(low_val + min_gap, high_val - step_amount)
if focused and imgui.is_key_pressed(imgui.Key.up_arrow):
high_val = min(max_numeric, high_val + step_amount)
typed_low = convert_value(low_val)
typed_high = convert_value(max(high_val, typed_low + min_gap))
if value_type == "int":
low_val = float(typed_low)
high_val = float(typed_high)
left_ratio = ratio_from_value(low_val)
right_ratio = ratio_from_value(high_val)
left_x = track_left + (track_right - track_left) * left_ratio
right_x = track_left + (track_right - track_left) * right_ratio
y_center = (bb_min.y + bb_max.y) / 2.0
track_color = imgui.get_color_u32(SLIDER_THEME["track_color"])
track_covered_color = imgui.get_color_u32(SLIDER_THEME["track_covered_color"])
draw_list.add_rect_filled(
(track_left, y_center - track_height / 2.0),
(track_right, y_center + track_height / 2.0),
track_color,
min(track_height / 2.0, 4.0),
)
draw_list.add_rect_filled(
(left_x, y_center - track_height / 2.0),
(right_x, y_center + track_height / 2.0),
track_covered_color,
min(track_height / 2.0, 4.0),
)
thumb_color = imgui.get_color_u32(SLIDER_THEME["thumb_color"])
shadow_color = imgui.get_color_u32(SLIDER_THEME["shadow_color"])
mouse_x, mouse_y = imgui.get_mouse_pos()
left_hovered = hovered and (abs(mouse_x - left_x) <= thumb_radius * 1.4)
right_hovered = hovered and (abs(mouse_x - right_x) <= thumb_radius * 1.4)
def draw_thumb(x_pos, is_hovered, is_active):
radius = thumb_radius
if is_hovered:
radius = thumb_radius * 1.08
if is_active:
radius = thumb_radius * 1.18
draw_list.add_circle_filled((x_pos, y_center), radius + 2.0, shadow_color)
draw_list.add_circle_filled((x_pos, y_center), radius, thumb_color)
draw_thumb(left_x, left_hovered, active_thumb == 0 and active)
draw_thumb(right_x, right_hovered, active_thumb == 1 and active)
value_color = imgui.get_color_u32(SLIDER_THEME["value_color"])
display_low, display_high = (
display_values if display_values is not None else (typed_low, typed_high)
)
if value_unit is not None and display_values is None:
left_text = f"{display_low:{text_format}}{value_unit}"
right_text = f"{display_high:{text_format}}{value_unit}"
else:
left_text = f"{display_low:{text_format}}"
right_text = f"{display_high:{text_format}}"
left_size = imgui.calc_text_size(left_text)
right_size = imgui.calc_text_size(right_text)
text_y = y_center - left_size.y * 0.5
draw_list.add_text(imgui.ImVec2(bb_min.x, text_y), value_color, left_text)
draw_list.add_text(
imgui.ImVec2(bb_max.x - right_size.x, y_center - right_size.y * 0.5),
value_color,
right_text,
)
if width > 0:
imgui.pop_item_width()
imgui.pop_id()
value_changed = typed_low != convert_value(
original_low
) or typed_high != convert_value(original_high)
return value_changed, (typed_low, typed_high)