Repository: LiveSplit/LiveSplit.Splits
Pull request #27: ColumnType: CustomVariable
Author: login: AlexKnauth
Labels: name: enhancement
Created: 2024-09-13T02:35:03Z | Merged: 2025-05-17T18:06:14Z

A companion PR to https://github.com/LiveSplit/LiveSplit/pull/2528. Allows a column in the splits component to display the value of a Custom Variable, with Column Type choice option.

Depends on https://github.com/LiveSplit/LiveSplit/pull/2600.

Tasks:
- [x] Test behavior on skipped splits. If different from LiveSplit One, fix it to match.
  - After `wipe custom variable column for skipped splits`, when a split is skipped, both wipe the variable entries for that split
- [x] Fix behavior when splits scroll up and down.
- [x] Make sure past segment custom variable values are preserved when switching layouts, and show as the normal text color, not black


Diff:
diff --git a/src/LiveSplit.Splits/UI/ColumnType.cs b/src/LiveSplit.Splits/UI/ColumnType.cs
index 07def45..9ea1e6b 100644
--- a/src/LiveSplit.Splits/UI/ColumnType.cs
+++ b/src/LiveSplit.Splits/UI/ColumnType.cs
@@ -2,5 +2,5 @@
 
 public enum ColumnType
 {
-    Delta, SplitTime, DeltaorSplitTime, SegmentDelta, SegmentTime, SegmentDeltaorSegmentTime
+    Delta, SplitTime, DeltaorSplitTime, SegmentDelta, SegmentTime, SegmentDeltaorSegmentTime, CustomVariable
 }
diff --git a/src/LiveSplit.Splits/UI/Components/ColumnSettings.Designer.cs b/src/LiveSplit.Splits/UI/Components/ColumnSettings.Designer.cs
index 5c3a4d7..36aab24 100644
--- a/src/LiveSplit.Splits/UI/Components/ColumnSettings.Designer.cs
+++ b/src/LiveSplit.Splits/UI/Components/ColumnSettings.Designer.cs
@@ -137,7 +137,8 @@ private void InitializeComponent()
             "Delta or Split Time",
             "Segment Delta",
             "Segment Time",
-            "Segment Delta or Segment Time"});
+            "Segment Delta or Segment Time",
+            "Custom Variable"});
             this.cmbColumnType.Location = new System.Drawing.Point(93, 33);
             this.cmbColumnType.Name = "cmbColumnType";
             this.cmbColumnType.Size = new System.Drawing.Size(325, 21);
@@ -247,4 +248,4 @@ private void InitializeComponent()
         private System.Windows.Forms.Button btnMoveDown;
         private System.Windows.Forms.Button btnMoveUp;
     }
-}
\ No newline at end of file
+}
diff --git a/src/LiveSplit.Splits/UI/Components/ColumnSettings.cs b/src/LiveSplit.Splits/UI/Components/ColumnSettings.cs
index deda8ef..a33cb0b 100644
--- a/src/LiveSplit.Splits/UI/Components/ColumnSettings.cs
+++ b/src/LiveSplit.Splits/UI/Components/ColumnSettings.cs
@@ -131,10 +131,18 @@ private static string GetColumnType(ColumnType type)
         {
             return "Segment Delta";
         }
-        else
+        else if (type == ColumnType.SegmentDeltaorSegmentTime)
         {
             return "Segment Delta or Segment Time";
         }
+        else if (type == ColumnType.CustomVariable)
+        {
+            return "Custom Variable";
+        }
+        else
+        {
+            return "Unknown";
+        }
     }
 
     private static ColumnType ParseColumnType(string columnType)
diff --git a/src/LiveSplit.Splits/UI/Components/LabelsComponent.cs b/src/LiveSplit.Splits/UI/Components/LabelsComponent.cs
index 5b594ea..8179a77 100644
--- a/src/LiveSplit.Splits/UI/Components/LabelsComponent.cs
+++ b/src/LiveSplit.Splits/UI/Components/LabelsComponent.cs
@@ -6,7 +6,6 @@
 
 using LiveSplit.Model;
 using LiveSplit.Model.Comparisons;
-using LiveSplit.TimeFormatters;
 
 namespace LiveSplit.UI.Components;
 
@@ -14,22 +13,13 @@ public class LabelsComponent : IComponent
 {
     public SplitsSettings Settings { get; set; }
 
-    protected SimpleLabel MeasureTimeLabel { get; set; }
-    protected SimpleLabel MeasureDeltaLabel { get; set; }
-
-    protected ITimeFormatter TimeFormatter { get; set; }
-    protected ITimeFormatter DeltaTimeFormatter { get; set; }
-
-    protected TimeAccuracy CurrentAccuracy { get; set; }
-    protected TimeAccuracy CurrentDeltaAccuracy { get; set; }
-    protected bool CurrentDropDecimals { get; set; }
-
     protected int FrameCount { get; set; }
 
     public GraphicsCache Cache { get; set; }
 
     public IEnumerable<ColumnData> ColumnsList { get; set; }
     public IList<SimpleLabel> LabelsList { get; set; }
+    protected List<float> ColumnWidths { get; }
 
     public float PaddingTop => 0f;
     public float PaddingLeft => 0f;
@@ -45,19 +35,15 @@ public class LabelsComponent : IComponent
     public float MinimumHeight { get; set; }
 
     public IDictionary<string, Action> ContextMenuControls => null;
-    public LabelsComponent(SplitsSettings settings, IEnumerable<ColumnData> columns)
+    public LabelsComponent(SplitsSettings settings, IEnumerable<ColumnData> columns, List<float> columnWidths)
     {
         Settings = settings;
         MinimumHeight = 31;
 
-        MeasureTimeLabel = new SimpleLabel();
-        MeasureDeltaLabel = new SimpleLabel();
-        TimeFormatter = new SplitTimeFormatter(Settings.SplitTimesAccuracy);
-        DeltaTimeFormatter = new DeltaSplitTimeFormatter(Settings.DeltasAccuracy, Settings.DropDecimals);
-
         Cache = new GraphicsCache();
         LabelsList = [];
         ColumnsList = columns;
+        ColumnWidths = columnWidths;
     }
 
     private void DrawGeneral(Graphics g, LiveSplitState state, float width, float height, LayoutMode mode)
@@ -69,30 +55,6 @@ private void DrawGeneral(Graphics g, LiveSplitState state, float width, float he
                 ), 0, 0, width, height);
         }
 
