#nullable enable
using ImGuiNET;
using T3.Editor.Gui.Interaction;
using T3.Editor.Gui.Styling;
using T3.Editor.Gui.Styling.Markdown;
using T3.Editor.Gui.UiHelpers;
using T3.Editor.UiModel.InputsAndTypes;
namespace T3.Editor.Gui.Input;
///
/// A set of custom widgets that allow to quickly draw dialogs with a more "traditional" labels on the left side of input fields.
/// It also provides a bunch of helper methods for minimal layout control.
///
internal static class FormInputs
{
public static void BeginFrame()
{
SetIndentToParameters();
}
public static void AddSectionHeader(string label)
{
//AddVerticalSpace(1);
ImGui.PushFont(Fonts.FontLarge);
ImGui.Text(label);
ImGui.PopFont();
//AddVerticalSpace(20);
}
public static void AddSectionHeaderParam(string label)
{
ImGui.PushFont(Fonts.FontLarge);
ImGui.Text(label.AddSpacesForImGuiOutput());
ImGui.PopFont();
}
public static void AddSectionSubHeader(string label)
{
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba);
ImGui.PushFont(Fonts.FontBold);
AddVerticalSpace(15 * T3Ui.UiScaleFactor);
ImGui.TextUnformatted(label);
AddVerticalSpace(5 * T3Ui.UiScaleFactor);
ImGui.PopFont();
ImGui.PopStyleColor();
}
public static void DrawFieldSetHeader(string label, bool useParamIndent = false)
{
// ImGui.SameLine(0,_paramIndent);
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba);
ImGui.PushFont(Fonts.FontSmall);
AddVerticalSpace(5);
if (useParamIndent)
ImGui.SetCursorPosX(DefaultParameterIndent);
ImGui.TextUnformatted(label.ToUpperInvariant());
AddVerticalSpace(1);
ImGui.PopFont();
ImGui.PopStyleColor();
}
public static bool BeginGroup(string label)
{
AddVerticalSpace(5);
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba);
// var isNotCollapsable = !label.EndsWith("...");
// if (isNotCollapsable)
// {
// ImGui.Text(label);
// ImGui.PopStyleColor();
// return true;
// }
// var id = ImGui.GetID(label);
// if (isNotCollapsable && !_openedGroups.Contains(id))
// {
// ImGui.SetNextItemOpen(true);
// _openedGroups.Add(id);
// }
var isOpen = ImGui.TreeNode(label);
ImGui.PopStyleColor();
if (isOpen)
ImGui.PushStyleVar(ImGuiStyleVar.IndentSpacing, 0);
return isOpen;
}
// private static HashSet _openedGroups = new();
public static void EndGroup()
{
ImGui.PopStyleVar();
ImGui.TreePop();
}
public static bool AddInt(string label,
ref int value,
int min = int.MinValue,
int max = int.MaxValue,
float scale = 1,
string? tooltip = null,
int defaultValue = NotADefaultValue)
{
var hasReset = defaultValue != NotADefaultValue;
var isDefault = hasReset && value == defaultValue;
// Fade the row while it holds its default value, so an unchanged field reads as inactive (matches AddFloat).
if (isDefault)
ImGui.PushStyleVar(ImGuiStyleVar.Alpha, DefaultFadeAlpha * ImGui.GetStyle().Alpha);
DrawInputLabel(label);
ImGui.PushID(label);
var size = GetAvailableInputSize(tooltip, hasReset, fillWidth: true, maxWidth: MaxNumberInputWidth);
var result = SingleValueEdit.Draw(ref value, size, min, max, true, true, scale);
ImGui.PopID();
AppendTooltip(tooltip);
if (AppendResetButton(hasReset && !isDefault, label))
{
value = defaultValue;
result |= InputEditStateFlags.ModifiedAndFinished;
}
if (isDefault)
ImGui.PopStyleVar();
var modified = (result & InputEditStateFlags.Modified) != InputEditStateFlags.Nothing;
return modified;
}
private const float DefaultFadeAlpha = 0.7f;
// Number fields fill the row on narrow panels but cap here so they don't stretch across a wide settings
// panel; the value-range indicator stays readable and float/int rows line up. (Unscaled px.)
private const float MaxNumberInputWidth = 280f;
public static bool AddFloat(string label,
ref float value,
float min = float.NegativeInfinity,
float max = float.PositiveInfinity,
float scale = 0.01f,
bool clampMin = false,
bool clampMax = false,
string? tooltip = null,
float defaultValue = float.NaN)
{
var hasReset = !float.IsNaN(defaultValue);
var isDefault = hasReset && Math.Abs(value - defaultValue) < 0.0001f;
if (isDefault)
{
ImGui.PushStyleVar(ImGuiStyleVar.Alpha, DefaultFadeAlpha * ImGui.GetStyle().Alpha);
}
DrawInputLabel(label);
var size = GetAvailableInputSize(tooltip, hasReset, fillWidth: true, maxWidth: MaxNumberInputWidth);
ImGui.PushID(label);
var result = SingleValueEdit.Draw(ref value, size, min, max, clampMin, clampMax, scale);
ImGui.PopID();
AppendTooltip(tooltip);
if (AppendResetButton(hasReset && !isDefault, label))
{
value = defaultValue;
result |= InputEditStateFlags.ModifiedAndFinished;
}
if (isDefault)
{
ImGui.PopStyleVar();
}
var modified = (result & InputEditStateFlags.Modified) != InputEditStateFlags.Nothing;
return modified;
}
/// Optional display-name formatter for each enum value (e.g. "HapQ" → "Hap Q").
/// When null, the raw enum member names are shown.
/// Optional secondary text drawn muted after each item's label (e.g. a size/time
/// estimate). When provided, the dropdown renders items itself so the suffix can be de-emphasised.
public static bool AddEnumDropdown(ref T selectedValue, string? label, string? tooltip = null,
T defaultValue = default, Func? itemLabel = null, Func? itemSuffix = null)
where T : struct, Enum, IConvertible, IFormattable
{
DrawInputLabel(label);
var inputSize = GetAvailableInputSize(tooltip, false, true);
ImGui.SetNextItemWidth(inputSize.X);
var modified = DrawEnumDropdown(ref selectedValue, label, defaultValue, itemLabel, itemSuffix);
AppendTooltip(tooltip);
return modified;
}
public static bool DrawEnumDropdown(ref T selectedValue, string? label, T defaultValue = default,
Func? itemLabel = null, Func? itemSuffix = null)
where T : struct, Enum, IConvertible, IFormattable, IComparable
{
var values = Enum.GetValues();
var names = Enum.GetNames();
if (itemLabel != null)
{
for (var i = 0; i < values.Length; i++)
names[i] = itemLabel(values[i]);
}
var selectedIndex = 0;
for (var i = 0; i < values.Length; i++)
{
if (values[i].Equals(selectedValue))
{
selectedIndex = i;
break;
}
}
ImGui.PushStyleColor(ImGuiCol.FrameBg, UiColors.BackgroundButton.Rgba);
ImGui.PushStyleColor(ImGuiCol.Text,
selectedValue.Equals(defaultValue) ? UiColors.TextMuted.Rgba : UiColors.ForegroundFull.Rgba);
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 5);
bool modified;
if (itemSuffix != null)
modified = DrawEnumComboWithMutedSuffix(ref selectedValue, label, values, names, selectedIndex, itemSuffix);
else
{
modified = ImGui.Combo($"##dropDown{typeof(T)}{label}", ref selectedIndex, names, names.Length, names.Length);
if (modified)
selectedValue = values[selectedIndex];
}
ImGui.PopStyleVar();
ImGui.PopStyleColor(2);
return modified;
}
// Custom combo so the per-item suffix (e.g. "→ ~95 MB, ~34s") can render in TextMuted while the label keeps the
// normal colour. The closed preview shows label + suffix in one colour (BeginCombo can't two-tone it); the open
// list two-tones each row via a full-width hidden Selectable with the label/suffix drawn over it.
private static bool DrawEnumComboWithMutedSuffix(ref T selectedValue, string? label, T[] values, string[] names,
int selectedIndex, Func itemSuffix)
where T : struct, Enum
{
var preview = names[selectedIndex] + itemSuffix(values[selectedIndex]);
var modified = false;
if (ImGui.BeginCombo($"##dropDown{typeof(T)}{label}", preview))
{
for (var i = 0; i < values.Length; i++)
{
var startX = ImGui.GetCursorPosX();
if (ImGui.Selectable($"##enumItem{i}", i == selectedIndex))
{
selectedValue = values[i];
modified = true;
}
ImGui.SameLine(startX);
ImGui.TextUnformatted(names[i]);
var suffix = itemSuffix(values[i]);
if (!string.IsNullOrEmpty(suffix))
{
ImGui.SameLine();
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba);
ImGui.TextUnformatted(suffix);
ImGui.PopStyleColor();
}
}
ImGui.EndCombo();
}
return modified;
}
public static bool AddDropdown(ref string selectedValue, IEnumerable values, string label,
string? tooltip = null)
{
DrawInputLabel(label);
var spaceForTooltip = 0f;
if (tooltip != null)
{
spaceForTooltip = 30f;
}
var inputSize = GetAvailableInputSize(null, false, true, spaceForTooltip);
ImGui.SetNextItemWidth(inputSize.X);
var modified = false;
if (ImGui.BeginCombo("##SelectTheme",
selectedValue,
ImGuiComboFlags.HeightLarge))
{
foreach (var value in values)
{
if (value == null)
continue;
var isSelected = value == selectedValue;
if (!ImGui.Selectable($"{value}",
isSelected,
ImGuiSelectableFlags.NoAutoClosePopups))
continue;
ImGui.CloseCurrentPopup();
selectedValue = value;
modified = true;
}
ImGui.EndCombo();
}
AppendTooltip(tooltip);
return modified;
}
public static bool AddDropdown(ref T selectedValue,
IEnumerable values,
string label,
Func getDisplayTextFunc,
string? tooltip = null)
{
DrawInputLabel(label);
const string imguiLabelFmt = "##Select{0}{1}";
var imguiLabel = string.Format(imguiLabelFmt, label, nameof(T));
// const string defaultDisplayTextFmt = "Select {0}";
// defaultDisplayText ??= string.Format(defaultDisplayTextFmt, selectedValue);
var previewLabel = selectedValue == null ? "please select" : getDisplayTextFunc(selectedValue);
// Constrain to the standard input column (reserving room for the tooltip icon) like the other inputs —
// otherwise the combo spans the whole content region and overflows narrow panels.
var inputSize = GetAvailableInputSize(tooltip, false, true);
ImGui.SetNextItemWidth(inputSize.X);
var modified = false;
if (ImGui.BeginCombo(imguiLabel,
previewLabel,
ImGuiComboFlags.HeightLarge))
{
foreach (var value in values)
{
if (value == null)
continue;
var equalityComparer = EqualityComparer.Default;
var isSelected = equalityComparer.Equals(value, selectedValue);
// if (!ImGui.Selectable($"{value}", isSelected, ImGuiSelectableFlags.NoAutoClosePopups))
// continue;
if (!ImGui.Selectable(getDisplayTextFunc(value), isSelected, ImGuiSelectableFlags.NoAutoClosePopups))
continue;
ImGui.CloseCurrentPopup();
selectedValue = value;
modified = true;
}
ImGui.EndCombo();
}
AppendTooltip(tooltip);
return modified;
}
private static readonly Dictionary _listBoxAddInputs = new();
// Dragging state for reorder: persistent across frames while mouse button is down
private static int _draggingIndex = -1;
private static bool _isDragging;
public static bool AddEditableListBox(ref string selectedValue,
IList values,
string label,
Predicate? warningPredicate,
String? warningText,
string? tooltip = null)
{
DrawInputLabel(label);
var imguiLabel = "##ListBox" + label;
// compute input size early
var inputSize = GetAvailableInputSize(tooltip, false, true);
// Compute box height: account for all items plus the bottom transient add-row; add 1.5 lines total so the add-row is visible
var lineHeight = ImGui.GetTextLineHeightWithSpacing();
var listBoxHeight = MathF.Min(300, (values.Count + 2.5f) * lineHeight);
var size = inputSize with {Y = listBoxHeight};
var modified = false;
if (ImGui.BeginListBox(imguiLabel, size))
{
try
{
// Remember the list content start Y so we can compute item indices based on mouse Y while dragging
var listStartY = ImGui.GetCursorScreenPos().Y;
// Top: existing items
for (var i = 0; i < values.Count; i++)
{
ImGui.PushID(i);
ImGui.AlignTextToFramePadding();
// Drag handle
ImGui.Button($"{i}.");
// start drag if user presses the handle
if (ImGui.IsItemActive() && !_isDragging && ImGui.IsMouseDown(0))
{
_isDragging = true;
_draggingIndex = i;
}
// continue drag while mouse down
if (_isDragging && ImGui.IsMouseDown(0) && _draggingIndex >= 0)
{
var mouseY = ImGui.GetMousePos().Y;
var targetIndex = (int) ((mouseY - listStartY) / lineHeight);
targetIndex = Math.Clamp(targetIndex, 0, values.Count - 1);
if (targetIndex != _draggingIndex)
{
(values[targetIndex], values[_draggingIndex]) =
(values[_draggingIndex], values[targetIndex]);
modified = true;
_draggingIndex = targetIndex;
}
}
// stop dragging when mouse released
if (_isDragging && !ImGui.IsMouseDown(0))
{
_isDragging = false;
_draggingIndex = -1;
}
ImGui.SameLine(30 * T3Ui.UiScaleFactor);
var value = values[i];
// warning icon on the left
var hasWarning = warningPredicate != null && !warningPredicate(value);
if (hasWarning)
{
AddIcon(Icon.Warning);
if (ImGui.IsItemHovered())
{
ImGui.BeginTooltip();
ImGui.TextWrapped(warningText ?? "This entry may cause issues.");
ImGui.EndTooltip();
}
ImGui.SameLine();
}
// compute reserve space for two small buttons
var style = ImGui.GetStyle();
var padX = style.FramePadding.X * 2;
var wX = ImGui.CalcTextSize("×").X + padX;
var wPlus = ImGui.CalcTextSize("+").X + padX;
var buttonSpacing = style.ItemSpacing.X;
var reservedRight = wX + wPlus + buttonSpacing;
// set width for input so buttons fit on same line
var regionAvail = ImGui.GetContentRegionAvail().X;
ImGui.SetNextItemWidth(MathF.Max(20, regionAvail - reservedRight));
var temp = value;
if (ImGui.InputText("##item" + i + imguiLabel, ref temp, 1024u))
{
values[i] = temp;
modified = true;
if (selectedValue == value)
selectedValue = temp;
}
// draw delete/insert on same line, right after the input
ImGui.SameLine();
if (ImGui.Button("×"))
{
if (selectedValue == value)
selectedValue = string.Empty;
values.RemoveAt(i);
modified = true;
ImGui.PopID();
i--;
continue;
}
ImGui.SameLine();
if (ImGui.Button("+"))
{
values.Insert(i, value);
modified = true;
i++; // skip the inserted duplicate
}
ImGui.PopID();
}
// Bottom: add new item input
ImGui.Separator();
ImGui.PushID("add" + imguiLabel);
if (!_listBoxAddInputs.TryGetValue(imguiLabel, out var addInput))
addInput = string.Empty;
// Shift cursor horizontally to match the per-item input left offset (drag-handle width)
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 30 * T3Ui.UiScaleFactor);
// Compute reserved right width same as per-item (X + + spacing) so plus aligns exactly
var styleBottom = ImGui.GetStyle();
var padXBottom = styleBottom.FramePadding.X * 2;
var wXBottom = ImGui.CalcTextSize("×").X + padXBottom;
var wPlusBottom = ImGui.CalcTextSize("+").X + padXBottom;
var buttonSpacingBottom = styleBottom.ItemSpacing.X;
var reservedRightSame = wXBottom + wPlusBottom + buttonSpacingBottom;
var regionAvailBottom = ImGui.GetContentRegionAvail().X;
ImGui.SetNextItemWidth(MathF.Max(20, regionAvailBottom - reservedRightSame));
if (ImGui.InputText("##add" + imguiLabel, ref addInput, 1024u))
{
_listBoxAddInputs[imguiLabel] = addInput;
}
ImGui.SameLine();
if (ImGui.Button("+"))
{
var trimmed = (addInput ?? string.Empty).Trim();
if (!string.IsNullOrEmpty(trimmed))
{
values.Add(trimmed);
selectedValue = trimmed;
_listBoxAddInputs[imguiLabel] = string.Empty;
modified = true;
}
}
ImGui.PopID();
}
finally
{
ImGui.EndListBox();
}
}
AppendTooltip(tooltip);
return modified;
}
public static bool AddSegmentedButtonWithLabel(ref T selectedValue, string label, float columnWidth = 0, string? tooltip = null)
where T : struct, Enum
{
DrawInputLabel(label);
var modified = SegmentedButton(ref selectedValue, columnWidth);
AppendTooltip(tooltip);
return modified;
}
public static bool SegmentedButton(ref T selectedValue, float columnWidth = 0, Func? isItemDisabled = null)
where T : struct, Enum
{
if (columnWidth <= 0)
return SegmentedButtonPill(ref selectedValue, isItemDisabled);
// Fixed-width vertical mode (category selectors) keeps the simple per-button look.
var modified = false;
var selectedValueString = selectedValue.ToString();
foreach (var value in Enum.GetValues())
{
var name = CustomComponents.HumanReadablePascalCase(Enum.GetName(value));
var isSelected = selectedValueString == value.ToString();
if (DrawSelectButton(name, isSelected, columnWidth))
{
modified = true;
selectedValue = value;
}
}
return modified;
}
///
/// Horizontal segmented control drawn as one rounded track with the active option floating inside it.
/// Custom draw-list rendering (rather than per-option ImGui.Button) so the track and the active pill
/// share corners and the active label can use a heavier font.
///
private static bool SegmentedButtonPill(ref T selectedValue, Func? isItemDisabled = null) where T : struct, Enum
{
var scale = T3Ui.UiScaleFactor;
var h = ImGui.GetFrameHeight();
var segPadding = 14 * scale;
var rounding = 4 * scale;
var inset = 2 * scale;
var values = Enum.GetValues();
var selectedString = selectedValue.ToString();
var dl = ImGui.GetWindowDrawList();
var start = ImGui.GetCursorScreenPos();
var total = 0f;
foreach (var value in values)
{
var name = CustomComponents.HumanReadablePascalCase(Enum.GetName(value));
total += ImGui.CalcTextSize(name).X + segPadding * 2;
}
dl.AddRectFilled(start, start + new Vector2(total, h), UiColors.BackgroundInputField, rounding);
// Scope ids by enum type so two unrelated segmented controls in the same window don't collide.
ImGui.PushID(typeof(T).Name);
var modified = false;
var isFirst = true;
var x = start.X;
foreach (var value in values)
{
var name = CustomComponents.HumanReadablePascalCase(Enum.GetName(value));
var segWidth = ImGui.CalcTextSize(name).X + segPadding * 2;
var isSelected = selectedString == value.ToString();
var isDisabled = isItemDisabled?.Invoke(value) ?? false;
if (!isFirst)
ImGui.SameLine(0, 0);
// Disabled options still reserve their slot but ignore clicks and don't highlight on hover.
if (ImGui.InvisibleButton(name, new Vector2(segWidth, h)) && !isDisabled)
{
selectedValue = value;
modified = true;
}
var isHovered = !isDisabled && ImGui.IsItemHovered();
var segMin = new Vector2(x, start.Y);
if (isSelected)
{
dl.AddRectFilled(segMin + new Vector2(inset, inset),
segMin + new Vector2(segWidth - inset, h - inset),
UiColors.BackgroundButton, rounding);
}
else if (isHovered)
{
dl.AddRectFilled(segMin + new Vector2(inset, inset),
segMin + new Vector2(segWidth - inset, h - inset),
UiColors.BackgroundButton.Fade(0.4f), rounding);
}
var font = isSelected ? Fonts.FontBold : Fonts.FontNormal;
var textColor = isDisabled
? UiColors.TextMuted.Fade(0.4f)
: isSelected
? UiColors.ForegroundFull
: (isHovered ? UiColors.Text : UiColors.TextMuted);
ImGui.PushFont(font);
var textSize = ImGui.CalcTextSize(name);
var textPos = new Vector2(segMin.X + (segWidth - textSize.X) * 0.5f, segMin.Y + (h - textSize.Y) * 0.5f);
dl.AddText(textPos, ImGui.GetColorU32(textColor.Rgba), name);
ImGui.PopFont();
x += segWidth;
isFirst = false;
}
ImGui.PopID();
return modified;
}
private static bool DrawSelectButton(string name, bool isSelected, float width = 0)
{
ImGui.PushStyleColor(ImGuiCol.Button,
isSelected ? UiColors.BackgroundActive.Fade(0.7f).Rgba : UiColors.BackgroundButton.Rgba);
ImGui.PushStyleColor(ImGuiCol.ButtonHovered,
isSelected ? UiColors.BackgroundActive.Rgba : UiColors.BackgroundButton.Fade(0.7f).Rgba);
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.Text.Rgba);
ImGui.PushStyleColor(ImGuiCol.ButtonActive,
isSelected ? UiColors.BackgroundActive.Fade(0.7f).Rgba : UiColors.BackgroundButton.Fade(0.7f).Rgba);
var clicked = ImGui.Button(name, new Vector2(width, 0));
ImGui.PopStyleColor(4);
return clicked;
}
private const string NoDefaultString = "_";
///
/// Draws string input or file picker.
///
public static bool AddStringInput(string label,
ref string value,
string? placeHolder = null,
string? warning = null,
string? tooltip = null,
string? defaultValue = NoDefaultString,
bool autoFocus = false)
{
if (string.IsNullOrEmpty(label))
{
Log.Error("AddStringInput() requires an id to work. Use ## prefix to hide.");
label = "##fallback";
}
var hasDefault = defaultValue != NoDefaultString;
var isDefault = hasDefault && value == defaultValue;
if (isDefault)
{
ImGui.PushStyleVar(ImGuiStyleVar.Alpha, DefaultFadeAlpha * ImGui.GetStyle().Alpha);
}
DrawInputLabel(label);
var wasNull = value == null!; // Support legacy calls
if (wasNull)
value = string.Empty;
var inputSize = GetAvailableInputSize(tooltip, false, true);
ImGui.SetNextItemWidth(inputSize.X);
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 5);
var modified = ImGui.InputText("##" + label, ref value, 1000);
if (!modified && wasNull)
value = null!; // Support legacy calls
if (autoFocus)
{
// Todo - how the hell do you make this not select the entire text?
ImGui.SetKeyboardFocusHere(-1);
}
ImGui.PopStyleVar();
if (string.IsNullOrEmpty(value) && !string.IsNullOrEmpty(placeHolder))
{
var drawList = ImGui.GetWindowDrawList();
var minPos = ImGui.GetItemRectMin();
var maxPos = ImGui.GetItemRectMax();
drawList.PushClipRect(minPos, maxPos);
drawList.AddText(minPos + new Vector2(8, 3) * T3Ui.UiScaleFactor, UiColors.ForegroundFull.Fade(0.25f),
placeHolder);
drawList.PopClipRect();
}
AppendTooltip(tooltip);
if (isDefault)
{
ImGui.PopStyleVar();
}
if (AppendResetButton(hasDefault && !isDefault, label))
{
value = defaultValue ?? string.Empty;
modified = true;
}
DrawWarningBelowField(warning);
return modified;
}
public static bool AddStringInputWithSuggestions(string label,
ref string value,
IOrderedEnumerable items,
string? placeHolder = null,
string? warning = null,
string? tooltip = null,
string? defaultValue = NoDefaultString,
bool autoFocus = false)
{
if (string.IsNullOrEmpty(label))
{
Log.Error("AddStringInput() requires an id to work. Use ## prefix to hide.");
label = "##fallback";
}
defaultValue ??= NoDefaultString;
var hasDefault = defaultValue != NoDefaultString;
var isDefault = hasDefault && value == defaultValue;
if (isDefault)
{
ImGui.PushStyleVar(ImGuiStyleVar.Alpha, DefaultFadeAlpha * ImGui.GetStyle().Alpha);
}
DrawInputLabel(label);
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
var wasNull = value == null;
if (wasNull)
value = string.Empty;
var inputSize = GetAvailableInputSize(tooltip, false, true);
ImGui.SetNextItemWidth(inputSize.X);
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 5);
var modified = InputWithTypeAheadSearch.Draw("##typeAheadSearch",
items,
!string.IsNullOrEmpty(warning),
ref value!, out _, out var _);
if (!modified && wasNull)
value = null!;
if (autoFocus)
{
// Todo - how the hell do you make this not select the entire text?
ImGui.SetKeyboardFocusHere(-1);
}
ImGui.PopStyleVar();
if (string.IsNullOrEmpty(value) && !string.IsNullOrEmpty(placeHolder))
{
var drawList = ImGui.GetWindowDrawList();
var minPos = ImGui.GetItemRectMin();
var maxPos = ImGui.GetItemRectMax();
drawList.PushClipRect(minPos, maxPos);
drawList.AddText(minPos + new Vector2(8, 3) * T3Ui.UiScaleFactor, UiColors.ForegroundFull.Fade(0.25f),
placeHolder);
drawList.PopClipRect();
}
AppendTooltip(tooltip);
if (isDefault)
{
ImGui.PopStyleVar();
}
if (AppendResetButton(hasDefault && !isDefault, label))
{
value = defaultValue;
modified = true;
}
DrawWarningBelowField(warning);
return modified;
}
///
/// Draws string input or file picker.
///
public static bool AddFilePicker(string label,
ref string? value,
string? placeHolder = null,
string? warning = null,
string? tooltip = null,
FileOperations.FilePickerTypes showFilePicker = FileOperations.FilePickerTypes.None)
{
DrawInputLabel(label);
var isFilePickerVisible = showFilePicker != FileOperations.FilePickerTypes.None;
float spaceForFilePicker = isFilePickerVisible ? 80 * T3Ui.UiScaleFactor : 0;
var inputSize = GetAvailableInputSize(null, false, true, spaceForFilePicker);
ImGui.SetNextItemWidth(inputSize.X);
var wasNull = value == null;
if (wasNull)
value = string.Empty;
var modified = ImGui.InputText("##" + label, ref value, 1000);
if (!modified && wasNull)
value = null;
if (string.IsNullOrEmpty(value) && !string.IsNullOrEmpty(placeHolder))
{
var drawList = ImGui.GetWindowDrawList();
var minPos = ImGui.GetItemRectMin();
var maxPos = ImGui.GetItemRectMax();
drawList.PushClipRect(minPos, maxPos);
drawList.AddText(minPos + new Vector2(8, 3), UiColors.ForegroundFull.Fade(0.25f), placeHolder);
drawList.PopClipRect();
}
if (isFilePickerVisible)
{
ImGui.PushID(label);
modified |= FileOperations.DrawFileSelector(showFilePicker, ref value);
ImGui.PopID();
}
AppendTooltip(tooltip);
DrawWarningBelowField(warning);
return modified;
}
public static bool AddCheckBox(string label,
ref bool value,
string? tooltip = null,
bool? defaultValue = null)
{
var hasDefault = defaultValue != null;
var isDefault = defaultValue != null && value == (bool) defaultValue;
if (isDefault)
{
ImGui.PushStyleVar(ImGuiStyleVar.Alpha, DefaultFadeAlpha * ImGui.GetStyle().Alpha);
}
ImGui.SetCursorPosX(MathF.Max(LeftParameterPadding, 0) + 20 * T3Ui.UiScaleFactor);
var modified = DrawCheckbox(label, ref value);
AppendTooltip(tooltip);
if (isDefault)
{
ImGui.PopStyleVar();
}
if (AppendResetButton(hasDefault && !isDefault, label))
{
value = defaultValue ?? false;
modified = true;
}
return modified;
}
///
/// Rounded checkbox with a white atlas checkmark when on — replaces ImGui.Checkbox so checkboxes
/// match the editor's look (rounded field, baked glyph) instead of the default tick.
///
private static bool DrawCheckbox(string label, ref bool value)
{
var scale = T3Ui.UiScaleFactor;
var h = ImGui.GetFrameHeight();
var boxSize = MathF.Round(h * 0.8f);
var hasLabel = !string.IsNullOrEmpty(label) && !label.StartsWith("##");
var labelSize = hasLabel ? ImGui.CalcTextSize(label) : Vector2.Zero;
var spacing = hasLabel ? 8 * scale : 0;
var id = string.IsNullOrEmpty(label) ? "##checkbox" : label;
var clicked = ImGui.InvisibleButton(id, new Vector2(boxSize + spacing + labelSize.X, h));
if (clicked)
value = !value;
var min = ImGui.GetItemRectMin();
var isHovered = ImGui.IsItemHovered();
var dl = ImGui.GetWindowDrawList();
var boxMin = new Vector2(min.X, min.Y + (h - boxSize) * 0.5f);
var boxMax = boxMin + new Vector2(boxSize, boxSize);
var boxColor = isHovered ? UiColors.BackgroundInputFieldHover : UiColors.BackgroundInputField;
dl.AddRectFilled(boxMin, boxMax, boxColor, 4 * scale);
if (value)
{
var iconSize = boxSize * 0.9f;
var iconPos = boxMin + new Vector2((boxSize - iconSize) * 0.5f);
Icons.DrawIconAtScreenPosition(Icon.Checkmark, iconPos, new Vector2(iconSize), dl, UiColors.ForegroundFull);
}
if (hasLabel)
{
var textPos = new Vector2(boxMax.X + spacing, min.Y + (h - labelSize.Y) * 0.5f);
dl.AddText(textPos, ImGui.GetColorU32(UiColors.Text.Rgba), label);
}
return clicked;
}
public static void AddHint(string label)
{
if (string.IsNullOrEmpty(label))
return;
AddVerticalSpace(5);
ApplyIndent();
ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.5f * ImGui.GetStyle().Alpha);
ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(10, 20));
AddIcon(Icon.Tip);
ImGui.SameLine();
ImGui.TextWrapped(label);
//ImGui.Indent(-13);
ImGui.PopStyleVar(2);
}
public static void AddVerticalSpace(float size = 10)
{
// This breaks ImGui.Indent for some reason so I commented it out and brought back the Dummy call
/*ImGui.SetCursorPos( new Vector2( ImGui.GetCursorStartPos().X,
ImGui.GetCursorPosY()
+size * T3Ui.UiScaleFactor));*/
ImGui.Dummy(new Vector2(1, size * T3Ui.UiScaleFactor));
}
#region layout helpers
public static void SetIndent(float newIndent)
{
_paramIndent = newIndent;
}
public static void Indent(float delta)
{
_paramIndent += delta;
}
public static void Unindent(float delta)
{
_paramIndent -= delta;
}
public static void SetIndentToLeft()
{
_paramIndent = 0;
}
public static void SetIndentToParameters()
{
_paramIndent = DefaultParameterIndent;
}
public static void ApplyIndent()
{
ImGui.SetCursorPosX(LeftParameterPadding + ParameterSpacing);
}
public static void SetWidth(float ratio)
{
_widthRatio = ratio;
}
public static void ResetWidth()
{
_widthRatio = 1;
}
public static void DrawInputLabel(string? label)
{
if (string.IsNullOrEmpty(label) || label.StartsWith("##"))
return;
var labelSize = ImGui.CalcTextSize(label);
var p = ImGui.GetCursorPos();
ImGui.SetCursorPosX(MathF.Max(LeftParameterPadding - labelSize.X, 0) + 10);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(label);
ImGui.SetCursorPos(p);
ImGui.SameLine();
SetCursorToParameterEdit();
}
public static void SetCursorToParameterEdit()
{
ImGui.SetCursorPosX(LeftParameterPadding + ParameterSpacing);
}
public static bool DrawValueRangeControl(ref float min, ref float max, ref float scale, ref bool clampedMin,
ref bool clampedMax, float defaultMin,
float defaultMax, float defaultScale)
{
var modified = false;
var flexWidth = ComputeFlexWidth(2, 3);
if (CustomComponents.RoundedIconButton("clampMin",
clampedMin ? Icon.ClampMinOn : Icon.ClampMinOff, 0,
ImDrawFlags.RoundCornersLeft,
clampedMin
? CustomComponents.ButtonStates.NeedsAttention
: CustomComponents.ButtonStates.Default))
{
modified = true;
clampedMin = !clampedMin;
}
modified |= SimpleFloatEdit(1, ref min, defaultMin, flexWidth);
modified |= SimpleFloatEdit(2, ref scale, defaultScale, flexWidth);
modified |= SimpleFloatEdit(3, ref max, defaultMax, flexWidth);
ImGui.SameLine();
if (CustomComponents.RoundedIconButton("clampMax",
clampedMax ? Icon.ClampMaxOn : Icon.ClampMaxOff, 0,
ImDrawFlags.RoundCornersRight,
clampedMax
? CustomComponents.ButtonStates.NeedsAttention
: CustomComponents.ButtonStates.Default))
{
modified = true;
clampedMax = !clampedMax;
}
return modified;
}
// TODO: This could become obsolete if SingleValueEdit would handle fading on default
private static bool SimpleFloatEdit(int id, ref float max, float defaultValue, float flexWidth)
{
var modified = false;
ImGui.SameLine();
ImGui.PushID(id);
ImGui.PushStyleVar(ImGuiStyleVar.Alpha, Math.Abs(max - defaultValue) < 0.0001f ? 0.5f : 1.0f);
if (SingleValueEdit.Draw(ref max,
new Vector2(flexWidth, ImGui.GetFrameHeight()),
format: "{0:G7}",
defaultValue: defaultValue, horizontalAlign: 0.5f)
.HasFlag(InputEditStateFlags.Modified))
{
modified = true;
}
ImGui.PopStyleVar();
ImGui.PopID();
return modified;
}
public static bool DrawIntValueRangeControl(ref int min, ref int max, ref float scale, ref bool clampedMin,
ref bool clampedMax)
{
var modified = false;
var flexWidth = ComputeFlexWidth(2, 3);
if (CustomComponents.RoundedIconButton("clampMin",
clampedMin ? Icon.ClampMinOn : Icon.ClampMinOff, 0,
ImDrawFlags.RoundCornersLeft,
clampedMin
? CustomComponents.ButtonStates.NeedsAttention
: CustomComponents.ButtonStates.Default))
{
modified = true;
clampedMin = !clampedMin;
}
modified |= SimpleIntEdit(1, ref min, int.MinValue, flexWidth);
modified |= SimpleFloatEdit(2, ref scale, 0, flexWidth);
modified |= SimpleIntEdit(3, ref max, int.MaxValue, flexWidth);
ImGui.SameLine();
if (CustomComponents.RoundedIconButton("clampMax",
clampedMax ? Icon.ClampMaxOn : Icon.ClampMaxOff, 0,
ImDrawFlags.RoundCornersRight,
clampedMax
? CustomComponents.ButtonStates.NeedsAttention
: CustomComponents.ButtonStates.Default))
{
modified = true;
clampedMax = !clampedMax;
}
return modified;
}
// TODO: This could become obsolete if SingleValueEdit would handle fading on default
private static bool SimpleIntEdit(int id, ref int value, int defaultValue, float flexWidth)
{
var modified = false;
ImGui.SameLine();
ImGui.PushID(id);
ImGui.PushStyleVar(ImGuiStyleVar.Alpha, value == defaultValue ? 0.5f : 1.0f);
if (SingleValueEdit.Draw(ref value,
new Vector2(flexWidth, ImGui.GetFrameHeight()), defaultValue: defaultValue, horizontalAlign: 0.5f)
.HasFlag(InputEditStateFlags.Modified))
{
modified = true;
}
ImGui.PopStyleVar();
ImGui.PopID();
return modified;
}
/**
* Computes the fill width for input group segments
*/
private static float ComputeFlexWidth(int fixedWidthItemCount, int flexItemCount)
{
var totalWidth = ImGui.GetContentRegionAvail().X;
var height = ImGui.GetFrameHeight();
return (totalWidth - fixedWidthItemCount * height) / flexItemCount;
}
private static void DrawWarningBelowField(string? warning)
{
if (string.IsNullOrEmpty(warning))
return;
ImGui.SetCursorPosX(MathF.Max(LeftParameterPadding, 0) + 20 * T3Ui.UiScaleFactor);
ImGui.PushFont(Fonts.FontSmall);
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.StatusError.Rgba);
ImGui.TextUnformatted(warning);
ImGui.PopStyleColor();
ImGui.PopFont();
AddVerticalSpace(4);
}
#endregion
#region internal helpers
/// When > 0, caps the requested width (in unscaled px, scaled internally). Used by
/// number fields so they fill the row on narrow panels but don't stretch across very wide ones. Dropdowns
/// and string inputs leave this at 0 to keep filling the full width.
public static Vector2 GetAvailableInputSize(string? tooltip, bool hasReset, bool fillWidth = false,
float rightPadding = 0, float maxWidth = 0)
{
var toolWidth = 20f * T3Ui.UiScaleFactor;
var sizeForResetToDefault = hasReset ? toolWidth : 0;
var sizeForTooltip = !string.IsNullOrEmpty(tooltip) ? toolWidth : 0;
var requestedWidth = fillWidth ? ImGui.GetContentRegionAvail().X * _widthRatio : 200;
if (maxWidth > 0)
requestedWidth = MathF.Min(requestedWidth, maxWidth * T3Ui.UiScaleFactor);
var availableWidth = MathF.Min(requestedWidth, ImGui.GetContentRegionAvail().X + 20);
var vector2 = new Vector2(availableWidth
- 20
- rightPadding
- sizeForResetToDefault
- sizeForTooltip,
ImGui.GetFrameHeight());
return vector2;
}
/// Draws the small help-marker icon on the current line and shows when
/// hovered. Used by the input rows here, and reusable for ad-hoc info markers next to custom labels.
internal static void AppendTooltip(string? tooltip)
{
if (string.IsNullOrEmpty(tooltip))
return;
// A small fixed gap to the input — the default item spacing reads as detached.
ImGui.SameLine(0, 3 * T3Ui.UiScaleFactor);
ImGui.PushFont(Icons.IconFont);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("" + (char) Icon.Help);
ImGui.PopFont();
if (!ImGui.IsItemHovered())
return;
// Tooltip — rendered as Markdown so parameter docs can use **bold**, "- " lists and [OpRef] links.
// HardLineBreaks keeps plain multi-line tooltips (single "\n") looking as before. The finite max height
// lets the auto-resizing window fit its content without a spurious scrollbar.
var scale = T3Ui.UiScaleFactor;
var width = 280 * scale;
var maxHeight = ImGui.GetMainViewport().WorkSize.Y - 40 * scale;
ImGui.SetNextWindowSizeConstraints(new Vector2(width, 0), new Vector2(width, maxHeight));
ImGui.PushStyleColor(ImGuiCol.PopupBg, UiColors.BackgroundFull.Rgba);
ImGui.PushStyleVar(ImGuiStyleVar.Alpha, ImGui.GetStyle().Alpha);
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(10, 10) * scale);
ImGui.BeginTooltip();
// Wrap to a fixed inner width rather than the live content region. The window is auto-resizing,
// so on its first frame ImGui briefly shows a vertical scrollbar before it knows the content
// height; that scrollbar steals width, which would reflow the text taller and make the scrollbar
// stick. A stable wrap width (inner width minus padding and a scrollbar's worth of slack) keeps
// the layout from reflowing, so the spurious bar disappears on the next frame.
var innerWrap = width - 2 * 10 * scale - ImGui.GetStyle().ScrollbarSize;
_tooltipMarkdown.Draw(tooltip, wrapWidthPx: innerWrap);
ImGui.EndTooltip();
ImGui.PopStyleVar(2);
ImGui.PopStyleColor();
}
private static readonly MarkdownView _tooltipMarkdown = new(new MarkdownView.Options
{
MutedBodyText = true,
HardLineBreaks = true,
});
private static bool AppendResetButton(bool hasReset, string? id)
{
if (!hasReset)
return false;
ImGui.SameLine();
ImGui.PushID(id ?? "fallback");
var clicked = CustomComponents.IconButton(Icon.Revert,
new Vector2(Math.Min(.8f, T3Ui.UiScaleFactor)) * ImGui.GetFrameHeight());
ImGui.PopID();
return clicked;
}
private static void AddIcon(Icon icon)
{
ImGui.PushFont(Icons.IconFont);
ImGui.PushStyleVar(ImGuiStyleVar.ButtonTextAlign, new Vector2(0.5f, 0.5f));
ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, Vector2.Zero);
ImGui.TextUnformatted("" + (char) (icon));
ImGui.PopFont();
ImGui.PopStyleVar(2);
}
#endregion
private const int NotADefaultValue = Int32.MinValue;
private const float DefaultParameterIndent = 150;
private static float _paramIndent = DefaultParameterIndent;
private static float _widthRatio = 1;
private static float LeftParameterPadding => _paramIndent * T3Ui.UiScaleFactor;
public static float ParameterSpacing => 20 * T3Ui.UiScaleFactor;
}