using System.Data; using System.Globalization; using System.Text.RegularExpressions; using ImGuiNET; using T3.Core.Utils; using T3.Editor.App; using T3.Editor.Gui.Interaction.Timing; using T3.Editor.Gui.Styling; using T3.Editor.Gui.UiHelpers; using T3.Editor.UiModel.InputsAndTypes; using T3.SystemUi; using Vector2 = System.Numerics.Vector2; namespace T3.Editor.Gui.Interaction; /// /// An alternative ImGui component for editing float values /// internal static class SingleValueEdit { /// /// Wrapper coll for int type /// internal static InputEditStateFlags Draw(ref int value, Vector2 size, int min = int.MinValue, int max = int.MaxValue, bool clampMin = false, bool clampMax = false, float scale = 0.2f, string format = "{0:0}", int defaultValue = default, float horizontalAlign = 0) { double doubleValue = value; var result = Draw(ref doubleValue, size, min, max, clampMin, clampMax, scale: scale, format: format, useIntegers: true, defaultValue: defaultValue, horizontalAlign: horizontalAlign); value = (int)doubleValue; return result; } /// /// Wrapper call for float type /// internal static InputEditStateFlags Draw(ref float value, Vector2 size, float min = float.NegativeInfinity, float max = float.PositiveInfinity, bool clampMin = false, bool clampMax= false, float scale = 0.01f, string format = "{0:0.000}", float defaultValue = default, float horizontalAlign = 0) { double floatValue = value; var result = Draw(ref floatValue, size, min, max, clampMin, clampMax, scale: scale, format: format, defaultValue: defaultValue, horizontalAlign: horizontalAlign); value = (float)floatValue; return result; } private static int _editInteractionCounter = 0; private static InputEditStateFlags Draw(ref double value, Vector2 size, double min = double.NegativeInfinity, double max = double.PositiveInfinity, bool clampMin = false, bool clampMax= false, float scale = 1, string format = "{0:0.000}", bool useIntegers = false, double defaultValue = default, float horizontalAlign = 0) { _numberFormat = format; _currentTabIndex++; var componentId = ImGui.GetID("valueEdit"); var shouldFocus = _currentTabIndex == _tabFocusIndex; if (shouldFocus) { //Log.Debug(" ShouldFocus for index " + TabFocusIndex + " state " + _state ); SetState(InputStates.TextInput); _activeJogDialId = componentId; _startValue = value; _jogDialText = FormatValueForButton(ref value, true); } var io = ImGui.GetIO(); if (componentId == _activeJogDialId) { var modified = false; switch (_state) { case InputStates.Dialing: modified = true; ImGui.PushStyleColor(ImGuiCol.Button, UiColors.BackgroundActive.Rgba); ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UiColors.BackgroundActive.Rgba); ImGui.PushStyleColor(ImGuiCol.ButtonActive, UiColors.BackgroundActive.Rgba); //DrawButtonWithDynamicLabel(FormatValueForButton(ref _editValue), ref size); ImGui.PushStyleVar(ImGuiStyleVar.ButtonTextAlign, new Vector2(horizontalAlign,0.5f)); ImGui.Button(FormatValueForButton(ref _editValue) + "###button", size); ImGui.PopStyleVar(); DrawValueRangeIndicator(value, min, max, true); ImGui.PopStyleColor(3); if (ImGui.IsMouseReleased(0)) { var wasClick = ImGui.GetIO().MouseDragMaxDistanceSqr[0] < UserSettings.Config.ClickThreshold; if (wasClick) { if (io.KeyCtrl) { SetState(InputStates.Inactive); return InputEditStateFlags.ResetToDefault; } else { SetState(InputStates.StartedTextInput); } } else { SetState(InputStates.Inactive); } break; } if (ImGui.IsItemDeactivated()) { SetState(InputStates.Inactive); break; } var restarted = (float)(ImGui.GetTime() - _timeOpened) < 0.1f; DrawValueEditMethod(ref _editValue, restarted, _center, min, max, clampMin, clampMax, scale); _dampedValue = _slidingAverage.UpdateAndCompute(_editValue); break; case InputStates.StartedTextInput: _editInteractionCounter++; ImGui.SetKeyboardFocusHere(); SetState(InputStates.TextInput); goto case InputStates.TextInput; case InputStates.TextInput: ImGui.PushStyleColor(ImGuiCol.Text, double.IsNaN(_editValue) ? UiColors.StatusError.Rgba : UiColors.ForegroundFull); ImGui.SetNextItemWidth(size.X); modified =ImGui.InputText("##dialInput" + _editInteractionCounter, ref _jogDialText, 20); // Keep Focusing until Tab-Key released if (shouldFocus) { ImGui.SetKeyboardFocusHere(-1); if (ImGui.IsKeyReleased(Key.Tab.ToImGuiKey())) { _tabFocusIndex = -1; } } ImGui.PopStyleColor(); var completedAfterTabbing = false; if (ImGui.IsKeyPressed(Key.Tab.ToImGuiKey()) && _tabFocusIndex == -1) { _tabFocusIndex = _currentTabIndex + (ImGui.GetIO().KeyShift ? -1 : 1); completedAfterTabbing = true; } var completedAfterFocusLoss = ImGui.IsKeyPressed(Key.Esc.ToImGuiKey()) || ImGui.IsItemDeactivated() || !ImGui.IsWindowFocused(); var cancelInputAfterFocusLoss = !shouldFocus && !ImGui.IsItemActive(); if (cancelInputAfterFocusLoss || completedAfterFocusLoss) { // NOTE: This happens after canceling editing by closing the input // and reopen the state. Sadly there doesn't appear to be a simple fix for this. _tabFocusIndex = -1; modified = false; } if (completedAfterTabbing || completedAfterFocusLoss) { SetState(InputStates.Inactive); if (double.IsNaN(_editValue)) _editValue = _startValue; modified = false; } var valid = Evaluate(_jogDialText, ref _editValue); // If the value is invalid, just revert it to what it was previously if (!valid) { _editValue = _startValue; _editValue = defaultValue; _jogDialText = value.ToString(CultureInfo.InvariantCulture); } break; } //value = _editValue; if (_state == InputStates.Dialing) { value = _dampedValue; } else { value = _editValue; } if (_state == InputStates.Inactive) { return InputEditStateFlags.Finished; } return modified ? InputEditStateFlags.Modified : InputEditStateFlags.Started; } ImGui.PushStyleVar(ImGuiStyleVar.ButtonTextAlign, new Vector2(horizontalAlign,0.5f)); ImGui.Button(FormatValueForButton(ref value) + "###button", size); ImGui.PopStyleVar(); //DrawButtonWithDynamicLabel(FormatValueForButton(ref value), ref size); DrawValueRangeIndicator(value, min, max); if (ImGui.IsItemActivated()) { _activeJogDialId = componentId; _editValue = value; _startValue = value; _jogDialText = FormatValueForButton(ref value); SetState(InputStates.Dialing); } else { var isHovered = ImGui.IsItemHovered(); if (_state == InputStates.Inactive) { var isHoveredComponent = _activeHoverComponentId == componentId; if (isHoveredComponent) { if (isHovered && (!UserSettings.Config.MouseWheelEditsNeedCtrlKey || io.KeyCtrl)) { T3Ui.MouseWheelFieldHovered = true; ImGui.SetMouseCursor(ImGuiMouseCursor.ResizeEW); var dl = ImGui.GetForegroundDrawList(); dl.AddRect(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), UiColors.StatusActivated); var wheel = io.MouseWheel; if (wheel == 0) return InputEditStateFlags.Nothing; if (useIntegers) { value += wheel * (io.KeyAlt ? 10 : 1); } else { var factor = 1f; if (io.KeyShift) { factor = 0.01f; } else if (io.KeyAlt) { factor = 10f; } value += wheel * scale * 10 * factor; } _hoveredComponentModifiedByWheel = true; return InputEditStateFlags.Modified; } var didModify = _hoveredComponentModifiedByWheel; _hoveredComponentModifiedByWheel = false; return didModify ? InputEditStateFlags.ModifiedAndFinished : InputEditStateFlags.Nothing; } if (isHovered) { T3Ui.MouseWheelFieldHovered = true; _hoveredComponentModifiedByWheel = false; _activeHoverComponentId = componentId; return InputEditStateFlags.Started; } } } return InputEditStateFlags.Nothing; } internal static void DrawValueEditMethod(ref double editValue, bool restarted, Vector2 center, double min, double max, bool clampMin, bool clampMax, float scale) { switch (UserSettings.Config.ValueEditMethod) { case UserSettings.ValueEditMethods.InfinitySlider: InfinitySliderOverlay.Draw(ref editValue, restarted, center, min, max, scale, clampMin, clampMax); break; case UserSettings.ValueEditMethods.RadialSlider: RadialSliderOverlay.Draw(ref editValue, restarted, center, min, max, scale, clampMin, clampMax); break; case UserSettings.ValueEditMethods.JogDial: JogDialOverlay.Draw(ref editValue, restarted, center, min, max, scale, clampMin, clampMax); break; case UserSettings.ValueEditMethods.ValueLadder: default: SliderLadder.Draw(ref editValue, min, max, scale, (float)(ImGui.GetTime() - _timeOpened), clampMin, clampMax, center); break; } } private static bool _hoveredComponentModifiedByWheel; private static readonly SlidingAverage _slidingAverage = new(UserSettings.Config.ValueEditSmoothing.Clamp(0,100)); private static double _dampedValue; private static void SetState(InputStates newState) { switch (newState) { case InputStates.Inactive: { _activeJogDialId = 0; break; } case InputStates.Dialing: _slidingAverage.Clear(UserSettings.Config.ValueEditSmoothing); _center = ImGui.GetMousePos(); _timeOpened = ImGui.GetTime(); break; case InputStates.StartedTextInput: break; case InputStates.TextInput: break; } _state = newState; } private static int _currentTabIndex; private static int _lastMaxTabIndex; private static int _tabFocusIndex = -1; // if not -1 tries to set keyboard focus to input field. private static bool Evaluate(string expression, ref double editValue) { try { var table = new DataTable(); table.Columns.Add("expression", typeof(string), expression); var row = table.NewRow(); table.Rows.Add(row); var newValue = double.Parse((string)row["expression"]); if (double.IsNaN(newValue) || double.IsInfinity(newValue)) return false; editValue = newValue; return true; } catch { return false; } } private static readonly Regex FilterFormattingPattern = new(@".*(\{0:(.+)\}).*"); private static string FormatValueForButton(ref double value, bool hideSuffix = false) { // Don't use rounding for integers if (_numberFormat == "{0:0}") return "" + (int)value; try { if (!hideSuffix) return string.Format(_numberFormat, value); // Special formatting suffices like ° or × will break the parse and lead to incorrect value input. // So we filter them out here. var match = FilterFormattingPattern.Match(_numberFormat); return string.Format( match.Success ? match.Groups[1].Value : _numberFormat, value); } catch (FormatException) { Log.Warning($"Invalid value format '{_numberFormat}'"); return value.ToString(CultureInfo.InvariantCulture); } } /// /// A horrible ImGui work around to have button that stays active while its label changes. /// private static void DrawButtonWithDynamicLabel(string label, ref Vector2 size) { var color1 = ImGuiCol.Text.GetStyleColor().Fade(ImGui.GetStyle().Alpha); var keepPos = ImGui.GetCursorScreenPos(); ImGui.Button("##dial", size); if (string.IsNullOrEmpty(label)) return; ImGui.GetWindowDrawList().AddText(keepPos + new Vector2(4, 4), color1, label); } private static void DrawValueRangeIndicator(double value, double min, double max, bool isActive = false) { if (double.IsInfinity(min) && double.IsInfinity(max)) return; var itemSize = ImGui.GetItemRectSize(); var itemPos = ImGui.GetItemRectMin(); var center = 0.0; if (Math.Abs(min) > 0.001f) { center = MathUtils.RemapAndClamp(0, min, max, 0, itemSize.X); // center = MathUtils.RemapAndClamp((min + max) * 0.5, min, max, 0, itemSize.X); } var end = MathUtils.RemapAndClamp(value, min, max, 0, itemSize.X); var orgCenter = center; if (center > end) { (center, end) = (end, center); } var p1 = itemPos + new Vector2((float)center, 0); var p2 = itemPos + new Vector2((float)end, itemSize.Y); var areaColor = UiColors.ForegroundFull.Fade(isActive ? 0.5f : 0.05f); var centerColor = UiColors.ForegroundFull.Fade(isActive ? 0.5f : 0.1f); var symbolColor = UiColors.ForegroundFull.Fade(isActive ? 0.8f : 0.06f); ImGui.GetWindowDrawList().AddRectFilled(p1, p2, areaColor); // Indicate center var alignment = center < orgCenter ? -1 : 0; ImGui.GetWindowDrawList().AddRectFilled( ImGui.GetItemRectMin() + new Vector2((float)orgCenter + alignment, 0), ImGui.GetItemRectMin() + new Vector2((float)orgCenter + alignment + 1, itemSize.Y), centerColor); // Indicate overflow if (value < min) { var triangleCenter = new Vector2(itemPos.X + 5, itemPos.Y + itemSize.Y - 5); ImGui.GetWindowDrawList().AddTriangleFilled( triangleCenter + new Vector2(-3, 0), triangleCenter + new Vector2(2, -4), triangleCenter + new Vector2(2, 4), symbolColor ); } else if (value > max) { var triangleCenter = new Vector2(itemPos.X + itemSize.X - 3, itemPos.Y + itemSize.Y - 5); ImGui.GetWindowDrawList().AddTriangleFilled( triangleCenter + new Vector2(-2, -4), triangleCenter + new Vector2(3, 0), triangleCenter + new Vector2(-2, 4), symbolColor ); } } private enum InputStates { Inactive, Dialing, StartedTextInput, TextInput, } private static uint _activeJogDialId; private static uint _activeHoverComponentId; private static Vector2 _center; private static double _editValue; private static double _startValue; private static string _jogDialText = ""; private static InputStates _state = InputStates.Inactive; private static string _numberFormat = "{0:0.000}"; private static double _timeOpened; private const double DefaultValue = 0.0; /// /// This is a horrible attempt to work around imguis current limitation that button elements can't have a tab focus /// public static void StartNextFrame() { _lastMaxTabIndex = _currentTabIndex; _currentTabIndex = 0; if (_tabFocusIndex > _lastMaxTabIndex) { Log.Debug("fixing tab overflow"); _tabFocusIndex = -1; } } }