-        MeasureTimeLabel.Text = TimeFormatter.Format(new TimeSpan(24, 0, 0));
-        MeasureDeltaLabel.Text = DeltaTimeFormatter.Format(new TimeSpan(0, 9, 0, 0));
-
-        MeasureTimeLabel.Font = state.LayoutSettings.TimesFont;
-        MeasureTimeLabel.IsMonospaced = true;
-        MeasureDeltaLabel.Font = state.LayoutSettings.TimesFont;
-        MeasureDeltaLabel.IsMonospaced = true;
-
-        MeasureTimeLabel.SetActualWidth(g);
-        MeasureDeltaLabel.SetActualWidth(g);
-
-        if (Settings.SplitTimesAccuracy != CurrentAccuracy)
-        {
-            TimeFormatter = new SplitTimeFormatter(Settings.SplitTimesAccuracy);
-            CurrentAccuracy = Settings.SplitTimesAccuracy;
-        }
-
-        if (Settings.DeltasAccuracy != CurrentDeltaAccuracy || Settings.DropDecimals != CurrentDropDecimals)
-        {
-            DeltaTimeFormatter = new DeltaSplitTimeFormatter(Settings.DeltasAccuracy, Settings.DropDecimals);
-            CurrentDeltaAccuracy = Settings.DeltasAccuracy;
-            CurrentDropDecimals = Settings.DropDecimals;
-        }
-
         foreach (SimpleLabel label in LabelsList)
         {
             label.ShadowColor = state.LayoutSettings.ShadowsColor;
@@ -105,24 +67,15 @@ private void DrawGeneral(Graphics g, LiveSplitState state, float width, float he
 
         if (ColumnsList.Count() == LabelsList.Count)
         {
+            while (ColumnWidths.Count < LabelsList.Count)
+            {
+                ColumnWidths.Add(0f);
+            }
+
             float curX = width - 7;
             foreach (SimpleLabel label in LabelsList.Reverse())
             {
-                ColumnData column = ColumnsList.ElementAt(LabelsList.IndexOf(label));
-
-                float labelWidth = 0f;
-                if (column.Type is ColumnType.DeltaorSplitTime or ColumnType.SegmentDeltaorSegmentTime)
-                {
-                    labelWidth = Math.Max(MeasureDeltaLabel.ActualWidth, MeasureTimeLabel.ActualWidth);
-                }
-                else if (column.Type is ColumnType.Delta or ColumnType.SegmentDelta)
-                {
-                    labelWidth = MeasureDeltaLabel.ActualWidth;
-                }
-                else
-                {
-                    labelWidth = MeasureTimeLabel.ActualWidth;
-                }
+                float labelWidth = ColumnWidths[LabelsList.IndexOf(label)];
 
                 curX -= labelWidth + 5;
                 label.Width = labelWidth;
diff --git a/src/LiveSplit.Splits/UI/Components/SplitComponent.cs b/src/LiveSplit.Splits/UI/Components/SplitComponent.cs
index a0240ac..f88773a 100644
--- a/src/LiveSplit.Splits/UI/Components/SplitComponent.cs
+++ b/src/LiveSplit.Splits/UI/Components/SplitComponent.cs
@@ -16,8 +16,6 @@ public class SplitComponent : IComponent
     public ISegment Split { get; set; }
 
     protected SimpleLabel NameLabel { get; set; }
-    protected SimpleLabel MeasureTimeLabel { get; set; }
-    protected SimpleLabel MeasureDeltaLabel { get; set; }
     public SplitsSettings Settings { get; set; }
 
     protected int FrameCount { get; set; }
@@ -47,6 +45,7 @@ public class SplitComponent : IComponent
 
     public IEnumerable<ColumnData> ColumnsList { get; set; }
     public IList<SimpleLabel> LabelsList { get; set; }
+    protected List<float> ColumnWidths { get; }
 
     public float VerticalHeight { get; set; }
 
@@ -60,17 +59,16 @@ public float HorizontalWidth
 
     public IDictionary<string, Action> ContextMenuControls => null;
 
-    public SplitComponent(SplitsSettings settings, IEnumerable<ColumnData> columnsList)
+    public SplitComponent(SplitsSettings settings, IEnumerable<ColumnData> columnsList, List<float> columnWidths)
     {
         NameLabel = new SimpleLabel()
         {
             HorizontalAlignment = StringAlignment.Near,
             X = 8,
         };
-        MeasureTimeLabel = new SimpleLabel();
-        MeasureDeltaLabel = new SimpleLabel();
         Settings = settings;
         ColumnsList = columnsList;
+        ColumnWidths = columnWidths;
         TimeFormatter = new SplitTimeFormatter(Settings.SplitTimesAccuracy);
         DeltaTimeFormatter = new DeltaSplitTimeFormatter(Settings.DeltasAccuracy, Settings.DropDecimals);
         MinimumHeight = 25;
@@ -99,17 +97,6 @@ private void DrawGeneral(Graphics g, LiveSplitState state, float width, float he
                 ), 0, 0, width, height);
         }
 
-        MeasureTimeLabel.Text = TimeFormatter.Format(new TimeSpan(24, 0, 0));
-        MeasureDeltaLabel.Text = DeltaTimeFormatter.Format(new TimeSpan(0, 9, 0, 0));
-
-        MeasureTimeLabel.Font = state.LayoutSettings.TimesFont;
-        MeasureTimeLabel.IsMonospaced = true;
-        MeasureDeltaLabel.Font = state.LayoutSettings.TimesFont;
-        MeasureDeltaLabel.IsMonospaced = true;
-
-        MeasureTimeLabel.SetActualWidth(g);
-        MeasureDeltaLabel.SetActualWidth(g);
-
         NameLabel.ShadowColor = state.LayoutSettings.ShadowsColor;
         NameLabel.OutlineColor = state.LayoutSettings.TextOutlineColor;
         foreach (SimpleLabel label in LabelsList)
@@ -229,25 +216,16 @@ private void DrawGeneral(Graphics g, LiveSplitState state, float width, float he
 
             if (ColumnsList.Count() == LabelsList.Count)
             {
+                while (ColumnWidths.Count < LabelsList.Count)
+                {
+                    ColumnWidths.Add(0f);
+                }
+
                 float curX = width - 7;
                 float nameX = width - 7;
                 foreach (SimpleLabel label in LabelsList.Reverse())
                 {
-                    ColumnData column = ColumnsList.ElementAt(LabelsList.IndexOf(label));
-
-                    float labelWidth = 0f;
-                    if (column.Type is ColumnType.DeltaorSplitTime or ColumnType.SegmentDeltaorSegmentTime)
-                    {
-                        labelWidth = Math.Max(MeasureDeltaLabel.ActualWidth, MeasureTimeLabel.ActualWidth);
-                    }
-                    else if (column.Type is ColumnType.Delta or ColumnType.SegmentDelta)
-                    {
-                        labelWidth = MeasureDeltaLabel.ActualWidth;
-                    }
-                    else
-                    {
-                        labelWidth = MeasureTimeLabel.ActualWidth;
-                    }
+                    float labelWidth = ColumnWidths[LabelsList.IndexOf(label)];
 
                     label.Width = labelWidth + 20;
                     curX -= labelWidth + 5;
@@ -387,7 +365,7 @@ protected void UpdateColumn(LiveSplitState state, SimpleLabel label, ColumnData
         int splitIndex = state.Run.IndexOf(Split);
         if (splitIndex < state.CurrentSplitIndex)
         {
-            if (type is ColumnType.SplitTime or ColumnType.SegmentTime)
+            if (type is ColumnType.SplitTime or ColumnType.SegmentTime or ColumnType.CustomVariable)
             {
                 label.ForeColor = Settings.OverrideTimesColor ? Settings.BeforeTimesColor : state.LayoutSettings.TextColor;
 
@@ -395,11 +373,16 @@ protected void UpdateColumn(LiveSplitState state, SimpleLabel label, ColumnData
                 {
                     label.Text = TimeFormatter.Format(Split.SplitTime[timingMethod]);
                 }
-                else //SegmentTime
+                else if (type == ColumnType.SegmentTime)
                 {
                     TimeSpan? segmentTime = LiveSplitStateHelper.GetPreviousSegmentTime(state, splitIndex, timingMethod);
                     label.Text = TimeFormatter.Format(segmentTime);
                 }
+                else if (type == ColumnType.CustomVariable)
+                {
+                    Split.CustomVariableValues.TryGetValue(data.Name, out string text);
+                    label.Text = text ?? "";
+                }
             }
 
             if (type is ColumnType.DeltaorSplitTime or ColumnType.Delta)
@@ -461,7 +444,7 @@ protected void UpdateColumn(LiveSplitState state, SimpleLabel label, ColumnData
         }
         else
         {
-            if (type is ColumnType.SplitTime or ColumnType.SegmentTime or ColumnType.DeltaorSplitTime or ColumnType.SegmentDeltaorSegmentTime)
+            if (type is ColumnType.SplitTime or ColumnType.SegmentTime or ColumnType.DeltaorSplitTime or ColumnType.SegmentDeltaorSegmentTime or ColumnType.CustomVariable)
             {
                 if (Split == state.CurrentSplit)
                 {
@@ -476,7 +459,7 @@ protected void UpdateColumn(LiveSplitState state, SimpleLabel label, ColumnData
                 {
                     label.Text = TimeFormatter.Format(Split.Comparisons[comparison][timingMethod]);
                 }
-                else //SegmentTime or SegmentTimeorSegmentDeltaTime
+                else if (type is ColumnType.SegmentTime or ColumnType.SegmentDeltaorSegmentTime)
                 {
                     TimeSpan previousTime = TimeSpan.Zero;
                     for (int index = splitIndex - 1; index >= 0; index--)
@@ -491,6 +474,17 @@ protected void UpdateColumn(LiveSplitState state, SimpleLabel label, ColumnData
 
                     label.Text = TimeFormatter.Format(Split.Comparisons[comparison][timingMethod] - previousTime);
                 }
+                else if (type is ColumnType.CustomVariable)
+                {
+                    if (splitIndex == state.CurrentSplitIndex)
+                    {
+                        label.Text = state.Run.Metadata.CustomVariableValue(data.Name) ?? "";
+                    }
+                    else if (splitIndex > state.CurrentSplitIndex)
+                    {
+                        label.Text = "";
+                    }
+                }
             }
 
             //Live Delta
@@ -511,14 +505,9 @@ protected void UpdateColumn(LiveSplitState state, SimpleLabel label, ColumnData
 
     protected float CalculateLabelsWidth()
     {
-        if (ColumnsList != null)
+        if (ColumnWidths != null)
         {
-            int mixedCount = ColumnsList.Count(x => x.Type is ColumnType.DeltaorSplitTime or ColumnType.SegmentDeltaorSegmentTime);
-            int deltaCount = ColumnsList.Count(x => x.Type is ColumnType.Delta or ColumnType.SegmentDelta);
-            int timeCount = ColumnsList.Count(x => x.Type is ColumnType.SplitTime or ColumnType.SegmentTime);
-            return (mixedCount * (Math.Max(MeasureDeltaLabel.ActualWidth, MeasureTimeLabel.ActualWidth) + 5))
-                + (deltaCount * (MeasureDeltaLabel.ActualWidth + 5))
-                + (timeCount * (MeasureTimeLabel.ActualWidth + 5));
+            return ColumnWidths.Sum() + (5 * ColumnWidths.Count());
         }
 
         return 0f;
@@ -569,10 +558,15 @@ public void Update(IInvalidator invalidator, LiveSplitState state, float width,
             Cache["IsActive"] = IsActive;
             Cache["NameColor"] = NameLabel.ForeColor.ToArgb();
             Cache["ColumnsCount"] = ColumnsList.Count();
-            foreach (SimpleLabel label in LabelsList)
+            for (int index = 0; index < LabelsList.Count; index++)
             {
-                Cache["Columns" + LabelsList.IndexOf(label) + "Text"] = label.Text;
-                Cache["Columns" + LabelsList.IndexOf(label) + "Color"] = label.ForeColor.ToArgb();
+                SimpleLabel label = LabelsList[index];
+                Cache["Columns" + index + "Text"] = label.Text;
+                Cache["Columns" + index + "Color"] = label.ForeColor.ToArgb();
+                if (index < ColumnWidths.Count)
+                {
+                    Cache["Columns" + index + "Width"] = ColumnWidths[index];
+                }
             }
 
             if (invalidator != null && (Cache.HasChanged || FrameCount > 1))
diff --git a/src/LiveSplit.Splits/UI/Components/SplitsComponent.cs b/src/LiveSplit.Splits/UI/Components/SplitsComponent.cs
index 0e8b72d..f7e95c3 100644
--- a/src/LiveSplit.Splits/UI/Components/SplitsComponent.cs
+++ b/src/LiveSplit.Splits/UI/Components/SplitsComponent.cs
@@ -6,6 +6,7 @@
 using System.Windows.Forms;
 
 using LiveSplit.Model;
+using LiveSplit.TimeFormatters;
 
 namespace LiveSplit.UI.Components;
 
@@ -23,6 +24,13 @@ public class SplitsComponent : IComponent
 
     protected SplitsSettings Settings { get; set; }
 
+    protected SimpleLabel MeasureTimeLabel { get; set; }
+    protected SimpleLabel MeasureDeltaLabel { get; set; }
+    protected SimpleLabel MeasureCharLabel { get; set; }
+
+    protected ITimeFormatter TimeFormatter { get; set; }
+    protected ITimeFormatter DeltaTimeFormatter { get; set; }
+
     private Dictionary<Image, Image> ShadowImages { get; set; }
 
     private int visualSplitCount;
@@ -39,6 +47,7 @@ public class SplitsComponent : IComponent
     protected Color OldShadowsColor { get; set; }
 
     protected IEnumerable<ColumnData> ColumnsList => Settings.ColumnsList.Select(x => x.Data);
+    protected List<float> ColumnWidths { get; set; }
 
     public string ComponentName => "Splits";
 
@@ -57,10 +66,18 @@ public SplitsComponent(LiveSplitState state)
         CurrentState = state;
         Settings = new SplitsSettings(state);
         InternalComponent = new ComponentRendererComponent();
+
+        MeasureTimeLabel = new SimpleLabel();
+        MeasureDeltaLabel = new SimpleLabel();
+        MeasureCharLabel = new SimpleLabel();
+        TimeFormatter = new SplitTimeFormatter(Settings.SplitTimesAccuracy);
+        DeltaTimeFormatter = new DeltaSplitTimeFormatter(Settings.DeltasAccuracy, Settings.DropDecimals);
+
         ShadowImages = [];
         visualSplitCount = Settings.VisualSplitCount;
         settingsSplitCount = Settings.VisualSplitCount;
         Settings.SplitLayoutChanged += Settings_SplitLayoutChanged;
+        ColumnWidths = Settings.ColumnsList.Select(_ => 0f).ToList();
         ScrollOffset = 0;
         RebuildVisualSplits();
         state.ComparisonRenamed += state_ComparisonRenamed;
@@ -94,7 +111,7 @@ private void RebuildVisualSplits()
 
         if (Settings.ShowColumnLabels && CurrentState.Layout?.Mode == LayoutMode.Vertical)
         {
-            Components.Add(new LabelsComponent(Settings, ColumnsList));
+            Components.Add(new LabelsComponent(Settings, ColumnsList, ColumnWidths));
             Components.Add(new SeparatorComponent());
         }
 
@@ -113,7 +130,7 @@ private void RebuildVisualSplits()
                 }
             }
 
-            var splitComponent = new SplitComponent(Settings, ColumnsList);
+            var splitComponent = new SplitComponent(Settings, ColumnsList, ColumnWidths);
             Components.Add(splitComponent);
             if (i < visualSplitCount - 1 || i == (Settings.LockLastSplit ? totalSplits - 1 : visualSplitCount - 1))
             {
@@ -305,10 +322,29 @@ private void DrawBackground(Graphics g, float width, float height)
         }
     }
 
+    private void SetMeasureLabels(Graphics g, LiveSplitState state)
+    {
+        MeasureTimeLabel.Text = TimeFormatter.Format(new TimeSpan(24, 0, 0));
+        MeasureDeltaLabel.Text = DeltaTimeFormatter.Format(new TimeSpan(0, 9, 0, 0));
+        MeasureCharLabel.Text = "W";
+
+        MeasureTimeLabel.Font = state.LayoutSettings.TimesFont;
+        MeasureTimeLabel.IsMonospaced = true;
+        MeasureDeltaLabel.Font = state.LayoutSettings.TimesFont;
+        MeasureDeltaLabel.IsMonospaced = true;
+        MeasureCharLabel.Font = state.LayoutSettings.TimesFont;
+        MeasureCharLabel.IsMonospaced = true;
+
+        MeasureTimeLabel.SetActualWidth(g);
+        MeasureDeltaLabel.SetActualWidth(g);
+        MeasureCharLabel.SetActualWidth(g);
+    }
+
     public void DrawVertical(Graphics g, LiveSplitState state, float width, Region clipRegion)
     {
         Prepare(state);
         DrawBackground(g, width, VerticalHeight);
+        SetMeasureLabels(g, state);
         InternalComponent.DrawVertical(g, state, width, clipRegion);
     }
 
@@ -316,6 +352,7 @@ public void DrawHorizontal(Graphics g, LiveSplitState state, float height, Regio
     {
         Prepare(state);
         DrawBackground(g, HorizontalWidth, height);
+        SetMeasureLabels(g, state);
         InternalComponent.DrawHorizontal(g, state, height, clipRegion);
     }
 
@@ -361,12 +398,59 @@ public void Update(IInvalidator invalidator, LiveSplitState state, float width,
             }
         }
 
+        CalculateColumnWidths(state.Run);
+
         if (invalidator != null)
         {
             InternalComponent.Update(invalidator, state, width, height, mode);
         }
     }
 
+    private void CalculateColumnWidths(IRun run)
+    {
+        if (ColumnsList != null)
+        {
+            while (ColumnWidths.Count < ColumnsList.Count())
+            {
+                ColumnWidths.Add(0f);
+            }
+
+            for (int i = 0; i < ColumnsList.Count(); i++)
+            {
+                ColumnData column = ColumnsList.ElementAt(i);
+
+                float labelWidth = 0f;
+                if (column.Type is ColumnType.DeltaorSplitTime or ColumnType.SegmentDeltaorSegmentTime)
+                {
+                    labelWidth = Math.Max(MeasureDeltaLabel.ActualWidth, MeasureTimeLabel.ActualWidth);
+                }
+                else if (column.Type is ColumnType.Delta or ColumnType.SegmentDelta)
+                {
+                    labelWidth = MeasureDeltaLabel.ActualWidth;
+                }
+                else if (column.Type is ColumnType.SplitTime or ColumnType.SegmentTime)
+                {
+                    labelWidth = MeasureTimeLabel.ActualWidth;
+                }
+                else if (column.Type is ColumnType.CustomVariable)
+                {
+                    int longest_length = run.Metadata.CustomVariableValue(column.Name).Length;
+                    foreach (ISegment split in run)
+                    {
+                        if (split.CustomVariableValues.TryGetValue(column.Name, out string value) && !string.IsNullOrEmpty(value))
+                        {
+                            longest_length = Math.Max(longest_length, value.Length);
+                        }
+                    }
+
+                    labelWidth = MeasureCharLabel.ActualWidth * longest_length;
+                }
+
+                ColumnWidths[i] = labelWidth;
+            }
+        }
+    }
+
     public void Dispose()
     {
     }


Reviews:
user: login: wooferzfg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-01-27T02:05:23Z
user: login: AlexKnauth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-27T02:11:07Z
user: login: wooferzfg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-01-27T02:12:49Z
user: login: AlexKnauth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T21:19:29Z
user: login: wooferzfg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T16:28:49Z
user: login: wooferzfg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T16:29:20Z
user: login: AlexKnauth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T16:59:28Z
user: login: AlexKnauth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T16:30:06Z
user: login: wooferzfg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T17:34:30Z
user: login: AlexKnauth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T17:35:04Z
user: login: wooferzfg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T23:20:51Z
user: login: wooferzfg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T23:20:57Z
user: login: wooferzfg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T23:21:12Z
user: login: wooferzfg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T03:56:06Z
user: login: AlexKnauth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T04:00:36Z
user: login: wooferzfg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T04:08:25Z
user: login: AlexKnauth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T04:18:28Z
user: login: wooferzfg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T04:29:03Z
user: login: wooferzfg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T04:29:53Z
user: login: AlexKnauth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T04:32:54Z
user: login: wooferzfg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T04:35:51Z
user: login: AlexKnauth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T06:25:14Z

Review comments:
diff_hunk: @@ -338,6 +341,22 @@ public System.Xml.XmlNode GetSettings(System.Xml.XmlDocument document)
 
     public void Update(IInvalidator invalidator, LiveSplitState state, float width, float height, LayoutMode mode)
     {
+        if (state.CurrentPhase is TimerPhase.Running or TimerPhase.Paused)
+        {
+            while (CustomVariableValues.Count <= state.CurrentSplitIndex)
+            {
+                CustomVariableValues.Add([]);
+            }
+
+            foreach (ColumnData column in ColumnsList)
+            {
+                if (column.Type is ColumnType.CustomVariable)
+                {
+                    CustomVariableValues[state.CurrentSplitIndex][column.Name] = state.Run.Metadata.CustomVariableValue(column.Name) ?? TimeFormatConstants.DASH;
path: src/LiveSplit.Splits/UI/Components/SplitsComponent.cs
user: login: wooferzfg
body: Shouldn't this be happening in the LiveSplit code and stored in the Run, instead of in this component? Otherwise you won't get any variable values if you switch layouts mid-run
created_at: 2025-01-27T02:04:56Z
author_association: MEMBER
diff_hunk: @@ -338,6 +341,22 @@ public System.Xml.XmlNode GetSettings(System.Xml.XmlDocument document)
 
     public void Update(IInvalidator invalidator, LiveSplitState state, float width, float height, LayoutMode mode)
     {
+        if (state.CurrentPhase is TimerPhase.Running or TimerPhase.Paused)
+        {
+            while (CustomVariableValues.Count <= state.CurrentSplitIndex)
+            {
+                CustomVariableValues.Add([]);
+            }
+
+            foreach (ColumnData column in ColumnsList)
+            {
+                if (column.Type is ColumnType.CustomVariable)
+                {
+                    CustomVariableValues[state.CurrentSplitIndex][column.Name] = state.Run.Metadata.CustomVariableValue(column.Name) ?? TimeFormatConstants.DASH;
path: src/LiveSplit.Splits/UI/Components/SplitsComponent.cs
user: login: AlexKnauth
body: That didn't seem necessary to me since these variable values don't need to be saved in the `.lss` splits file, just temporarily displayed as the run is going. Persisting across layout changes mid-run isn't something needed for any of the use-cases I was considering
created_at: 2025-01-27T02:11:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -338,6 +341,22 @@ public System.Xml.XmlNode GetSettings(System.Xml.XmlDocument document)
 
     public void Update(IInvalidator invalidator, LiveSplitState state, float width, float height, LayoutMode mode)
     {
+        if (state.CurrentPhase is TimerPhase.Running or TimerPhase.Paused)
+        {
+            while (CustomVariableValues.Count <= state.CurrentSplitIndex)
+            {
+                CustomVariableValues.Add([]);
+            }
+
+            foreach (ColumnData column in ColumnsList)
+            {
+                if (column.Type is ColumnType.CustomVariable)
+                {
+                    CustomVariableValues[state.CurrentSplitIndex][column.Name] = state.Run.Metadata.CustomVariableValue(column.Name) ?? TimeFormatConstants.DASH;
path: src/LiveSplit.Splits/UI/Components/SplitsComponent.cs
user: login: wooferzfg
body: Everything else related to a run is persisted across layout changes
created_at: 2025-01-27T02:12:49Z
author_association: MEMBER
diff_hunk: @@ -338,6 +341,22 @@ public System.Xml.XmlNode GetSettings(System.Xml.XmlDocument document)
 
     public void Update(IInvalidator invalidator, LiveSplitState state, float width, float height, LayoutMode mode)
     {
+        if (state.CurrentPhase is TimerPhase.Running or TimerPhase.Paused)
+        {
+            while (CustomVariableValues.Count <= state.CurrentSplitIndex)
+            {
+                CustomVariableValues.Add([]);
+            }
+
+            foreach (ColumnData column in ColumnsList)
+            {
+                if (column.Type is ColumnType.CustomVariable)
+                {
+                    CustomVariableValues[state.CurrentSplitIndex][column.Name] = state.Run.Metadata.CustomVariableValue(column.Name) ?? TimeFormatConstants.DASH;
path: src/LiveSplit.Splits/UI/Components/SplitsComponent.cs
user: login: AlexKnauth
body: I have updated this PR to use https://github.com/LiveSplit/LiveSplit/pull/2600 to store Custom Variable values in each Segment of the run, rather than in the layout component.
created_at: 2025-04-26T21:19:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -458,10 +458,24 @@ protected void UpdateColumn(LiveSplitState state, SimpleLabel label, ColumnData
                     label.Text = DeltaTimeFormatter.Format(segmentDelta);
                 }
             }
+
+            else if (type is ColumnType.CustomVariable)
+            {
+                // if the split was skipped, wipe the custom variable column entry for the split
+                if (Split.SplitTime[timingMethod] == null)
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: wooferzfg
body: How will these custom variables be set? It's a bit surprising to me that you have this check here, rather than this being done by whatever process is setting the custom variables (related to https://github.com/LiveSplit/LiveSplit/pull/2600)
created_at: 2025-04-27T16:28:49Z
author_association: MEMBER
diff_hunk: @@ -458,10 +458,24 @@ protected void UpdateColumn(LiveSplitState state, SimpleLabel label, ColumnData
                     label.Text = DeltaTimeFormatter.Format(segmentDelta);
                 }
             }
+
+            else if (type is ColumnType.CustomVariable)
+            {
+                // if the split was skipped, wipe the custom variable column entry for the split
+                if (Split.SplitTime[timingMethod] == null)
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: wooferzfg
body: Is there a reason you're allowing custom variable values to be set for skipped splits and future splits, if they're never displayed?
created_at: 2025-04-27T16:29:20Z
author_association: MEMBER
diff_hunk: @@ -458,10 +458,24 @@ protected void UpdateColumn(LiveSplitState state, SimpleLabel label, ColumnData
                     label.Text = DeltaTimeFormatter.Format(segmentDelta);
                 }
             }
+
+            else if (type is ColumnType.CustomVariable)
+            {
+                // if the split was skipped, wipe the custom variable column entry for the split
+                if (Split.SplitTime[timingMethod] == null)
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: AlexKnauth
body: In https://github.com/LiveSplit/LiveSplit/pull/2600, the custom variable values are cleared for a skipped segment, here:
https://github.com/LiveSplit/LiveSplit/pull/2600/files#diff-5702f4e00c13058babd747d35ff6fa73fff0a5a774a55eb542ad076c29bdf4f9R89

```csharp
    public void SkipSplit()
    {
        if ((CurrentState.CurrentPhase == TimerPhase.Running
            || CurrentState.CurrentPhase == TimerPhase.Paused)
            && CurrentState.CurrentSplitIndex < CurrentState.Run.Count - 1)
        {
            CurrentState.CurrentSplit.SplitTime = default;
            CurrentState.CurrentSplit.CustomVariableValues.Clear();
            CurrentState.CurrentSplitIndex++;
            CurrentState.Run.HasChanged = true;

            OnSkipSplit?.Invoke(this, null);
        }
    }
```

This `Split.SplitTime[timingMethod] == null` is a holdover from before then. I'll remove it after I find time to test that its behavior still matches LiveSplit One after removal.
created_at: 2025-04-27T16:59:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -458,10 +458,24 @@ protected void UpdateColumn(LiveSplitState state, SimpleLabel label, ColumnData
                     label.Text = DeltaTimeFormatter.Format(segmentDelta);
                 }
             }
+
+            else if (type is ColumnType.CustomVariable)
+            {
+                // if the split was skipped, wipe the custom variable column entry for the split
+                if (Split.SplitTime[timingMethod] == null)
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: AlexKnauth
body: I've cleaned up this holdover code and tested it to still be the same as LiveSplit One.

However, at the same time I also tested whether switching between a layout containing Splits vs a layout containing Subsplits, and it did not properly preserve past segment custom variable values.
created_at: 2025-04-28T16:30:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -458,10 +458,24 @@ protected void UpdateColumn(LiveSplitState state, SimpleLabel label, ColumnData
                     label.Text = DeltaTimeFormatter.Format(segmentDelta);
                 }
             }
+
+            else if (type is ColumnType.CustomVariable)
+            {
+                // if the split was skipped, wipe the custom variable column entry for the split
+                if (Split.SplitTime[timingMethod] == null)
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: wooferzfg
body: > However, at the same time I also tested whether switching between a layout containing Splits vs a layout containing Subsplits, and it did not properly preserve past segment custom variable values.

That's not good; do you know why that's the case?
created_at: 2025-04-28T17:34:30Z
author_association: MEMBER
diff_hunk: @@ -458,10 +458,24 @@ protected void UpdateColumn(LiveSplitState state, SimpleLabel label, ColumnData
                     label.Text = DeltaTimeFormatter.Format(segmentDelta);
                 }
             }
+
+            else if (type is ColumnType.CustomVariable)
+            {
+                // if the split was skipped, wipe the custom variable column entry for the split
+                if (Split.SplitTime[timingMethod] == null)
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: AlexKnauth
body: Not yet, but I'm looking into it
created_at: 2025-04-28T17:35:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -244,10 +244,14 @@ private void DrawGeneral(Graphics g, LiveSplitState state, float width, float he
                     {
                         labelWidth = MeasureDeltaLabel.ActualWidth;
                     }
-                    else
+                    else if (column.Type is ColumnType.SplitTime or ColumnType.SegmentTime)
                     {
                         labelWidth = MeasureTimeLabel.ActualWidth;
                     }
+                    else if (column.Type is ColumnType.CustomVariable)
+                    {
+                        labelWidth = Math.Max(MeasureDeltaLabel.ActualWidth, MeasureTimeLabel.ActualWidth);
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: wooferzfg
body: Why is that the width?
created_at: 2025-05-05T23:20:51Z
author_association: MEMBER
diff_hunk: @@ -516,9 +536,11 @@ protected float CalculateLabelsWidth()
             int mixedCount = ColumnsList.Count(x => x.Type is ColumnType.DeltaorSplitTime or ColumnType.SegmentDeltaorSegmentTime);
             int deltaCount = ColumnsList.Count(x => x.Type is ColumnType.Delta or ColumnType.SegmentDelta);
             int timeCount = ColumnsList.Count(x => x.Type is ColumnType.SplitTime or ColumnType.SegmentTime);
+            int varCount = ColumnsList.Count(x => x.Type is ColumnType.CustomVariable);
             return (mixedCount * (Math.Max(MeasureDeltaLabel.ActualWidth, MeasureTimeLabel.ActualWidth) + 5))
                 + (deltaCount * (MeasureDeltaLabel.ActualWidth + 5))
-                + (timeCount * (MeasureTimeLabel.ActualWidth + 5));
+                + (timeCount * (MeasureTimeLabel.ActualWidth + 5))
+                + (varCount * (Math.Max(MeasureDeltaLabel.ActualWidth, MeasureTimeLabel.ActualWidth) + 5));
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: wooferzfg
body: Why is that the width?
created_at: 2025-05-05T23:20:57Z
author_association: MEMBER
diff_hunk: @@ -119,10 +119,14 @@ private void DrawGeneral(Graphics g, LiveSplitState state, float width, float he
                 {
                     labelWidth = MeasureDeltaLabel.ActualWidth;
                 }
-                else
+                else if (column.Type is ColumnType.SplitTime or ColumnType.SegmentTime)
                 {
                     labelWidth = MeasureTimeLabel.ActualWidth;
                 }
+                else if (column.Type is ColumnType.CustomVariable)
+                {
+                    labelWidth = Math.Max(MeasureDeltaLabel.ActualWidth, MeasureTimeLabel.ActualWidth);
path: src/LiveSplit.Splits/UI/Components/LabelsComponent.cs
user: login: wooferzfg
body: Why is that the width?
created_at: 2025-05-05T23:21:12Z
author_association: MEMBER
diff_hunk: @@ -47,6 +49,7 @@ public class SplitComponent : IComponent
 
     public IEnumerable<ColumnData> ColumnsList { get; set; }
     public IList<SimpleLabel> LabelsList { get; set; }
+    protected List<float> ColumnWidths { get; }
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: wooferzfg
body: Why is this being added?
created_at: 2025-05-07T03:56:06Z
author_association: MEMBER
diff_hunk: @@ -47,6 +49,7 @@ public class SplitComponent : IComponent
 
     public IEnumerable<ColumnData> ColumnsList { get; set; }
     public IList<SimpleLabel> LabelsList { get; set; }
+    protected List<float> ColumnWidths { get; }
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: AlexKnauth
body: To store the list of column widths that comes from `SplitsComponent`, and is meant to be a shared mutable object: when a column gets larger in one `SplitComponent`, it should get larger on all `SplitComponent`s. Right now it looks a bit janky to me... there's probably a better way to do this
created_at: 2025-05-07T04:00:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,6 +49,7 @@ public class SplitComponent : IComponent
 
     public IEnumerable<ColumnData> ColumnsList { get; set; }
     public IList<SimpleLabel> LabelsList { get; set; }
+    protected List<float> ColumnWidths { get; }
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: wooferzfg
body: Is this fixing a bug that existed before? Or is it fixing an issue that was introduced with custom variable columns?
created_at: 2025-05-07T04:08:25Z
author_association: MEMBER
diff_hunk: @@ -47,6 +49,7 @@ public class SplitComponent : IComponent
 
     public IEnumerable<ColumnData> ColumnsList { get; set; }
     public IList<SimpleLabel> LabelsList { get; set; }
+    protected List<float> ColumnWidths { get; }
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: AlexKnauth
body: To my knowledge, it isn't fixing a bug that existed before. Unless it was possible to make split times or deltas be so big that they overrun the set column widths for those based on `MeasureDeltaLabel.ActualWidth`... but I'm not aware of such an issue before Custom Variables.

Custom Variables can be much longer than split times or deltas, so this is a new issue with those.

The "jank" I'm referring to is a slight delay from when the variable gets longer to when the next column over shifts to accommodate it. For that short period of time, there can be some overrun.

Also, sometimes it updates the current split first, and past splits a slight bit later. For that moment, the next column over looks crooked.
created_at: 2025-05-07T04:18:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,6 +49,7 @@ public class SplitComponent : IComponent
 
     public IEnumerable<ColumnData> ColumnsList { get; set; }
     public IList<SimpleLabel> LabelsList { get; set; }
+    protected List<float> ColumnWidths { get; }
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: wooferzfg
body: > The "jank" I'm referring to is a slight delay from when the variable gets longer to when the next column over shifts to accommodate it. For that short period of time, there can be some overrun.

Ideally, the custom variable text would temporarily be cut off instead of the columns not being aligned. And all the columns would shift at the same time
created_at: 2025-05-07T04:29:02Z
author_association: MEMBER
diff_hunk: @@ -47,6 +49,7 @@ public class SplitComponent : IComponent
 
     public IEnumerable<ColumnData> ColumnsList { get; set; }
     public IList<SimpleLabel> LabelsList { get; set; }
+    protected List<float> ColumnWidths { get; }
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: wooferzfg
body: What if a custom variable gets longer and then shorter again? Do the columns shrink?
created_at: 2025-05-07T04:29:53Z
author_association: MEMBER
diff_hunk: @@ -47,6 +49,7 @@ public class SplitComponent : IComponent
 
     public IEnumerable<ColumnData> ColumnsList { get; set; }
     public IList<SimpleLabel> LabelsList { get; set; }
+    protected List<float> ColumnWidths { get; }
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: AlexKnauth
body: The columns do not currently shrink. I can implement that if you want, I just hadn't thought of it because in my use-cases, the values wouldn't shrink so the columns would never need to.
created_at: 2025-05-07T04:32:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,6 +49,7 @@ public class SplitComponent : IComponent
 
     public IEnumerable<ColumnData> ColumnsList { get; set; }
     public IList<SimpleLabel> LabelsList { get; set; }
+    protected List<float> ColumnWidths { get; }
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: wooferzfg
body: What if you have a large variable value and then undo a split? Doesn't that cause the issue even with your use case?
created_at: 2025-05-07T04:35:51Z
author_association: MEMBER
diff_hunk: @@ -47,6 +49,7 @@ public class SplitComponent : IComponent
 
     public IEnumerable<ColumnData> ColumnsList { get; set; }
     public IList<SimpleLabel> LabelsList { get; set; }
+    protected List<float> ColumnWidths { get; }
path: src/LiveSplit.Splits/UI/Components/SplitComponent.cs
user: login: AlexKnauth
body: After `SplitsComponent: CalculateColumnWidths`, it can shrink the columns when the values shrink. It is also less precise, not accounting for characters with different widths, such as Fullwidth Unicode CJK characters.

I was hoping it would also get rid of the jank, but some of the jank I referred to above is still there.
created_at: 2025-05-07T06:25:14Z
author_association: CONTRIBUTOR

PR comments:
user: login: wooferzfg
created_at: 2025-04-27T16:30:33Z
body: Update Subsplits as well?
author_association: MEMBER
user: login: AlexKnauth
created_at: 2025-05-05T21:52:40Z
body: I figured out why it was looking like it didn't properly preserve past segment custom variable values when switching layouts. Turns out it *was* preserving them, but I couldn't see them because the text color was black! The text color gets set back to white when scrolling.

This video shows what it looks like, at 4:45:
https://youtu.be/GZt0n-kjic0?si=IKCZiVLoLYs2hR-a&t=285
author_association: CONTRIBUTOR
user: login: AlexKnauth
created_at: 2025-05-05T22:36:21Z
body: After `Fix CustomVariable ForeColor`, it's working properly, showing custom variable values when switching layouts, between a layout containing this Splits component and one containing the Subsplits component.
author_association: CONTRIBUTOR
user: login: AlexKnauth
created_at: 2025-05-13T04:24:56Z
body: I was hoping `Cache invalidation` would get rid of the jank crooked columns, but it seems like it didn't. I can try and test a few variations on when column widths get updated: there must be some variation where they all get the updated width together, but I haven't found it yet.
author_association: CONTRIBUTOR
user: login: AlexKnauth
created_at: 2025-05-14T20:04:38Z
body: With both `Cache invalidation` and `Revert "Draw: CalculateColumnWidths"`, the jank with the crooked columns has been solved. An example video of a column width changing mid-run: https://youtu.be/d5pBbmDtIXw?si=ONAYr5f_iTeLJRcA&t=143
author_association: CONTRIBUTOR