#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; }