From fd1cf10cd97ae473a2d09879e58ba971d4205aeb Mon Sep 17 00:00:00 2001 From: Kiko kiko Date: Fri, 3 Apr 2026 16:54:14 +0200 Subject: [PATCH 1/4] Add TrackBar control with renderer and demo panel --- samples/ControlGallery/MainForm.cs | 3 + .../ControlGallery/Panels/TrackBarPanel.cs | 235 ++++++++ src/Modern.Forms/Renderers/RenderManager.cs | 1 + .../Renderers/TrackBarRenderer.cs | 239 ++++++++ src/Modern.Forms/TickStyle.cs | 30 + src/Modern.Forms/TrackBar.cs | 567 ++++++++++++++++++ 6 files changed, 1075 insertions(+) create mode 100644 samples/ControlGallery/Panels/TrackBarPanel.cs create mode 100644 src/Modern.Forms/Renderers/TrackBarRenderer.cs create mode 100644 src/Modern.Forms/TickStyle.cs create mode 100644 src/Modern.Forms/TrackBar.cs diff --git a/samples/ControlGallery/MainForm.cs b/samples/ControlGallery/MainForm.cs index 97417e80..9bef835b 100644 --- a/samples/ControlGallery/MainForm.cs +++ b/samples/ControlGallery/MainForm.cs @@ -49,6 +49,7 @@ public MainForm () tree.Items.Add ("TextBox", ImageLoader.Get ("button.png")); tree.Items.Add ("TitleBar", ImageLoader.Get ("button.png")); tree.Items.Add ("ToolBar", ImageLoader.Get ("button.png")); + tree.Items.Add("TrackBar", ImageLoader.Get("button.png")); tree.Items.Add ("TreeView", ImageLoader.Get ("button.png")); tree.ItemSelected += Tree_ItemSelected; @@ -140,6 +141,8 @@ private void Tree_ItemSelected (object? sender, EventArgs e) return new TitleBarPanel (); case "ToolBar": return new ToolBarPanel (); + case "TrackBar": + return new TrackBarPanel (); case "TreeView": return new TreeViewPanel (); } diff --git a/samples/ControlGallery/Panels/TrackBarPanel.cs b/samples/ControlGallery/Panels/TrackBarPanel.cs new file mode 100644 index 00000000..56a79bd2 --- /dev/null +++ b/samples/ControlGallery/Panels/TrackBarPanel.cs @@ -0,0 +1,235 @@ +using Modern.Forms; + +namespace ControlGallery.Panels +{ + /// + /// Demonstrates different configurations in the control gallery. + /// + /// + /// This panel showcases: + /// + /// A basic horizontal track bar. + /// A horizontal track bar with tick marks on both sides. + /// A vertical track bar. + /// A snapped track bar that rounds values to tick marks. + /// A disabled track bar. + /// + /// + public class TrackBarPanel : Panel + { + /// + /// Initializes a new instance of the class. + /// + public TrackBarPanel () + { + CreateHorizontalExamples (); + CreateVerticalExamples (); + CreateOptionsSection (); + } + + private void CreateHorizontalExamples () + { + Controls.Add (new Label { + Text = "Horizontal", + Left = 10, + Top = 10, + Width = 200, + Height = 25 + }); + + var horizontal_value_label = Controls.Add (new Label { + Text = "Value: 25", + Left = 320, + Top = 45, + Width = 120, + Height = 25 + }); + + var horizontal = Controls.Add (new TrackBar { + Left = 10, + Top = 40, + Width = 280, + Minimum = 0, + Maximum = 100, + Value = 25, + TickFrequency = 10, + TickStyle = TickStyle.BottomRight + }); + + horizontal.ValueChanged += (sender, e) => { + horizontal_value_label.Text = $"Value: {horizontal.Value}"; + }; + + var both_ticks_value_label = Controls.Add (new Label { + Text = "Value: 60", + Left = 320, + Top = 105, + Width = 120, + Height = 25 + }); + + var both_ticks = Controls.Add (new TrackBar { + Left = 10, + Top = 100, + Width = 280, + Minimum = 0, + Maximum = 100, + Value = 60, + TickFrequency = 5, + TickStyle = TickStyle.Both + }); + + both_ticks.ValueChanged += (sender, e) => { + both_ticks_value_label.Text = $"Value: {both_ticks.Value}"; + }; + + Controls.Add (new Label { + Text = "Snap to ticks", + Left = 10, + Top = 160, + Width = 200, + Height = 25 + }); + + var snap_value_label = Controls.Add (new Label { + Text = "Value: 30", + Left = 320, + Top = 195, + Width = 120, + Height = 25 + }); + + var snapped = Controls.Add (new TrackBar { + Left = 10, + Top = 190, + Width = 280, + Minimum = 0, + Maximum = 100, + Value = 30, + TickFrequency = 10, + TickStyle = TickStyle.TopLeft, + SnapToTicks = true + }); + + snapped.ValueChanged += (sender, e) => { + snap_value_label.Text = $"Value: {snapped.Value}"; + }; + + Controls.Add (new Label { + Text = "Disabled", + Left = 10, + Top = 250, + Width = 200, + Height = 25 + }); + + Controls.Add (new TrackBar { + Left = 10, + Top = 280, + Width = 280, + Minimum = 0, + Maximum = 100, + Value = 45, + TickFrequency = 10, + TickStyle = TickStyle.BottomRight, + Enabled = false + }); + } + + private void CreateVerticalExamples () + { + Controls.Add (new Label { + Text = "Vertical", + Left = 500, + Top = 10, + Width = 100, + Height = 25 + }); + + var vertical_value_label = Controls.Add (new Label { + Text = "Value: 75", + Left = 470, + Top = 45, + Width = 120, + Height = 25 + }); + + var vertical = Controls.Add (new TrackBar { + Left = 500, + Top = 80, + Width = 32, + Height = 220, + Orientation = Orientation.Vertical, + Minimum = 0, + Maximum = 100, + Value = 75, + TickFrequency = 10, + TickStyle = TickStyle.Both + }); + + vertical.ValueChanged += (sender, e) => { + vertical_value_label.Text = $"Value: {vertical.Value}"; + }; + } + + private void CreateOptionsSection () + { + Controls.Add (new Label { + Text = "Interactive example", + Left = 10, + Top = 360, + Width = 200, + Height = 25 + }); + + var interactive = Controls.Add (new TrackBar { + Left = 10, + Top = 390, + Width = 280, + Minimum = 0, + Maximum = 50, + Value = 10, + TickFrequency = 5, + TickStyle = TickStyle.BottomRight + }); + + var value_label = Controls.Add (new Label { + Text = "Value: 10", + Left = 320, + Top = 395, + Width = 120, + Height = 25 + }); + + var snap_checkbox = Controls.Add (new CheckBox { + Text = "Snap to ticks", + Left = 320, + Top = 425, + Width = 150, + Checked = false + }); + + var tick_style_combo = Controls.Add (new ComboBox { + Left = 320, + Top = 455, + Width = 150 + }); + + tick_style_combo.Items.AddRange (Enum.GetNames ()); + tick_style_combo.SelectedItem = TickStyle.BottomRight.ToString (); + + interactive.ValueChanged += (sender, e) => { + value_label.Text = $"Value: {interactive.Value}"; + }; + + snap_checkbox.CheckedChanged += (sender, e) => { + interactive.SnapToTicks = snap_checkbox.Checked; + }; + + tick_style_combo.SelectedIndexChanged += (sender, e) => { + if (tick_style_combo.SelectedItem is string selected_text) + interactive.TickStyle = Enum.Parse (selected_text); + }; + } + } +} diff --git a/src/Modern.Forms/Renderers/RenderManager.cs b/src/Modern.Forms/Renderers/RenderManager.cs index 1652634b..5a568e4e 100644 --- a/src/Modern.Forms/Renderers/RenderManager.cs +++ b/src/Modern.Forms/Renderers/RenderManager.cs @@ -33,6 +33,7 @@ static RenderManager () SetRenderer (new TabStripRenderer ()); SetRenderer (new TextBoxRenderer ()); SetRenderer (new ToolBarRenderer ()); + SetRenderer (new TrackBarRenderer ()); SetRenderer (new TreeViewRenderer ()); } diff --git a/src/Modern.Forms/Renderers/TrackBarRenderer.cs b/src/Modern.Forms/Renderers/TrackBarRenderer.cs new file mode 100644 index 00000000..b3205952 --- /dev/null +++ b/src/Modern.Forms/Renderers/TrackBarRenderer.cs @@ -0,0 +1,239 @@ +using System.Drawing; + +namespace Modern.Forms.Renderers +{ + /// + /// Renders a custom TrackBar control. + /// + public class TrackBarRenderer : Renderer + { + /// + protected override void Render (TrackBar control, PaintEventArgs e) + { + var track_bounds = GetTrackBounds (control, e); + var thumb_bounds = GetThumbBounds (control, e); + + DrawTicks (control, e, track_bounds); + DrawTrack (control, e, track_bounds); + DrawThumb (control, e, thumb_bounds); + + if (control.Selected && control.ShowFocusCues) { + var focus_bounds = control.ClientRectangle; + focus_bounds.Width -= 1; + focus_bounds.Height -= 1; + e.Canvas.DrawRectangle (focus_bounds, Theme.AccentColor2); + } + } + + /// + /// Gets the bounds of the drawn track line. + /// + public Rectangle GetTrackBounds (TrackBar control) + => GetTrackBounds (control, null); + + /// + /// Gets the bounds of the thumb. + /// + public Rectangle GetThumbBounds (TrackBar control) + => GetThumbBounds (control, null); + + /// + /// Converts a point on the control into a value. + /// + public int PositionToValue (TrackBar control, Point location) + { + var thumb_bounds = GetThumbBounds (control); + return PositionToValueFromThumb (control, location, control.Orientation == Orientation.Horizontal ? thumb_bounds.Width / 2 : thumb_bounds.Height / 2); + } + + /// + /// Converts a point on the control into a value using the thumb drag offset. + /// + public int PositionToValueFromThumb (TrackBar control, Point location, int dragOffsetFromThumbOrigin) + { + var track_bounds = GetTrackBounds (control); + var thumb_size = GetThumbSize (control, null); + + if (control.Maximum <= control.Minimum) + return control.Minimum; + + if (control.Orientation == Orientation.Horizontal) { + var usable = Math.Max (1, track_bounds.Width - thumb_size.Width); + var thumb_left = location.X - dragOffsetFromThumbOrigin; + var percent = (double)(thumb_left - track_bounds.Left) / usable; + percent = Math.Max (0d, Math.Min (1d, percent)); + + return control.Minimum + (int)Math.Round (percent * (control.Maximum - control.Minimum)); + } else { + var usable = Math.Max (1, track_bounds.Height - thumb_size.Height); + var thumb_top = location.Y - dragOffsetFromThumbOrigin; + var percent = 1d - ((double)(thumb_top - track_bounds.Top) / usable); + percent = Math.Max (0d, Math.Min (1d, percent)); + + return control.Minimum + (int)Math.Round (percent * (control.Maximum - control.Minimum)); + } + } + + private void DrawThumb (TrackBar control, PaintEventArgs e, Rectangle thumb_bounds) + { + var fill_color = !control.Enabled ? Theme.ControlMidHighColor + : control.ThumbPressed ? Theme.AccentColor2 + : control.ThumbHovered ? Theme.AccentColor + : Theme.ControlMidHighColor; + + var border_color = !control.Enabled ? Theme.ForegroundDisabledColor + : control.ThumbPressed ? Theme.AccentColor2 + : Theme.BorderLowColor; + + var draw_bounds = thumb_bounds; + draw_bounds.Width -= 1; + draw_bounds.Height -= 1; + + e.Canvas.FillRectangle (draw_bounds, fill_color); + e.Canvas.DrawRectangle (draw_bounds, border_color); + } + + private void DrawTicks (TrackBar control, PaintEventArgs e, Rectangle track_bounds) + { + if (control.TickStyle == TickStyle.None || control.Maximum < control.Minimum) + return; + + var tick_length = e.LogicalToDeviceUnits (4); + var tick_color = control.Enabled ? Theme.ForegroundColor : Theme.ForegroundDisabledColor; + + foreach (var position in GetTickPositions (control, e, track_bounds)) { + if (control.Orientation == Orientation.Horizontal) { + if (control.TickStyle == TickStyle.TopLeft || control.TickStyle == TickStyle.Both) + e.Canvas.DrawLine (position, track_bounds.Top - tick_length - 1, position, track_bounds.Top - 1, tick_color); + + if (control.TickStyle == TickStyle.BottomRight || control.TickStyle == TickStyle.Both) + e.Canvas.DrawLine (position, track_bounds.Bottom + 1, position, track_bounds.Bottom + tick_length + 1, tick_color); + } else { + if (control.TickStyle == TickStyle.TopLeft || control.TickStyle == TickStyle.Both) + e.Canvas.DrawLine (track_bounds.Left - tick_length - 1, position, track_bounds.Left - 1, position, tick_color); + + if (control.TickStyle == TickStyle.BottomRight || control.TickStyle == TickStyle.Both) + e.Canvas.DrawLine (track_bounds.Right + 1, position, track_bounds.Right + tick_length + 1, position, tick_color); + } + } + } + + private void DrawTrack (TrackBar control, PaintEventArgs e, Rectangle track_bounds) + { + var track_color = control.Enabled ? Theme.ControlMidHighColor : Theme.ControlMidColor; + var border_color = control.Enabled ? Theme.BorderLowColor : Theme.ForegroundDisabledColor; + + e.Canvas.FillRectangle (track_bounds, track_color); + e.Canvas.DrawRectangle (track_bounds, border_color); + } + + private Rectangle GetTrackBounds (TrackBar control, PaintEventArgs? e) + { + var client = control.ClientRectangle; + var thumb_size = GetThumbSize (control, e); + var track_thickness = e?.LogicalToDeviceUnits (4) ?? control.LogicalToDeviceUnits (4); + + if (control.Orientation == Orientation.Horizontal) { + var x = thumb_size.Width / 2; + var width = Math.Max (1, client.Width - thumb_size.Width); + + var y = (client.Height - track_thickness) / 2; + return new Rectangle (x, y, width, track_thickness); + } else { + var y = thumb_size.Height / 2; + var height = Math.Max (1, client.Height - thumb_size.Height); + + var x = (client.Width - track_thickness) / 2; + return new Rectangle (x, y, track_thickness, height); + } + } + + private Rectangle GetThumbBounds (TrackBar control, PaintEventArgs? e) + { + var track_bounds = GetTrackBounds (control, e); + var thumb_size = GetThumbSize (control, e); + + if (control.Maximum <= control.Minimum) { + if (control.Orientation == Orientation.Horizontal) + return new Rectangle (track_bounds.Left, (control.ClientRectangle.Height - thumb_size.Height) / 2, thumb_size.Width, thumb_size.Height); + + return new Rectangle ((control.ClientRectangle.Width - thumb_size.Width) / 2, track_bounds.Bottom - thumb_size.Height, thumb_size.Width, thumb_size.Height); + } + + var percent = (double)(control.Value - control.Minimum) / (control.Maximum - control.Minimum); + percent = Math.Max (0d, Math.Min (1d, percent)); + + if (control.Orientation == Orientation.Horizontal) { + var usable = Math.Max (1, track_bounds.Width - thumb_size.Width); + var x = track_bounds.Left + (int)Math.Round (percent * usable); + var y = (control.ClientRectangle.Height - thumb_size.Height) / 2; + + return new Rectangle (x, y, thumb_size.Width, thumb_size.Height); + } else { + var usable = Math.Max (1, track_bounds.Height - thumb_size.Height); + var y = track_bounds.Top + (int)Math.Round ((1d - percent) * usable); + var x = (control.ClientRectangle.Width - thumb_size.Width) / 2; + + return new Rectangle (x, y, thumb_size.Width, thumb_size.Height); + } + } + + private Size GetThumbSize (TrackBar control, PaintEventArgs? e) + { + var width = e?.LogicalToDeviceUnits (14) ?? control.LogicalToDeviceUnits (14); + var height = e?.LogicalToDeviceUnits (20) ?? control.LogicalToDeviceUnits (20); + + return control.Orientation == Orientation.Horizontal + ? new Size (width, height) + : new Size (height, width); + } + + private IEnumerable GetTickPositions (TrackBar control, PaintEventArgs e, Rectangle track_bounds) + { + if (control.Maximum < control.Minimum) + yield break; + + var thumb_size = GetThumbSize (control, e); + + if (control.Maximum == control.Minimum) { + if (control.Orientation == Orientation.Horizontal) + yield return track_bounds.Left + (thumb_size.Width / 2); + else + yield return track_bounds.Top + (thumb_size.Height / 2); + + yield break; + } + + var producedMaximum = false; + + for (var value = control.Minimum; value <= control.Maximum; value += control.TickFrequency) { + if (value == control.Maximum) + producedMaximum = true; + + yield return ValueToPixel (control, track_bounds, thumb_size, value); + } + + if (!producedMaximum) + yield return ValueToPixel (control, track_bounds, thumb_size, control.Maximum); + } + + private int ValueToPixel (TrackBar control, Rectangle track_bounds, Size thumb_size, int value) + { + if (control.Maximum <= control.Minimum) + return control.Orientation == Orientation.Horizontal + ? track_bounds.Left + (thumb_size.Width / 2) + : track_bounds.Top + (thumb_size.Height / 2); + + var percent = (double)(value - control.Minimum) / (control.Maximum - control.Minimum); + percent = Math.Max (0d, Math.Min (1d, percent)); + + if (control.Orientation == Orientation.Horizontal) { + var usable = Math.Max (1, track_bounds.Width - thumb_size.Width); + return track_bounds.Left + (thumb_size.Width / 2) + (int)Math.Round (percent * usable); + } else { + var usable = Math.Max (1, track_bounds.Height - thumb_size.Height); + return track_bounds.Top + (thumb_size.Height / 2) + (int)Math.Round ((1d - percent) * usable); + } + } + } +} diff --git a/src/Modern.Forms/TickStyle.cs b/src/Modern.Forms/TickStyle.cs new file mode 100644 index 00000000..118c83e9 --- /dev/null +++ b/src/Modern.Forms/TickStyle.cs @@ -0,0 +1,30 @@ +namespace Modern.Forms +{ + /// + /// Specifies where tick marks are drawn on a . + /// + public enum TickStyle + { + /// + /// No tick marks are displayed. + /// + None, + + /// + /// Tick marks are displayed on the top side of a horizontal + /// or on the left side of a vertical . + /// + TopLeft, + + /// + /// Tick marks are displayed on the bottom side of a horizontal + /// or on the right side of a vertical . + /// + BottomRight, + + /// + /// Tick marks are displayed on both sides of the track. + /// + Both + } +} diff --git a/src/Modern.Forms/TrackBar.cs b/src/Modern.Forms/TrackBar.cs new file mode 100644 index 00000000..7c8c09e7 --- /dev/null +++ b/src/Modern.Forms/TrackBar.cs @@ -0,0 +1,567 @@ +using System.Drawing; +using Modern.Forms.Renderers; + +namespace Modern.Forms +{ + /// + /// Represents a slider control that allows the user to select a value + /// from a bounded numeric range by dragging a thumb along a track. + /// + /// + /// + /// supports both horizontal and vertical layouts, + /// keyboard and mouse interaction, tick mark rendering, and optional + /// snapping to tick positions. + /// + /// + /// The control uses for layout calculations + /// and painting. + /// + /// + /// + /// + /// var trackBar = new TrackBar + /// { + /// Minimum = 0, + /// Maximum = 100, + /// Value = 25, + /// TickFrequency = 10, + /// TickStyle = TickStyle.BottomRight, + /// SnapToTicks = false, + /// Dock = DockStyle.Top + /// }; + /// + /// trackBar.ValueChanged += (sender, e) => + /// { + /// Console.WriteLine(trackBar.Value); + /// }; + /// + /// + public class TrackBar : Control + { + private const int DEFAULT_MINIMUM = 0; + private const int DEFAULT_MAXIMUM = 10; + private const int DEFAULT_VALUE = 0; + private const int DEFAULT_SMALL_CHANGE = 1; + private const int DEFAULT_LARGE_CHANGE = 5; + private const int DEFAULT_TICK_FREQUENCY = 1; + private const int DEFAULT_PREFERRED_THICKNESS = 32; + private const int DEFAULT_PREFERRED_LENGTH = 104; + + private bool thumb_pressed; + private bool thumb_hovered; + private int drag_offset_from_thumb_origin; + + private int minimum = DEFAULT_MINIMUM; + private int maximum = DEFAULT_MAXIMUM; + private int current_value = DEFAULT_VALUE; + private int small_change = DEFAULT_SMALL_CHANGE; + private int large_change = DEFAULT_LARGE_CHANGE; + private int tick_frequency = DEFAULT_TICK_FREQUENCY; + private Orientation orientation = Orientation.Horizontal; + private TickStyle tick_style = TickStyle.BottomRight; + private bool snap_to_ticks; + + /// + /// Initializes a new instance of the class. + /// + public TrackBar () + { + AutoSize = true; + TabStop = true; + + SetAutoSizeMode (AutoSizeMode.GrowOnly); + SetControlBehavior (ControlBehaviors.Hoverable | ControlBehaviors.Selectable); + } + + /// + /// Gets the default for all instances. + /// + public new static ControlStyle DefaultStyle = new ControlStyle (Control.DefaultStyle, + (style) => { + style.BackgroundColor = Theme.BackgroundColor; + }); + + /// + public override ControlStyle Style { get; } = new ControlStyle (DefaultStyle); + + /// + protected override Size DefaultSize + => Orientation == Orientation.Horizontal + ? new Size (DEFAULT_PREFERRED_LENGTH, DEFAULT_PREFERRED_THICKNESS) + : new Size (DEFAULT_PREFERRED_THICKNESS, DEFAULT_PREFERRED_LENGTH); + + /// + /// Gets or sets a value indicating whether the control automatically keeps + /// its thickness appropriate for the current . + /// + public override bool AutoSize { + get => base.AutoSize; + set { + if (base.AutoSize != value) { + base.AutoSize = value; + AdjustAutoSizeDimension (); + } + } + } + + /// + /// Gets or sets the amount by which the value changes when Page Up or Page Down is used. + /// + /// + /// Thrown when the value is less than zero. + /// + public int LargeChange { + get => large_change; + set { + if (value < 0) + throw new ArgumentOutOfRangeException (nameof (LargeChange), $"Value '{value}' must be greater than or equal to 0."); + + if (large_change != value) { + large_change = value; + Invalidate (); + } + } + } + + /// + /// Gets or sets the maximum value of the control range. + /// + /// + /// Thrown when the assigned value is less than . + /// + public int Maximum { + get => maximum; + set { + if (maximum == value) + return; + + if (value < minimum) + throw new ArgumentOutOfRangeException (nameof (Maximum), $"Value '{value}' must be greater than or equal to Minimum."); + + maximum = value; + + if (current_value > maximum) + SetValueCore (maximum, raiseScroll: false); + + Invalidate (); + } + } + + /// + /// Gets or sets the minimum value of the control range. + /// + /// + /// Thrown when the assigned value is greater than . + /// + public int Minimum { + get => minimum; + set { + if (minimum == value) + return; + + if (value > maximum) + throw new ArgumentOutOfRangeException (nameof (Minimum), $"Value '{value}' must be less than or equal to Maximum."); + + minimum = value; + + if (current_value < minimum) + SetValueCore (minimum, raiseScroll: false); + + Invalidate (); + } + } + + /// + /// Gets or sets the orientation of the control. + /// + public Orientation Orientation { + get => orientation; + set { + if (orientation != value) { + orientation = value; + AdjustAutoSizeDimension (); + Invalidate (); + } + } + } + + /// + /// Gets or sets the amount by which the value changes when arrow keys are used + /// or when the mouse wheel is rotated. + /// + /// + /// Thrown when the value is less than zero. + /// + public int SmallChange { + get => small_change; + set { + if (value < 0) + throw new ArgumentOutOfRangeException (nameof (SmallChange), $"Value '{value}' must be greater than or equal to 0."); + + if (small_change != value) { + small_change = value; + Invalidate (); + } + } + } + + /// + /// Gets or sets a value indicating whether the current value should be rounded + /// to the nearest tick position whenever it changes. + /// + public bool SnapToTicks { + get => snap_to_ticks; + set { + if (snap_to_ticks != value) { + snap_to_ticks = value; + + if (snap_to_ticks) + SetValueCore (current_value, raiseScroll: false); + + Invalidate (); + } + } + } + + /// + /// Gets or sets the spacing between tick marks, expressed in value units. + /// + /// + /// Thrown when the value is less than or equal to zero. + /// + public int TickFrequency { + get => tick_frequency; + set { + if (value <= 0) + throw new ArgumentOutOfRangeException (nameof (TickFrequency), $"Value '{value}' must be greater than 0."); + + if (tick_frequency != value) { + tick_frequency = value; + + if (SnapToTicks) + SetValueCore (current_value, raiseScroll: false); + + Invalidate (); + } + } + } + + /// + /// Gets or sets where tick marks are drawn relative to the track. + /// + public TickStyle TickStyle { + get => tick_style; + set { + if (tick_style != value) { + tick_style = value; + Invalidate (); + } + } + } + + /// + /// Gets or sets the current value of the control. + /// + /// + /// Thrown when the value is outside the and range. + /// + public int Value { + get => current_value; + set { + if (value < minimum || value > maximum) + throw new ArgumentOutOfRangeException (nameof (Value), $"'{value}' is not a valid value for 'Value'. 'Value' should be between 'Minimum' and 'Maximum'."); + + SetValueCore (value, raiseScroll: true); + } + } + + /// + /// Occurs when the control is scrolled by the user or programmatically through value changes + /// that should be treated as scroll interactions. + /// + public event EventHandler? Scroll; + + /// + /// Occurs when the property changes. + /// + public event EventHandler? ValueChanged; + + /// + /// Gets a value indicating whether the thumb is currently under the mouse pointer. + /// + internal bool ThumbHovered => thumb_hovered; + + /// + /// Gets a value indicating whether the thumb is currently pressed. + /// + internal bool ThumbPressed => thumb_pressed; + + private TrackBarRenderer GetRenderer () + { + return RenderManager.GetRenderer () + ?? throw new InvalidOperationException ("No TrackBarRenderer has been registered."); + } + + private void AdjustAutoSizeDimension () + { + if (!AutoSize) + return; + + if (Orientation == Orientation.Horizontal) + Height = DEFAULT_PREFERRED_THICKNESS; + else + Width = DEFAULT_PREFERRED_THICKNESS; + } + + private int Clamp (int value) => Math.Max (minimum, Math.Min (maximum, value)); + + private void ChangeValueBy (int delta) + { + var new_value = Clamp (current_value + delta); + SetValueCore (new_value, raiseScroll: true); + } + + private Rectangle GetThumbBounds () + => GetRenderer ().GetThumbBounds (this); + + private int PositionToValue (Point location) + => GetRenderer ().PositionToValue (this, location); + + private int SnapValueToTick (int value) + { + if (!SnapToTicks || tick_frequency <= 0 || maximum <= minimum) + return Clamp (value); + + if (value <= minimum) + return minimum; + + if (value >= maximum) + return maximum; + + var relative = value - minimum; + var snapped_relative = (int)Math.Round (relative / (double)tick_frequency) * tick_frequency; + var snapped = minimum + snapped_relative; + + return Clamp (snapped); + } + + /// + public override Size GetPreferredSize (Size proposedSize) + { + var current = Size; + + if (Orientation == Orientation.Horizontal) { + var width = Math.Max (current.Width, DEFAULT_PREFERRED_LENGTH); + return new Size (width, DEFAULT_PREFERRED_THICKNESS); + } + + var height = Math.Max (current.Height, DEFAULT_PREFERRED_LENGTH); + return new Size (DEFAULT_PREFERRED_THICKNESS, height); + } + + /// + protected override void OnKeyDown (KeyEventArgs e) + { + switch (e.KeyCode) { + case Keys.Left: + if (Orientation == Orientation.Horizontal) { + ChangeValueBy (-SmallChange); + e.Handled = true; + return; + } + + break; + + case Keys.Right: + if (Orientation == Orientation.Horizontal) { + ChangeValueBy (SmallChange); + e.Handled = true; + return; + } + + break; + + case Keys.Up: + if (Orientation == Orientation.Vertical) { + ChangeValueBy (SmallChange); + e.Handled = true; + return; + } + + break; + + case Keys.Down: + if (Orientation == Orientation.Vertical) { + ChangeValueBy (-SmallChange); + e.Handled = true; + return; + } + + break; + + case Keys.PageUp: + ChangeValueBy (LargeChange); + e.Handled = true; + return; + + case Keys.PageDown: + ChangeValueBy (-LargeChange); + e.Handled = true; + return; + + case Keys.Home: + SetValueCore (Minimum, raiseScroll: true); + e.Handled = true; + return; + + case Keys.End: + SetValueCore (Maximum, raiseScroll: true); + e.Handled = true; + return; + } + + base.OnKeyDown (e); + } + + /// + protected override void OnMouseDown (MouseEventArgs e) + { + base.OnMouseDown (e); + + if (!Enabled || !e.Button.HasFlag (MouseButtons.Left)) + return; + + Select (); + + var thumb_bounds = GetThumbBounds (); + + if (thumb_bounds.Contains (e.Location)) { + thumb_pressed = true; + + drag_offset_from_thumb_origin = Orientation == Orientation.Horizontal + ? Math.Max (0, Math.Min (thumb_bounds.Width, e.X - thumb_bounds.X)) + : Math.Max (0, Math.Min (thumb_bounds.Height, e.Y - thumb_bounds.Y)); + + Invalidate (); + return; + } + + SetValueCore (PositionToValue (e.Location), raiseScroll: true); + } + + /// + protected override void OnMouseLeave (EventArgs e) + { + base.OnMouseLeave (e); + + if (thumb_hovered) { + thumb_hovered = false; + Invalidate (); + } + } + + /// + protected override void OnMouseMove (MouseEventArgs e) + { + base.OnMouseMove (e); + + var renderer = GetRenderer (); + var thumb_bounds = renderer.GetThumbBounds (this); + + var new_hover_state = thumb_bounds.Contains (e.Location); + + if (thumb_hovered != new_hover_state) { + thumb_hovered = new_hover_state; + Invalidate (); + } + + if (!thumb_pressed) + return; + + var new_value = renderer.PositionToValueFromThumb (this, e.Location, drag_offset_from_thumb_origin); + SetValueCore (new_value, raiseScroll: true); + } + + /// + protected override void OnMouseUp (MouseEventArgs e) + { + base.OnMouseUp (e); + + if (thumb_pressed) { + thumb_pressed = false; + Invalidate (); + } + } + + /// + protected override void OnMouseWheel (MouseEventArgs e) + { + base.OnMouseWheel (e); + + if (!Enabled) + return; + + if (e.Delta.Y > 0) + ChangeValueBy (SmallChange); + else if (e.Delta.Y < 0) + ChangeValueBy (-SmallChange); + } + + /// + protected override void OnPaint (PaintEventArgs e) + { + base.OnPaint (e); + RenderManager.Render (this, e); + } + + /// + protected override void OnSizeChanged (EventArgs e) + { + base.OnSizeChanged (e); + Invalidate (); + } + + /// + protected override void SetBoundsCore (int x, int y, int width, int height, BoundsSpecified specified) + { + if (AutoSize) { + if (Orientation == Orientation.Horizontal) + height = DEFAULT_PREFERRED_THICKNESS; + else + width = DEFAULT_PREFERRED_THICKNESS; + } + + base.SetBoundsCore (x, y, width, height, specified); + } + + /// + /// Raises the event. + /// + /// The event data. + protected virtual void OnScroll (EventArgs e) => Scroll?.Invoke (this, e); + + /// + /// Raises the event. + /// + /// The event data. + protected virtual void OnValueChanged (EventArgs e) => ValueChanged?.Invoke (this, e); + + private void SetValueCore (int value, bool raiseScroll) + { + value = Clamp (value); + value = SnapValueToTick (value); + + if (current_value == value) + return; + + current_value = value; + + if (raiseScroll) + OnScroll (EventArgs.Empty); + + OnValueChanged (EventArgs.Empty); + Invalidate (); + } + } +} From a50ab233d83f405a334aea9bd0cb4b4934edfd12 Mon Sep 17 00:00:00 2001 From: Jonathan Pobst Date: Sat, 4 Apr 2026 12:58:57 -1000 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- samples/ControlGallery/MainForm.cs | 2 +- src/Modern.Forms/TrackBar.cs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/samples/ControlGallery/MainForm.cs b/samples/ControlGallery/MainForm.cs index 9bef835b..9c4696ff 100644 --- a/samples/ControlGallery/MainForm.cs +++ b/samples/ControlGallery/MainForm.cs @@ -49,7 +49,7 @@ public MainForm () tree.Items.Add ("TextBox", ImageLoader.Get ("button.png")); tree.Items.Add ("TitleBar", ImageLoader.Get ("button.png")); tree.Items.Add ("ToolBar", ImageLoader.Get ("button.png")); - tree.Items.Add("TrackBar", ImageLoader.Get("button.png")); + tree.Items.Add ("TrackBar", ImageLoader.Get ("button.png")); tree.Items.Add ("TreeView", ImageLoader.Get ("button.png")); tree.ItemSelected += Tree_ItemSelected; diff --git a/src/Modern.Forms/TrackBar.cs b/src/Modern.Forms/TrackBar.cs index 7c8c09e7..d5f4f785 100644 --- a/src/Modern.Forms/TrackBar.cs +++ b/src/Modern.Forms/TrackBar.cs @@ -340,7 +340,7 @@ private int SnapValueToTick (int value) return maximum; var relative = value - minimum; - var snapped_relative = (int)Math.Round (relative / (double)tick_frequency) * tick_frequency; + var snapped_relative = (int)Math.Round (relative / (double)tick_frequency, MidpointRounding.AwayFromZero) * tick_frequency; var snapped = minimum + snapped_relative; return Clamp (snapped); @@ -502,10 +502,10 @@ protected override void OnMouseWheel (MouseEventArgs e) if (!Enabled) return; - if (e.Delta.Y > 0) - ChangeValueBy (SmallChange); - else if (e.Delta.Y < 0) - ChangeValueBy (-SmallChange); + var wheel_change = (e.Delta.Y * SmallChange) / 120; + + if (wheel_change != 0) + ChangeValueBy (wheel_change); } /// From b2cca8c2c6e34be89317b4e4333dc7a68a4360b8 Mon Sep 17 00:00:00 2001 From: Jonathan Pobst Date: Sat, 4 Apr 2026 13:33:22 -1000 Subject: [PATCH 3/4] Reverse Copilot changes. --- src/Modern.Forms/TrackBar.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Modern.Forms/TrackBar.cs b/src/Modern.Forms/TrackBar.cs index d5f4f785..7c8c09e7 100644 --- a/src/Modern.Forms/TrackBar.cs +++ b/src/Modern.Forms/TrackBar.cs @@ -340,7 +340,7 @@ private int SnapValueToTick (int value) return maximum; var relative = value - minimum; - var snapped_relative = (int)Math.Round (relative / (double)tick_frequency, MidpointRounding.AwayFromZero) * tick_frequency; + var snapped_relative = (int)Math.Round (relative / (double)tick_frequency) * tick_frequency; var snapped = minimum + snapped_relative; return Clamp (snapped); @@ -502,10 +502,10 @@ protected override void OnMouseWheel (MouseEventArgs e) if (!Enabled) return; - var wheel_change = (e.Delta.Y * SmallChange) / 120; - - if (wheel_change != 0) - ChangeValueBy (wheel_change); + if (e.Delta.Y > 0) + ChangeValueBy (SmallChange); + else if (e.Delta.Y < 0) + ChangeValueBy (-SmallChange); } /// From 897e65d29bc7447c359885fa6ed250381c090913 Mon Sep 17 00:00:00 2001 From: Kiko kiko Date: Sun, 5 Apr 2026 12:28:18 +0200 Subject: [PATCH 4/4] Fix SnapToTicks behavior for keyboard and mouse input --- src/Modern.Forms/TrackBar.cs | 44 ++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/Modern.Forms/TrackBar.cs b/src/Modern.Forms/TrackBar.cs index 7c8c09e7..eb61817c 100644 --- a/src/Modern.Forms/TrackBar.cs +++ b/src/Modern.Forms/TrackBar.cs @@ -318,13 +318,53 @@ private void AdjustAutoSizeDimension () private void ChangeValueBy (int delta) { - var new_value = Clamp (current_value + delta); - SetValueCore (new_value, raiseScroll: true); + if (delta == 0) + return; + + if (SnapToTicks && tick_frequency > 0) { + var direction = Math.Sign (delta); + var nextTickValue = GetNextTickValue (current_value, direction); + SetValueCore (nextTickValue, raiseScroll: true); + return; + } + + var newValue = Clamp (current_value + delta); + SetValueCore (newValue, raiseScroll: true); } private Rectangle GetThumbBounds () => GetRenderer ().GetThumbBounds (this); + private int GetNextTickValue (int value, int direction) + { + if (tick_frequency <= 0 || maximum <= minimum || direction == 0) + return Clamp (value); + + if (direction > 0) { + if (value >= maximum) + return maximum; + + var relative = value - minimum; + var remainder = relative % tick_frequency; + + if (remainder == 0) + return Clamp (value + tick_frequency); + + return Clamp (value + (tick_frequency - remainder)); + } + + if (value <= minimum) + return minimum; + + var relativeDown = value - minimum; + var remainderDown = relativeDown % tick_frequency; + + if (remainderDown == 0) + return Clamp (value - tick_frequency); + + return Clamp (value - remainderDown); + } + private int PositionToValue (Point location) => GetRenderer ().PositionToValue (this, location);