diff --git a/samples/ControlGallery/ControlGallery.csproj b/samples/ControlGallery/ControlGallery.csproj
index 7be0ed2..b65494a 100644
--- a/samples/ControlGallery/ControlGallery.csproj
+++ b/samples/ControlGallery/ControlGallery.csproj
@@ -2,7 +2,7 @@
WinExe
- $(_TargetFramework)
+ net10.0
enable
diff --git a/samples/ControlGallery/MainForm.cs b/samples/ControlGallery/MainForm.cs
index 5e88d0d..cb14fe8 100644
--- a/samples/ControlGallery/MainForm.cs
+++ b/samples/ControlGallery/MainForm.cs
@@ -14,11 +14,11 @@ public class MainForm : Form
public MainForm ()
{
+ Theme.UIFont = SKTypeface.FromFamilyName ("Segoe UI", SKFontStyleWeight.Normal, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright);
tree = new TreeView {
Dock = DockStyle.Left,
ShowDropdownGlyph = false
};
-
tree.Style.Border.Width = 0;
tree.Style.Border.Right.Width = 1;
@@ -53,12 +53,15 @@ public MainForm ()
tree.Items.Add ("TitleBar", ImageLoader.Get ("button.png"));
tree.Items.Add ("ToolBar", ImageLoader.Get ("button.png"));
tree.Items.Add ("TreeView", ImageLoader.Get ("button.png"));
+ tree.Items.Add("ColorDialog", ImageLoader.Get("button.png"));
+ tree.Items.Add("DateTimePicker" , ImageLoader.Get("button.png"));
tree.ItemSelected += Tree_ItemSelected;
Controls.Add (tree);
Text = "Control Gallery";
Image = ImageLoader.Get ("button.png");
+ this.TitleBar.AllowDoubleClickMaximize = true;
}
private void Tree_ItemSelected (object? sender, EventArgs e)
@@ -145,6 +148,10 @@ private void Tree_ItemSelected (object? sender, EventArgs e)
return new ToolBarPanel ();
case "TreeView":
return new TreeViewPanel ();
+ case "ColorDialog":
+ return new ColorDialogPanel();
+ case "DateTimePicker":
+ return new DateTimePickerPanel();
}
return null;
diff --git a/samples/ControlGallery/Panels/ColorDialogPanel.cs b/samples/ControlGallery/Panels/ColorDialogPanel.cs
new file mode 100644
index 0000000..ddf1b89
--- /dev/null
+++ b/samples/ControlGallery/Panels/ColorDialogPanel.cs
@@ -0,0 +1,46 @@
+using Modern.Forms;
+using SkiaSharp;
+
+namespace ControlGallery.Panels
+{
+ public class ColorDialogPanel : Panel
+ {
+ public ColorDialogPanel ()
+ {
+ var button = Controls.Add (new Button {
+ Text = "Show Color Dialog",
+ AutoSize = true,
+ });
+
+ var color_panel = Controls.Add (new Panel {
+ Width = 100,
+ Height = 100
+ });
+ color_panel.Style.BackgroundColor = SKColors.DarkGray;
+
+ var label = Controls.Add (new Label {
+ AutoSize = true,
+ Width = 200,
+ Height = 100
+ });
+
+ button.Click += async (s, e) => {
+ var dlg = new ColorDialog ();
+ var result = await dlg.ShowDialog (this.FindForm());
+
+ if (result == DialogResult.OK) {
+ color_panel.Style.BackgroundColor = dlg.Color;
+ label.Text = $"Selected Color: R={dlg.Color.Red}, G={dlg.Color.Green}, B={dlg.Color.Blue}";
+ }
+ };
+
+ button.Dock = DockStyle.Top;
+ color_panel.Dock = DockStyle.Top;
+ label.Dock = DockStyle.Top;
+
+ Controls.Add (button);
+ Controls.Add (color_panel);
+ Controls.Add (label);
+ }
+ }
+}
diff --git a/samples/ControlGallery/Panels/DateTimePickerPanel.cs b/samples/ControlGallery/Panels/DateTimePickerPanel.cs
new file mode 100644
index 0000000..f0dd201
--- /dev/null
+++ b/samples/ControlGallery/Panels/DateTimePickerPanel.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Modern.Forms;
+
+namespace ControlGallery.Panels
+{
+ public class DateTimePickerPanel : Panel
+ {
+ public DateTimePickerPanel ()
+ {
+ Controls.Add (new Label { Text = "DateTimePicker", Left = 10, Top = 10, Width = 200 });
+ var dtp1 = Controls.Add (new DateTimePicker { Left = 10, Top = 35 , AutoSize = true});
+ dtp1.ValueChanged += (o, e) => Console.WriteLine ($"Value changed: {dtp1.Value}");
+ dtp1.Format = DateTimePickerFormat.Long;
+ Controls.Add (new Label { Text = "Disabled", Left = 10, Top = 70, Width = 200 });
+ var disabled = Controls.Add (new DateTimePicker { Left = 10, Top = 95, Enabled = false });
+ disabled.Value = new DateTime (2024, 6, 15);
+ }
+ }
+}
diff --git a/samples/Explorer/Explore.csproj b/samples/Explorer/Explore.csproj
index 35785e2..d3306ba 100644
--- a/samples/Explorer/Explore.csproj
+++ b/samples/Explorer/Explore.csproj
@@ -1,7 +1,7 @@
- $(_TargetFramework)
+ net10.0
WinExe
diff --git a/samples/Outlaw/Outlaw.csproj b/samples/Outlaw/Outlaw.csproj
index 97d0267..7ad608a 100644
--- a/samples/Outlaw/Outlaw.csproj
+++ b/samples/Outlaw/Outlaw.csproj
@@ -2,7 +2,7 @@
WinExe
- $(_TargetFramework)
+ net10.0
enable
enable
diff --git a/src/Modern.Forms/ColorBox.cs b/src/Modern.Forms/ColorBox.cs
new file mode 100644
index 0000000..f675956
--- /dev/null
+++ b/src/Modern.Forms/ColorBox.cs
@@ -0,0 +1,144 @@
+using System;
+using System.Drawing;
+using Modern.Forms.Renderers;
+
+namespace Modern.Forms
+{
+ ///
+ /// HSV Saturation/Value selection box.
+ ///
+ public class ColorBox : Control
+ {
+ private bool isDragging;
+ private float hue;
+ private float saturation = 1f;
+ private float value = 1f;
+
+ public ColorBox ()
+ {
+ SetControlBehavior (ControlBehaviors.Selectable, false);
+ SetControlBehavior (ControlBehaviors.Hoverable);
+ Cursor = Cursors.Cross;
+ }
+
+ public new static ControlStyle DefaultStyle = new ControlStyle (Control.DefaultStyle,
+ style => {
+ style.Border.Width = 1;
+ style.BackgroundColor = Theme.ControlLowColor;
+ });
+
+ public override ControlStyle Style { get; } = new ControlStyle (DefaultStyle);
+
+ protected override Size DefaultSize => new Size (260, 260);
+
+ public float Hue {
+ get => hue;
+ set {
+ float normalized = ColorHelper.NormalizeHue (value);
+ if (Math.Abs (hue - normalized) > float.Epsilon) {
+ hue = normalized;
+ Invalidate ();
+ }
+ }
+ }
+
+ public float Saturation {
+ get => saturation;
+ set {
+ float clamped = ColorHelper.Clamp01 (value);
+ if (Math.Abs (saturation - clamped) > float.Epsilon) {
+ saturation = clamped;
+ OnColorChanged (EventArgs.Empty);
+ Invalidate ();
+ }
+ }
+ }
+
+ public float Value {
+ get => this.value;
+ set {
+ float clamped = ColorHelper.Clamp01 (value);
+ if (Math.Abs (this.value - clamped) > float.Epsilon) {
+ this.value = clamped;
+ OnColorChanged (EventArgs.Empty);
+ Invalidate ();
+ }
+ }
+ }
+
+ public event EventHandler? ColorChanged;
+
+ public void SetColorComponents (float hue, float saturation, float value)
+ {
+ this.hue = ColorHelper.NormalizeHue (hue);
+ this.saturation = ColorHelper.Clamp01 (saturation);
+ this.value = ColorHelper.Clamp01 (value);
+
+ Invalidate ();
+ }
+
+ protected virtual void OnColorChanged (EventArgs e)
+ => ColorChanged?.Invoke (this, e);
+
+ protected override void OnMouseDown (MouseEventArgs e)
+ {
+ base.OnMouseDown (e);
+
+ if ((e.Button & MouseButtons.Left) == 0)
+ return;
+
+ isDragging = true;
+ UpdateFromPoint (e.Location);
+ }
+
+ protected override void OnMouseMove (MouseEventArgs e)
+ {
+ base.OnMouseMove (e);
+
+ if (!isDragging)
+ return;
+
+ UpdateFromPoint (e.Location);
+ }
+
+ protected override void OnMouseUp (MouseEventArgs e)
+ {
+ base.OnMouseUp (e);
+ isDragging = false;
+ }
+
+ protected override void OnPaint (PaintEventArgs e)
+ {
+ base.OnPaint (e);
+ RenderManager.Render (this, e);
+ }
+
+ private void UpdateFromPoint (Point location)
+ {
+ var renderer = RenderManager.GetRenderer ();
+ if (renderer is null)
+ return;
+
+ var content = renderer.GetContentBounds (this, null);
+
+ if (content.Width <= 1 || content.Height <= 1)
+ return;
+
+ float s = (location.X - content.Left) / (float)Math.Max (1, content.Width - 1);
+ float v = 1f - ((location.Y - content.Top) / (float)Math.Max (1, content.Height - 1));
+
+ s = ColorHelper.Clamp01 (s);
+ v = ColorHelper.Clamp01 (v);
+
+ bool changed = Math.Abs (saturation - s) > float.Epsilon || Math.Abs (value - v) > float.Epsilon;
+
+ saturation = s;
+ value = v;
+
+ if (changed)
+ OnColorChanged (EventArgs.Empty);
+
+ Invalidate ();
+ }
+ }
+}
diff --git a/src/Modern.Forms/ColorDialog.cs b/src/Modern.Forms/ColorDialog.cs
new file mode 100644
index 0000000..1da1adc
--- /dev/null
+++ b/src/Modern.Forms/ColorDialog.cs
@@ -0,0 +1,27 @@
+using System.Threading.Tasks;
+using SkiaSharp;
+
+namespace Modern.Forms
+{
+ public class ColorDialog
+ {
+ private SKColor selectedColor = SKColors.White;
+
+ public SKColor Color {
+ get => selectedColor;
+ set => selectedColor = value;
+ }
+
+ public async Task ShowDialog (Form owner)
+ {
+ var form = new ColorDialogForm (selectedColor);
+
+ var result = await form.ShowDialog (owner);
+
+ if (result == DialogResult.OK)
+ selectedColor = form.SelectedColor;
+
+ return result;
+ }
+ }
+}
diff --git a/src/Modern.Forms/ColorDialogForm.cs b/src/Modern.Forms/ColorDialogForm.cs
new file mode 100644
index 0000000..a581e2b
--- /dev/null
+++ b/src/Modern.Forms/ColorDialogForm.cs
@@ -0,0 +1,295 @@
+using System;
+using System.Drawing;
+using SkiaSharp;
+
+namespace Modern.Forms
+{
+ internal class ColorDialogForm : Form
+ {
+ public SKColor SelectedColor { get; private set; }
+
+ private readonly SKColor originalColor;
+
+ private readonly ColorBox colorBox;
+ private readonly HueSlider hueSlider;
+
+ private readonly Panel oldPreview;
+ private readonly Panel newPreview;
+
+ private readonly Label argbValueLabel;
+ private readonly Label hexValueLabel;
+ private readonly Label hsvValueLabel;
+ private readonly Label hslValueLabel;
+
+ private readonly TrackBar aTrackBar;
+ private readonly TrackBar rTrackBar;
+ private readonly TrackBar gTrackBar;
+ private readonly TrackBar bTrackBar;
+
+ private readonly Button okButton;
+ private readonly Button cancelButton;
+
+ private bool isUpdating;
+ private float hue;
+ private float saturation;
+ private float value;
+
+ public string TextForm { get; set; } = "Select Color";
+ public string TextCurrent { get; set; } = "Current";
+ public string TextNew { get; set; } = "New";
+ public string TextValues { get; set; } = "Values";
+ public string TextButtonOk { get; set; } = "OK";
+ public string TextButtonCancel { get; set; } = "Cancel";
+
+ public ColorDialogForm (SKColor initialColor)
+ {
+ originalColor = initialColor;
+ SelectedColor = initialColor;
+ Text = TextForm;
+ Size = new Size (760, 520);
+ StartPosition = FormStartPosition.CenterParent;
+ Resizeable = false;
+ AllowMaximize = false;
+ AllowMinimize = false;
+
+ SelectedColor = initialColor;
+ ColorHelper.ToHsv (initialColor, out hue, out saturation, out value);
+
+ colorBox = new ColorBox {
+ Location = new Point (20, 40),
+ Size = new Size (320, 320)
+ };
+ colorBox.SetColorComponents (hue, saturation, value);
+
+ hueSlider = new HueSlider {
+ Location = new Point (350, 40),
+ Size = new Size (28, 320)
+ };
+ hueSlider.SetHueSilently (hue);
+
+ var rightColumnX = 400;
+
+ var oldLabel = CreateCaptionLabel (TextCurrent, rightColumnX, 40);
+ oldPreview = CreatePreviewPanel (rightColumnX, 62, initialColor);
+
+ var newLabel = CreateCaptionLabel (TextNew, rightColumnX + 90, 40);
+ newPreview = CreatePreviewPanel (rightColumnX + 90, 62, initialColor);
+
+ var valuesTitle = CreateCaptionLabel (TextValues, rightColumnX, 130);
+ valuesTitle.Width = 220;
+
+ var argbLabel = CreateCaptionLabel ("ARGB:", rightColumnX, 160);
+ argbValueLabel = CreateValueLabel (rightColumnX + 55, 160, 260);
+
+ var hexLabel = CreateCaptionLabel ("Hex:", rightColumnX, 188);
+ hexValueLabel = CreateValueLabel (rightColumnX + 55, 188, 260);
+
+ var hsvLabel = CreateCaptionLabel ("HSV:", rightColumnX, 216);
+ hsvValueLabel = CreateValueLabel (rightColumnX + 55, 216, 260);
+
+ var hslLabel = CreateCaptionLabel ("HSL:", rightColumnX, 244);
+ hslValueLabel = CreateValueLabel (rightColumnX + 55, 244, 260);
+
+ var slidersTop = 360;
+
+ aTrackBar = CreateChannelTrackBar (50, slidersTop);
+ rTrackBar = CreateChannelTrackBar (50, slidersTop + 52);
+ gTrackBar = CreateChannelTrackBar (50, slidersTop + 84);
+ bTrackBar = CreateChannelTrackBar (50, slidersTop + 116);
+
+ Controls.Add (CreateCaptionLabel ("A", 20, slidersTop + 26));
+ Controls.Add (CreateCaptionLabel ("R", 20, slidersTop + 58));
+ Controls.Add (CreateCaptionLabel ("G", 20, slidersTop + 90));
+ Controls.Add (CreateCaptionLabel ("B", 20, slidersTop + 122));
+
+ okButton = new Button {
+ Text = TextButtonOk,
+ Location = new Point (560, 460),
+ Size = new Size (80, 30)
+ };
+
+ cancelButton = new Button {
+ Text = TextButtonCancel,
+ Location = new Point (650, 460),
+ Size = new Size (80, 30)
+ };
+
+ okButton.Click += (s, e) => {
+ DialogResult = DialogResult.OK;
+ Close ();
+ };
+
+ cancelButton.Click += (s, e) => {
+ DialogResult = DialogResult.Cancel;
+ Close ();
+ };
+
+ colorBox.ColorChanged += ColorBox_ColorChanged;
+ hueSlider.HueChanged += HueSlider_HueChanged;
+
+ aTrackBar.ValueChanged += ArgbTrackBar_ValueChanged;
+ rTrackBar.ValueChanged += ArgbTrackBar_ValueChanged;
+ gTrackBar.ValueChanged += ArgbTrackBar_ValueChanged;
+ bTrackBar.ValueChanged += ArgbTrackBar_ValueChanged;
+
+ Controls.Add (colorBox);
+ Controls.Add (hueSlider);
+
+ Controls.Add (oldLabel);
+ Controls.Add (oldPreview);
+ Controls.Add (newLabel);
+ Controls.Add (newPreview);
+
+ Controls.Add (valuesTitle);
+ Controls.Add (argbLabel);
+ Controls.Add (argbValueLabel);
+ Controls.Add (hexLabel);
+ Controls.Add (hexValueLabel);
+ Controls.Add (hsvLabel);
+ Controls.Add (hsvValueLabel);
+ Controls.Add (hslLabel);
+ Controls.Add (hslValueLabel);
+
+ Controls.Add (aTrackBar);
+ Controls.Add (rTrackBar);
+ Controls.Add (gTrackBar);
+ Controls.Add (bTrackBar);
+
+ Controls.Add (okButton);
+ Controls.Add (cancelButton);
+
+ ApplyColorToUi (initialColor, updateOriginalPreview: true);
+ }
+
+ private void HueSlider_HueChanged (object? sender, EventArgs e)
+ {
+ if (isUpdating)
+ return;
+
+ hue = hueSlider.Hue;
+ var color = ColorHelper.FromHsv (hue, colorBox.Saturation, colorBox.Value, (byte)aTrackBar.Value);
+ ApplyColorToUi (color, updateOriginalPreview: false);
+ }
+
+ private void ColorBox_ColorChanged (object? sender, EventArgs e)
+ {
+ if (isUpdating)
+ return;
+
+ saturation = colorBox.Saturation;
+ value = colorBox.Value;
+
+ var color = ColorHelper.FromHsv (hueSlider.Hue, saturation, value, (byte)aTrackBar.Value);
+ ApplyColorToUi (color, updateOriginalPreview: false);
+ }
+
+ private void ArgbTrackBar_ValueChanged (object? sender, EventArgs e)
+ {
+ if (isUpdating)
+ return;
+
+ var color = new SKColor (
+ (byte)rTrackBar.Value,
+ (byte)gTrackBar.Value,
+ (byte)bTrackBar.Value,
+ (byte)aTrackBar.Value);
+
+ ApplyColorToUi (color, updateOriginalPreview: false);
+ }
+
+ private void ApplyColorToUi (SKColor color, bool updateOriginalPreview)
+ {
+ isUpdating = true;
+
+ try {
+ SelectedColor = color;
+
+ ColorHelper.ToHsv (color, out float newHue, out float newSaturation, out float newValue);
+
+
+ if (newSaturation > 0f && newValue > 0f)
+ hue = newHue;
+
+ saturation = newSaturation;
+ value = newValue;
+
+ colorBox.SetColorComponents (hue, saturation, value);
+ hueSlider.SetHueSilently (hue);
+
+ aTrackBar.Value = color.Alpha;
+ rTrackBar.Value = color.Red;
+ gTrackBar.Value = color.Green;
+ bTrackBar.Value = color.Blue;
+
+ if (updateOriginalPreview) {
+ oldPreview.Style.BackgroundColor = originalColor;
+ oldPreview.Invalidate ();
+ }
+
+ newPreview.Style.BackgroundColor = color;
+ newPreview.Invalidate ();
+
+ UpdateValueLabels (color);
+ } finally {
+ isUpdating = false;
+ }
+ }
+
+ private void UpdateValueLabels (SKColor color)
+ {
+ ColorHelper.ToHsv (color, out float h, out float s, out float v);
+ ColorHelper.ToHsl (color, out float h2, out float s2, out float l2);
+
+ argbValueLabel.Text = $"{color.Alpha}, {color.Red}, {color.Green}, {color.Blue}";
+ hexValueLabel.Text = ColorHelper.ToHex (color, includeAlpha: true);
+ hsvValueLabel.Text = $"{h:0.##}°, {s * 100f:0.#}%, {v * 100f:0.#}%";
+ hslValueLabel.Text = $"{h2:0.##}°, {s2 * 100f:0.#}%, {l2 * 100f:0.#}%";
+ }
+
+ private static Label CreateCaptionLabel (string text, int x, int y)
+ {
+ return new Label {
+ Text = text,
+ Location = new Point (x, y),
+ Size = new Size (60, 22)
+ };
+ }
+
+ private static Label CreateValueLabel (int x, int y, int width)
+ {
+ return new Label {
+ Location = new Point (x, y),
+ Size = new Size (width, 22)
+ };
+ }
+
+ private static Panel CreatePreviewPanel (int x, int y, SKColor color)
+ {
+ var panel = new Panel {
+ Location = new Point (x, y),
+ Size = new Size (72, 52)
+ };
+
+ panel.Style.Border.Width = 1;
+ panel.Style.Border.Color = Theme.BorderLowColor;
+ panel.Style.BackgroundColor = color;
+
+ return panel;
+ }
+
+ private static TrackBar CreateChannelTrackBar (int x, int y)
+ {
+ return new TrackBar {
+ Minimum = 0,
+ Maximum = 255,
+ TickFrequency = 16,
+ SmallChange = 1,
+ LargeChange = 8,
+ AutoSize = false,
+ Height = 28,
+ Width = 360,
+ Location = new Point (x, y)
+ };
+ }
+ }
+}
diff --git a/src/Modern.Forms/ColorHelper.cs b/src/Modern.Forms/ColorHelper.cs
new file mode 100644
index 0000000..e3e3505
--- /dev/null
+++ b/src/Modern.Forms/ColorHelper.cs
@@ -0,0 +1,139 @@
+using System;
+using SkiaSharp;
+
+namespace Modern.Forms
+{
+ internal static class ColorHelper
+ {
+ public static SKColor FromHsv (float h, float s, float v, byte a = 255)
+ {
+ h = NormalizeHue (h);
+ s = Clamp01 (s);
+ v = Clamp01 (v);
+
+ if (s <= 0f) {
+ byte gray = ToByte (v * 255f);
+ return new SKColor (gray, gray, gray, a);
+ }
+
+ float hh = h / 60f;
+ int sector = (int)MathF.Floor (hh);
+ float fraction = hh - sector;
+
+ float p = v * (1f - s);
+ float q = v * (1f - s * fraction);
+ float t = v * (1f - s * (1f - fraction));
+
+ float r, g, b;
+
+ switch (sector) {
+ case 0:
+ r = v; g = t; b = p;
+ break;
+ case 1:
+ r = q; g = v; b = p;
+ break;
+ case 2:
+ r = p; g = v; b = t;
+ break;
+ case 3:
+ r = p; g = q; b = v;
+ break;
+ case 4:
+ r = t; g = p; b = v;
+ break;
+ default:
+ r = v; g = p; b = q;
+ break;
+ }
+
+ return new SKColor (ToByte (r * 255f), ToByte (g * 255f), ToByte (b * 255f), a);
+ }
+
+ public static void ToHsv (SKColor color, out float h, out float s, out float v)
+ {
+ float r = color.Red / 255f;
+ float g = color.Green / 255f;
+ float b = color.Blue / 255f;
+
+ float max = MathF.Max (r, MathF.Max (g, b));
+ float min = MathF.Min (r, MathF.Min (g, b));
+ float delta = max - min;
+
+ v = max;
+ s = max <= 0f ? 0f : delta / max;
+
+ if (delta <= 0f) {
+ h = 0f;
+ return;
+ }
+
+ if (max == r)
+ h = 60f * (((g - b) / delta) % 6f);
+ else if (max == g)
+ h = 60f * (((b - r) / delta) + 2f);
+ else
+ h = 60f * (((r - g) / delta) + 4f);
+
+ if (h < 0f)
+ h += 360f;
+ }
+
+ public static void ToHsl (SKColor color, out float h, out float s, out float l)
+ {
+ float r = color.Red / 255f;
+ float g = color.Green / 255f;
+ float b = color.Blue / 255f;
+
+ float max = MathF.Max (r, MathF.Max (g, b));
+ float min = MathF.Min (r, MathF.Min (g, b));
+ float delta = max - min;
+
+ l = (max + min) / 2f;
+
+ if (delta <= 0f) {
+ h = 0f;
+ s = 0f;
+ return;
+ }
+
+ s = l > 0.5f
+ ? delta / (2f - max - min)
+ : delta / (max + min);
+
+ if (max == r)
+ h = 60f * (((g - b) / delta) % 6f);
+ else if (max == g)
+ h = 60f * (((b - r) / delta) + 2f);
+ else
+ h = 60f * (((r - g) / delta) + 4f);
+
+ if (h < 0f)
+ h += 360f;
+ }
+
+ public static string ToHex (SKColor color, bool includeAlpha = true)
+ {
+ return includeAlpha
+ ? $"#{color.Alpha:X2}{color.Red:X2}{color.Green:X2}{color.Blue:X2}"
+ : $"#{color.Red:X2}{color.Green:X2}{color.Blue:X2}";
+ }
+
+ public static float NormalizeHue (float hue)
+ {
+ while (hue < 0f)
+ hue += 360f;
+
+ while (hue >= 360f)
+ hue -= 360f;
+
+ return hue;
+ }
+
+ public static float Clamp01 (float value)
+ => MathF.Max (0f, MathF.Min (1f, value));
+
+ public static byte ToByte (float value)
+ => (byte)Math.Clamp ((int)MathF.Round (value), 0, 255);
+ }
+}
diff --git a/src/Modern.Forms/ControlPaint.cs b/src/Modern.Forms/ControlPaint.cs
index 59e6dce..e045c4c 100644
--- a/src/Modern.Forms/ControlPaint.cs
+++ b/src/Modern.Forms/ControlPaint.cs
@@ -107,6 +107,31 @@ public static void DrawMaximizeGlyph (PaintEventArgs e, Rectangle rectangle)
e.Canvas.DrawRectangle (rectangle, Theme.ForegroundColorOnAccent);
}
+
+ ///
+ /// Draws a restore glyph, as seen on FormTitleBar when the window is maximized.
+ ///
+ public static void DrawRestoreGlyph (PaintEventArgs e, Rectangle rectangle)
+ {
+ var offset = e.LogicalToDeviceUnits (2);
+
+ var back = new Rectangle (
+ rectangle.X + offset,
+ rectangle.Y,
+ rectangle.Width - offset,
+ rectangle.Height - offset);
+
+ var front = new Rectangle (
+ rectangle.X,
+ rectangle.Y + offset,
+ rectangle.Width - offset,
+ rectangle.Height - offset);
+
+ e.Canvas.DrawRectangle (back, Theme.ForegroundColorOnAccent);
+ e.Canvas.DrawRectangle (front, Theme.ForegroundColorOnAccent);
+ }
+
+
///
/// Draws a minimize glyph, as seen on FormTitleBar.
///
diff --git a/src/Modern.Forms/DateTimePicker.cs b/src/Modern.Forms/DateTimePicker.cs
new file mode 100644
index 0000000..4141b90
--- /dev/null
+++ b/src/Modern.Forms/DateTimePicker.cs
@@ -0,0 +1,792 @@
+using System;
+using System.ComponentModel;
+using System.Drawing;
+using System.Globalization;
+using Modern.Forms.Renderers;
+
+namespace Modern.Forms
+{
+ ///
+ /// Represents a control that allows the user to select a date and/or time value.
+ ///
+ ///
+ /// This implementation is designed for Modern.Forms and does not depend on the native
+ /// Win32 DateTimePicker control. The class contains the control state, input handling,
+ /// layout calculations, and public API, while all drawing logic is delegated to
+ /// .
+ ///
+ [DefaultProperty (nameof (Value))]
+ [DefaultEvent (nameof (ValueChanged))]
+ public class DateTimePicker : Control
+ {
+ private const int DefaultButtonWidth = 22;
+ private const int DefaultCheckBoxSize = 14;
+ private const int DefaultHorizontalPadding = 6;
+ private const int DefaultVerticalPadding = 4;
+
+ ///
+ /// Default minimum value compatible with common desktop scenarios.
+ ///
+ private static readonly DateTime s_minDateTime = new (1753, 1, 1);
+
+ ///
+ /// Default maximum value compatible with common desktop scenarios.
+ ///
+ private static readonly DateTime s_maxDateTime = new (9998, 12, 31);
+
+ private DateTime value = DateTime.Now;
+ private DateTime minDate = DateTime.MinValue;
+ private DateTime maxDate = DateTime.MaxValue;
+
+ private bool userHasSetValue;
+ private bool isChecked = true;
+ private bool showCheckBox;
+ private bool showUpDown;
+ private bool isDroppedDown;
+
+ private DateTimePickerFormat format = DateTimePickerFormat.Long;
+ private string? customFormat;
+
+ private Rectangle checkBoxRect;
+ private Rectangle textRect;
+ private Rectangle buttonRect;
+
+ private bool buttonPressed;
+ private bool hoveredButton;
+
+ private PopupWindow? popupWindow;
+ private DateTimePickerCalendar? popupCalendar;
+
+ ///
+ /// Gets the active popup window instance, if any.
+ ///
+ internal PopupWindow? PopupWindow => popupWindow;
+
+ ///
+ /// Applies a date selected from the popup calendar.
+ ///
+ /// The selected date.
+ internal void ApplyDropDownValue (DateTime selectedDate)
+ {
+ var current = Value;
+
+ var merged = new DateTime (
+ selectedDate.Year,
+ selectedDate.Month,
+ selectedDate.Day,
+ current.Hour,
+ current.Minute,
+ current.Second,
+ current.Millisecond);
+
+ Value = merged;
+ CloseDropDown ();
+ }
+
+ ///
+ /// Closes the active drop-down popup, if one exists.
+ ///
+ internal void CloseDropDown ()
+ {
+ if (popupWindow is not null) {
+ popupWindow.Hide ();
+ popupWindow.Dispose ();
+ popupWindow = null;
+ }
+
+ popupCalendar = null;
+
+ if (isDroppedDown) {
+ isDroppedDown = false;
+ OnCloseUp (EventArgs.Empty);
+ Invalidate (buttonRect);
+ }
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public DateTimePicker ()
+ {
+ SetControlBehavior (ControlBehaviors.Selectable, true);
+ SetControlBehavior (ControlBehaviors.Hoverable, true);
+ SetControlBehavior (ControlBehaviors.InvalidateOnTextChanged, false);
+
+ TabStop = true;
+ Size = DefaultSize;
+
+ UpdateLayoutRects ();
+ }
+
+ ///
+ /// Gets the minimum date supported by the current culture calendar and this control.
+ ///
+ public static DateTime MinimumDateTime {
+ get {
+ DateTime cultureMin = CultureInfo.CurrentCulture.Calendar.MinSupportedDateTime;
+ return cultureMin.Year < 1753 ? s_minDateTime : cultureMin;
+ }
+ }
+
+ ///
+ /// Gets the maximum date supported by the current culture calendar and this control.
+ ///
+ public static DateTime MaximumDateTime {
+ get {
+ DateTime cultureMax = CultureInfo.CurrentCulture.Calendar.MaxSupportedDateTime;
+ return cultureMax.Year > s_maxDateTime.Year ? s_maxDateTime : cultureMax;
+ }
+ }
+
+ ///
+ /// Gets the preferred control height.
+ ///
+ [Browsable (false)]
+ public int PreferredHeight => 32;
+
+ ///
+ /// Gets or sets the visual display format of the value.
+ ///
+ [DefaultValue (DateTimePickerFormat.Long)]
+ public DateTimePickerFormat Format {
+ get => format;
+ set {
+ if (format == value)
+ return;
+
+ format = value;
+ Invalidate ();
+
+ OnFormatChanged (EventArgs.Empty);
+ OnTextChanged (EventArgs.Empty);
+ }
+ }
+
+ ///
+ /// Gets or sets the custom format string used when is
+ /// .
+ ///
+ [DefaultValue (null)]
+ public string? CustomFormat {
+ get => customFormat;
+ set {
+ if (customFormat == value)
+ return;
+
+ customFormat = value;
+ Invalidate ();
+ OnTextChanged (EventArgs.Empty);
+ }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether a checkbox should be shown on the left side.
+ ///
+ ///
+ /// When enabled, the checkbox controls whether the date/time value is considered active.
+ /// If disabled, the control is always treated as checked.
+ ///
+ [DefaultValue (false)]
+ public bool ShowCheckBox {
+ get => showCheckBox;
+ set {
+ if (showCheckBox == value)
+ return;
+
+ showCheckBox = value;
+
+ // If the checkbox is hidden, the control must always be considered active.
+ if (!showCheckBox)
+ isChecked = true;
+
+ UpdateLayoutRects ();
+ Invalidate ();
+ }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether up/down buttons should be shown instead
+ /// of a drop-down calendar button.
+ ///
+ [DefaultValue (false)]
+ public bool ShowUpDown {
+ get => showUpDown;
+ set {
+ if (showUpDown == value)
+ return;
+
+ showUpDown = value;
+ Invalidate ();
+ }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the control is checked.
+ ///
+ ///
+ /// When is , this property always
+ /// behaves as .
+ ///
+ [DefaultValue (true)]
+ public bool Checked {
+ get => !ShowCheckBox || isChecked;
+ set {
+ bool newValue = !ShowCheckBox || value;
+
+ if (isChecked == newValue)
+ return;
+
+ isChecked = newValue;
+ Invalidate ();
+
+ OnValueChanged (EventArgs.Empty);
+ OnTextChanged (EventArgs.Empty);
+ }
+ }
+
+ ///
+ /// Gets or sets the minimum allowed date.
+ ///
+ ///
+ /// Thrown when the assigned value is outside the supported range or greater than .
+ ///
+ public DateTime MinDate {
+ get => EffectiveMinDate (minDate);
+ set {
+ if (value == minDate)
+ return;
+
+ if (value < MinimumDateTime)
+ throw new ArgumentOutOfRangeException (nameof (value), value, $"MinDate cannot be less than {MinimumDateTime:G}.");
+
+ if (value > EffectiveMaxDate (maxDate))
+ throw new ArgumentOutOfRangeException (nameof (value), value, "MinDate cannot be greater than MaxDate.");
+
+ minDate = value;
+
+ // Keep the current value inside the valid range.
+ if (Value < minDate)
+ Value = minDate;
+
+ Invalidate ();
+ }
+ }
+
+ ///
+ /// Gets or sets the maximum allowed date.
+ ///
+ ///
+ /// Thrown when the assigned value is outside the supported range or less than .
+ ///
+ public DateTime MaxDate {
+ get => EffectiveMaxDate (maxDate);
+ set {
+ if (value == maxDate)
+ return;
+
+ if (value > MaximumDateTime)
+ throw new ArgumentOutOfRangeException (nameof (value), value, $"MaxDate cannot be greater than {MaximumDateTime:G}.");
+
+ if (value < EffectiveMinDate (minDate))
+ throw new ArgumentOutOfRangeException (nameof (value), value, "MaxDate cannot be less than MinDate.");
+
+ maxDate = value;
+
+ // Keep the current value inside the valid range.
+ if (Value > maxDate)
+ Value = maxDate;
+
+ Invalidate ();
+ }
+ }
+
+ ///
+ /// Gets or sets the currently selected date/time value.
+ ///
+ ///
+ /// Thrown when the assigned value is outside the range defined by
+ /// and .
+ ///
+ public DateTime Value {
+ get => value;
+ set {
+ if (value < MinDate || value > MaxDate)
+ throw new ArgumentOutOfRangeException (nameof (value), value, $"Value must be between {MinDate:G} and {MaxDate:G}.");
+
+ bool changed = this.value != value || !userHasSetValue;
+
+ if (!changed)
+ return;
+
+ this.value = value;
+ userHasSetValue = true;
+
+ // Setting the value explicitly activates the control when a checkbox is shown.
+ if (ShowCheckBox)
+ isChecked = true;
+
+ Invalidate ();
+ OnValueChanged (EventArgs.Empty);
+ OnTextChanged (EventArgs.Empty);
+ }
+ }
+
+ ///
+ /// Occurs when changes.
+ ///
+ public event EventHandler? ValueChanged;
+
+ ///
+ /// Occurs when the drop-down part of the control is opened.
+ ///
+ public event EventHandler? DropDown;
+
+ ///
+ /// Occurs when the drop-down part of the control is closed.
+ ///
+ public event EventHandler? CloseUp;
+
+ ///
+ /// Occurs when changes.
+ ///
+ public event EventHandler? FormatChanged;
+
+ ///
+ /// Gets or sets the formatted text of the current value.
+ ///
+ ///
+ /// Reading this property returns the current display text.
+ /// Assigning a value attempts to parse it using the current culture.
+ ///
+ public override string Text {
+ get => GetDisplayText ();
+ set {
+ if (string.IsNullOrWhiteSpace (value)) {
+ ResetValue ();
+ return;
+ }
+
+ Value = DateTime.Parse (value, CultureInfo.CurrentCulture);
+ }
+ }
+
+ ///
+ /// Gets the default size of the control.
+ ///
+ protected override Size DefaultSize => new (200, 32);
+
+ ///
+ /// Gets the calculated rectangle of the checkbox area.
+ ///
+ internal Rectangle CheckBoxRectangle => checkBoxRect;
+
+ ///
+ /// Gets the calculated rectangle of the text area.
+ ///
+ internal Rectangle TextRectangle => textRect;
+
+ ///
+ /// Gets the calculated rectangle of the right-side button area.
+ ///
+ internal Rectangle ButtonRectangle => buttonRect;
+
+ ///
+ /// Gets a value indicating whether the right-side button is currently pressed.
+ ///
+ internal bool IsButtonPressed => buttonPressed;
+
+ ///
+ /// Gets a value indicating whether the mouse is hovering the right-side button.
+ ///
+ internal bool IsButtonHovered => hoveredButton;
+
+ ///
+ /// Gets a value indicating whether the drop-down is currently open.
+ ///
+ internal bool IsDropDownOpen => isDroppedDown;
+
+ ///
+ /// Gets the display text that should be rendered.
+ ///
+ internal string DisplayText => Checked ? GetDisplayText () : string.Empty;
+
+ ///
+ /// Handles control resize and recalculates internal layout rectangles.
+ ///
+ /// The event data.
+ protected override void OnResize (EventArgs e)
+ {
+ base.OnResize (e);
+ UpdateLayoutRects ();
+ }
+
+ ///
+ /// Updates hover state for the right-side button.
+ ///
+ /// The mouse event data.
+ protected override void OnMouseMove (MouseEventArgs e)
+ {
+ base.OnMouseMove (e);
+
+ bool hover = buttonRect.Contains (e.Location);
+ if (hoveredButton != hover) {
+ hoveredButton = hover;
+ Invalidate (buttonRect);
+ }
+ }
+
+ ///
+ /// Clears hover state when the mouse leaves the control.
+ ///
+ /// The event data.
+ protected override void OnMouseLeave (EventArgs e)
+ {
+ base.OnMouseLeave (e);
+
+ if (hoveredButton) {
+ hoveredButton = false;
+ Invalidate (buttonRect);
+ }
+ }
+
+ ///
+ /// Handles mouse press interaction for the checkbox and right-side button.
+ ///
+ /// The mouse event data.
+ protected override void OnMouseDown (MouseEventArgs e)
+ {
+ base.OnMouseDown (e);
+
+ Select ();
+
+ if (ShowCheckBox && checkBoxRect.Contains (e.Location)) {
+ isChecked = !isChecked;
+ Invalidate ();
+
+ OnValueChanged (EventArgs.Empty);
+ OnTextChanged (EventArgs.Empty);
+ return;
+ }
+
+ if (buttonRect.Contains (e.Location)) {
+ buttonPressed = true;
+ Invalidate (buttonRect);
+ }
+ }
+
+ ///
+ /// Handles mouse release interaction for the right-side button.
+ ///
+ /// The mouse event data.
+ protected override void OnMouseUp (MouseEventArgs e)
+ {
+ base.OnMouseUp (e);
+
+ bool wasPressed = buttonPressed;
+ buttonPressed = false;
+
+ if (wasPressed)
+ Invalidate (buttonRect);
+
+ if (buttonRect.Contains (e.Location)) {
+ if (ShowUpDown)
+ HandleUpDownClick (e.Location);
+ else
+ ToggleDropDown ();
+ }
+ }
+
+ ///
+ /// Handles mouse wheel input to increment or decrement the current value.
+ ///
+ /// The mouse event data.
+ protected override void OnMouseWheel (MouseEventArgs e)
+ {
+ base.OnMouseWheel (e);
+
+ if (!Enabled || (!Checked && ShowCheckBox))
+ return;
+
+ if (e.Delta.Y > 0)
+ StepValue (+1);
+ else if (e.Delta.Y < 0)
+ StepValue (-1);
+ }
+
+ ///
+ /// Handles keyboard input for value stepping, drop-down toggling, and checkbox toggling.
+ ///
+ /// The key event data.
+ protected override void OnKeyDown (KeyEventArgs e)
+ {
+ base.OnKeyDown (e);
+
+ if (!Checked && ShowCheckBox)
+ return;
+
+ switch (e.KeyCode) {
+ case Keys.Up:
+ StepValue (+1);
+ e.Handled = true;
+ break;
+
+ case Keys.Down:
+ StepValue (-1);
+ e.Handled = true;
+ break;
+
+ case Keys.F4:
+ case Keys.Return:
+ if (!ShowUpDown) {
+ ToggleDropDown ();
+ e.Handled = true;
+ }
+ break;
+
+ case Keys.Space:
+ if (ShowCheckBox) {
+ Checked = !Checked;
+ e.Handled = true;
+ }
+ break;
+ }
+ }
+
+ ///
+ /// Paints the control using the renderer registered in .
+ ///
+ /// The paint event data.
+ protected override void OnPaint (PaintEventArgs e)
+ {
+ base.OnPaint (e);
+ RenderManager.Render (this, e);
+ }
+
+ ///
+ /// Raises the event.
+ ///
+ /// The event data.
+ protected virtual void OnValueChanged (EventArgs e) => ValueChanged?.Invoke (this, e);
+
+ ///
+ /// Raises the event.
+ ///
+ /// The event data.
+ protected virtual void OnDropDown (EventArgs e) => DropDown?.Invoke (this, e);
+
+ ///
+ /// Raises the event.
+ ///
+ /// The event data.
+ protected virtual void OnCloseUp (EventArgs e) => CloseUp?.Invoke (this, e);
+
+ ///
+ /// Raises the event.
+ ///
+ /// The event data.
+ protected virtual void OnFormatChanged (EventArgs e) => FormatChanged?.Invoke (this, e);
+
+ ///
+ /// Returns the effective minimum date after applying global supported limits.
+ ///
+ /// The requested minimum date.
+ /// The effective minimum date.
+ private static DateTime EffectiveMinDate (DateTime minDate)
+ {
+ DateTime minSupported = MinimumDateTime;
+ return minDate < minSupported ? minSupported : minDate;
+ }
+
+ ///
+ /// Returns the effective maximum date after applying global supported limits.
+ ///
+ /// The requested maximum date.
+ /// The effective maximum date.
+ private static DateTime EffectiveMaxDate (DateTime maxDate)
+ {
+ DateTime maxSupported = MaximumDateTime;
+ return maxDate > maxSupported ? maxSupported : maxDate;
+ }
+
+ ///
+ /// Formats the current value as display text according to the selected format.
+ ///
+ /// A formatted date/time string.
+ private string GetDisplayText ()
+ {
+ DateTime displayValue = userHasSetValue ? value : DateTime.Now;
+
+ return Format switch {
+ DateTimePickerFormat.Long => displayValue.ToString ("D", CultureInfo.CurrentCulture),
+ DateTimePickerFormat.Short => displayValue.ToString ("d", CultureInfo.CurrentCulture),
+ DateTimePickerFormat.Time => displayValue.ToString ("t", CultureInfo.CurrentCulture),
+ DateTimePickerFormat.Custom => displayValue.ToString (
+ string.IsNullOrWhiteSpace (CustomFormat) ? "G" : CustomFormat,
+ CultureInfo.CurrentCulture),
+ _ => displayValue.ToString (CultureInfo.CurrentCulture)
+ };
+ }
+
+ ///
+ /// Resets the internal value state to its default representation.
+ ///
+ ///
+ /// The stored value becomes , but the control is marked as
+ /// not explicitly set. If the checkbox is visible, the control becomes unchecked.
+ ///
+ private void ResetValue ()
+ {
+ value = DateTime.Now;
+ userHasSetValue = false;
+ isChecked = !ShowCheckBox;
+
+ Invalidate ();
+ OnValueChanged (EventArgs.Empty);
+ OnTextChanged (EventArgs.Empty);
+ }
+
+ ///
+ /// Updates the internal rectangles used for hit-testing and rendering.
+ ///
+ private void UpdateLayoutRects ()
+ {
+ int x = 1;
+ int y = 1;
+ int height = Math.Max (0, Height - 2);
+
+ if (ShowCheckBox) {
+ int checkBoxY = (Height - DefaultCheckBoxSize) / 2;
+ checkBoxRect = new Rectangle (x + 6, checkBoxY, DefaultCheckBoxSize, DefaultCheckBoxSize);
+ x = checkBoxRect.Right + 6;
+ } else {
+ checkBoxRect = Rectangle.Empty;
+ }
+
+ buttonRect = new Rectangle (Width - DefaultButtonWidth - 1, y, DefaultButtonWidth, height);
+
+ textRect = new Rectangle (
+ x + DefaultHorizontalPadding,
+ y + DefaultVerticalPadding,
+ Math.Max (0, buttonRect.Left - x - (DefaultHorizontalPadding * 2)),
+ Math.Max (0, height - (DefaultVerticalPadding * 2)));
+ }
+
+ ///
+ /// Opens or closes the drop-down part of the control.
+ ///
+ ///
+ /// This is currently a placeholder. A future implementation can attach a real calendar popup here.
+ ///
+ ///
+ /// Opens or closes the drop-down calendar.
+ ///
+ private void ToggleDropDown ()
+ {
+ if (ShowUpDown)
+ return;
+
+ if (isDroppedDown) {
+ CloseDropDown ();
+ return;
+ }
+
+ var hostForm = FindForm ();
+ if (hostForm is null)
+ return;
+
+ popupWindow = new PopupWindow (hostForm) {
+ Size = new Size (232, 268)
+ };
+
+ popupCalendar = new DateTimePickerCalendar (this) {
+ Dock = DockStyle.Fill,
+ Value = Value,
+ MinDate = MinDate,
+ MaxDate = MaxDate
+ };
+
+ popupWindow.Controls.Add(popupCalendar);
+
+ isDroppedDown = true;
+ OnDropDown (EventArgs.Empty);
+ Invalidate (buttonRect);
+
+ popupWindow.Show (this, 0, Height);
+ }
+
+ ///
+ /// Handles visibility changes.
+ ///
+ /// The event data.
+ protected override void OnVisibleChanged (EventArgs e)
+ {
+ if (!Visible)
+ CloseDropDown ();
+
+ base.OnVisibleChanged (e);
+ }
+
+ ///
+ /// Handles a mouse click in the up/down button area.
+ ///
+ /// The mouse location.
+ private void HandleUpDownClick (Point location)
+ {
+ int mid = buttonRect.Top + buttonRect.Height / 2;
+
+ if (location.Y < mid)
+ StepValue (+1);
+ else
+ StepValue (-1);
+ }
+
+ ///
+ /// Adjusts the current value according to the active display format.
+ ///
+ /// The step amount. Positive values increment, negative values decrement.
+ private void StepValue (int delta)
+ {
+ DateTime newValue = Format switch {
+ DateTimePickerFormat.Time => Value.AddMinutes (delta),
+ DateTimePickerFormat.Short => Value.AddDays (delta),
+ DateTimePickerFormat.Long => Value.AddDays (delta),
+ DateTimePickerFormat.Custom => StepCustom (delta),
+ _ => Value.AddDays (delta)
+ };
+
+ if (newValue < MinDate)
+ newValue = MinDate;
+
+ if (newValue > MaxDate)
+ newValue = MaxDate;
+
+ Value = newValue;
+ }
+
+ ///
+ /// Adjusts the current value when is .
+ ///
+ /// The step amount. Positive values increment, negative values decrement.
+ /// The adjusted date/time value.
+ ///
+ /// This method uses a simple heuristic based on the custom format string:
+ /// time tokens adjust minutes, month tokens adjust months, year tokens adjust years,
+ /// otherwise days are adjusted.
+ ///
+ private DateTime StepCustom (int delta)
+ {
+ string format = CustomFormat ?? string.Empty;
+
+ if (format.Contains ("H") || format.Contains ("h") || format.Contains ("m") || format.Contains ("s"))
+ return Value.AddMinutes (delta);
+
+ if (format.Contains ("M"))
+ return Value.AddMonths (delta);
+
+ if (format.Contains ("y"))
+ return Value.AddYears (delta);
+
+ return Value.AddDays (delta);
+ }
+ }
+}
diff --git a/src/Modern.Forms/DateTimePickerCalendar.cs b/src/Modern.Forms/DateTimePickerCalendar.cs
new file mode 100644
index 0000000..2baabd5
--- /dev/null
+++ b/src/Modern.Forms/DateTimePickerCalendar.cs
@@ -0,0 +1,423 @@
+using System;
+using System.ComponentModel;
+using System.Drawing;
+using System.Globalization;
+using Modern.Forms.Renderers;
+
+namespace Modern.Forms
+{
+ ///
+ /// Represents the calendar surface displayed inside the DateTimePicker popup.
+ ///
+ internal class DateTimePickerCalendar : Control
+ {
+ private const int HeaderHeight = 30;
+ private const int DayHeaderHeight = 22;
+ private const int CellWidth = 30;
+ private const int CellHeight = 28;
+ private const int PaddingSize = 8;
+ private const int WeekRows = 6;
+ private const int FooterHeight = 28;
+
+ private readonly DateTimePicker owner;
+
+ private Rectangle prevButtonRect;
+ private Rectangle nextButtonRect;
+ private Rectangle monthTitleRect;
+ private Rectangle yearTitleRect;
+ private Rectangle todayButtonRect;
+
+ private Rectangle[] dayCellRects = Array.Empty ();
+ private Rectangle[] monthCellRects = Array.Empty ();
+ private Rectangle[] yearCellRects = Array.Empty ();
+
+ private Rectangle hoveredRect = Rectangle.Empty;
+
+ private DateTime value = DateTime.Today;
+ private DateTime displayMonth = new (DateTime.Today.Year, DateTime.Today.Month, 1);
+ private DateTime minDate = DateTimePicker.MinimumDateTime;
+ private DateTime maxDate = DateTimePicker.MaximumDateTime;
+
+ private DateTimePickerCalendarViewMode viewMode = DateTimePickerCalendarViewMode.Days;
+ private int yearRangeStart;
+
+ public DateTimePickerCalendar (DateTimePicker owner)
+ {
+ this.owner = owner ?? throw new ArgumentNullException (nameof (owner));
+
+ SetControlBehavior (ControlBehaviors.Selectable, true);
+ SetControlBehavior (ControlBehaviors.Hoverable, true);
+
+ TabStop = true;
+ Size = new Size (
+ PaddingSize * 2 + CellWidth * 7,
+ PaddingSize * 2 + HeaderHeight + DayHeaderHeight + CellHeight * WeekRows + FooterHeight);
+
+ yearRangeStart = GetYearRangeStart (displayMonth.Year);
+ UpdateLayoutRects ();
+ }
+
+ public DateTime Value {
+ get => value;
+ set {
+ this.value = value.Date;
+ displayMonth = new DateTime (this.value.Year, this.value.Month, 1);
+ yearRangeStart = GetYearRangeStart (displayMonth.Year);
+ Invalidate ();
+ }
+ }
+
+ public DateTime MinDate {
+ get => minDate;
+ set {
+ minDate = value.Date;
+ Invalidate ();
+ }
+ }
+
+ public DateTime MaxDate {
+ get => maxDate;
+ set {
+ maxDate = value.Date;
+ Invalidate ();
+ }
+ }
+
+ internal DateTime DisplayMonth => displayMonth;
+ internal DateTimePickerCalendarViewMode ViewMode => viewMode;
+ internal int YearRangeStart => yearRangeStart;
+
+ internal Rectangle PreviousButtonRectangle => prevButtonRect;
+ internal Rectangle NextButtonRectangle => nextButtonRect;
+ internal Rectangle MonthTitleRectangle => monthTitleRect;
+ internal Rectangle YearTitleRectangle => yearTitleRect;
+ internal Rectangle TodayButtonRectangle => todayButtonRect;
+
+ internal Rectangle[] DayCellRectangles => dayCellRects;
+ internal Rectangle[] MonthCellRectangles => monthCellRects;
+ internal Rectangle[] YearCellRectangles => yearCellRects;
+
+ internal Rectangle HoveredRectangle => hoveredRect;
+
+ internal DayOfWeek FirstDayOfWeek => CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek;
+
+ protected override void OnResize (EventArgs e)
+ {
+ base.OnResize (e);
+ UpdateLayoutRects ();
+ }
+
+ protected override void OnMouseMove (MouseEventArgs e)
+ {
+ base.OnMouseMove (e);
+
+ var newHover = HitTestInteractiveRectangle (e.Location);
+ if (hoveredRect != newHover) {
+ hoveredRect = newHover;
+ Invalidate ();
+ }
+ }
+
+ protected override void OnMouseLeave (EventArgs e)
+ {
+ base.OnMouseLeave (e);
+
+ if (hoveredRect != Rectangle.Empty) {
+ hoveredRect = Rectangle.Empty;
+ Invalidate ();
+ }
+ }
+
+ protected override void OnMouseDown (MouseEventArgs e)
+ {
+ base.OnMouseDown (e);
+
+ if (prevButtonRect.Contains (e.Location)) {
+ NavigatePrevious ();
+ return;
+ }
+
+ if (nextButtonRect.Contains (e.Location)) {
+ NavigateNext ();
+ return;
+ }
+
+ if (viewMode == DateTimePickerCalendarViewMode.Days) {
+ if (monthTitleRect.Contains (e.Location)) {
+ viewMode = DateTimePickerCalendarViewMode.Months;
+ Invalidate ();
+ return;
+ }
+
+ if (yearTitleRect.Contains (e.Location)) {
+ viewMode = DateTimePickerCalendarViewMode.Years;
+ yearRangeStart = GetYearRangeStart (displayMonth.Year);
+ Invalidate ();
+ return;
+ }
+
+ if (todayButtonRect.Contains (e.Location)) {
+ GoToToday ();
+ return;
+ }
+
+ if (TryGetDateAt (e.Location, out var date)) {
+ if (date >= MinDate.Date && date <= MaxDate.Date)
+ owner.ApplyDropDownValue (date);
+ }
+
+ return;
+ }
+
+ if (viewMode == DateTimePickerCalendarViewMode.Months) {
+ if (yearTitleRect.Contains (e.Location)) {
+ viewMode = DateTimePickerCalendarViewMode.Years;
+ yearRangeStart = GetYearRangeStart (displayMonth.Year);
+ Invalidate ();
+ return;
+ }
+
+ if (TryGetMonthAt (e.Location, out int month)) {
+ displayMonth = new DateTime (displayMonth.Year, month, 1);
+ viewMode = DateTimePickerCalendarViewMode.Days;
+ Invalidate ();
+ }
+
+ return;
+ }
+
+ if (viewMode == DateTimePickerCalendarViewMode.Years) {
+ if (TryGetYearAt (e.Location, out int year)) {
+ displayMonth = new DateTime (year, displayMonth.Month, 1);
+ viewMode = DateTimePickerCalendarViewMode.Months;
+ Invalidate ();
+ }
+ }
+ }
+
+ protected override void OnKeyDown (KeyEventArgs e)
+ {
+ base.OnKeyDown (e);
+
+ switch (e.KeyCode) {
+ case Keys.Escape:
+ if (viewMode != DateTimePickerCalendarViewMode.Days) {
+ viewMode = DateTimePickerCalendarViewMode.Days;
+ Invalidate ();
+ } else {
+ owner.CloseDropDown ();
+ }
+
+ e.Handled = true;
+ break;
+
+ case Keys.Left:
+ NavigatePrevious ();
+ e.Handled = true;
+ break;
+
+ case Keys.Right:
+ NavigateNext ();
+ e.Handled = true;
+ break;
+ }
+ }
+
+ protected override void OnPaint (PaintEventArgs e)
+ {
+ base.OnPaint (e);
+ RenderManager.Render (this, e);
+ }
+
+ internal DateTime GetFirstVisibleDate ()
+ {
+ var firstOfMonth = new DateTime (displayMonth.Year, displayMonth.Month, 1);
+ int offset = ((int)firstOfMonth.DayOfWeek - (int)FirstDayOfWeek + 7) % 7;
+ return firstOfMonth.AddDays (-offset);
+ }
+
+ private void NavigatePrevious ()
+ {
+ switch (viewMode) {
+ case DateTimePickerCalendarViewMode.Days:
+ displayMonth = displayMonth.AddMonths (-1);
+ break;
+ case DateTimePickerCalendarViewMode.Months:
+ displayMonth = displayMonth.AddYears (-1);
+ break;
+ case DateTimePickerCalendarViewMode.Years:
+ yearRangeStart -= 12;
+ break;
+ }
+
+ Invalidate ();
+ }
+
+ private void NavigateNext ()
+ {
+ switch (viewMode) {
+ case DateTimePickerCalendarViewMode.Days:
+ displayMonth = displayMonth.AddMonths (1);
+ break;
+ case DateTimePickerCalendarViewMode.Months:
+ displayMonth = displayMonth.AddYears (1);
+ break;
+ case DateTimePickerCalendarViewMode.Years:
+ yearRangeStart += 12;
+ break;
+ }
+
+ Invalidate ();
+ }
+
+ private void GoToToday ()
+ {
+ var today = DateTime.Today;
+ if (today < MinDate.Date)
+ today = MinDate.Date;
+ if (today > MaxDate.Date)
+ today = MaxDate.Date;
+
+ displayMonth = new DateTime (today.Year, today.Month, 1);
+ viewMode = DateTimePickerCalendarViewMode.Days;
+ Invalidate ();
+ }
+
+ private void UpdateLayoutRects ()
+ {
+ prevButtonRect = new Rectangle (PaddingSize, PaddingSize, 24, HeaderHeight);
+ nextButtonRect = new Rectangle (Width - PaddingSize - 24, PaddingSize, 24, HeaderHeight);
+
+ int titleX = prevButtonRect.Right + 4;
+ int titleWidth = Width - titleX - (Width - nextButtonRect.Left) - 4;
+
+ monthTitleRect = new Rectangle (titleX, PaddingSize, titleWidth / 2, HeaderHeight);
+ yearTitleRect = new Rectangle (monthTitleRect.Right, PaddingSize, titleWidth - monthTitleRect.Width, HeaderHeight);
+
+ int startX = PaddingSize;
+ int startY = PaddingSize + HeaderHeight + DayHeaderHeight;
+
+ dayCellRects = new Rectangle[42];
+ for (int row = 0; row < WeekRows; row++) {
+ for (int col = 0; col < 7; col++) {
+ int index = row * 7 + col;
+ dayCellRects[index] = new Rectangle (
+ startX + col * CellWidth,
+ startY + row * CellHeight,
+ CellWidth,
+ CellHeight);
+ }
+ }
+
+ monthCellRects = new Rectangle[12];
+ for (int row = 0; row < 3; row++) {
+ for (int col = 0; col < 4; col++) {
+ int index = row * 4 + col;
+ monthCellRects[index] = new Rectangle (
+ PaddingSize + col * 52,
+ PaddingSize + HeaderHeight + 8 + row * 40,
+ 48,
+ 34);
+ }
+ }
+
+ yearCellRects = new Rectangle[12];
+ for (int row = 0; row < 3; row++) {
+ for (int col = 0; col < 4; col++) {
+ int index = row * 4 + col;
+ yearCellRects[index] = new Rectangle (
+ PaddingSize + col * 52,
+ PaddingSize + HeaderHeight + 8 + row * 40,
+ 48,
+ 34);
+ }
+ }
+
+ todayButtonRect = new Rectangle (
+ PaddingSize,
+ Height - PaddingSize - FooterHeight + 2,
+ Width - PaddingSize * 2,
+ FooterHeight - 4);
+ }
+
+ private Rectangle HitTestInteractiveRectangle (Point point)
+ {
+ if (prevButtonRect.Contains (point))
+ return prevButtonRect;
+
+ if (nextButtonRect.Contains (point))
+ return nextButtonRect;
+
+ if (monthTitleRect.Contains (point))
+ return monthTitleRect;
+
+ if (yearTitleRect.Contains (point))
+ return yearTitleRect;
+
+ if (viewMode == DateTimePickerCalendarViewMode.Days) {
+ if (todayButtonRect.Contains (point))
+ return todayButtonRect;
+
+ foreach (var rect in dayCellRects)
+ if (rect.Contains (point))
+ return rect;
+ } else if (viewMode == DateTimePickerCalendarViewMode.Months) {
+ foreach (var rect in monthCellRects)
+ if (rect.Contains (point))
+ return rect;
+ } else {
+ foreach (var rect in yearCellRects)
+ if (rect.Contains (point))
+ return rect;
+ }
+
+ return Rectangle.Empty;
+ }
+
+ private bool TryGetDateAt (Point point, out DateTime date)
+ {
+ var firstVisible = GetFirstVisibleDate ();
+
+ for (int i = 0; i < dayCellRects.Length; i++) {
+ if (dayCellRects[i].Contains (point)) {
+ date = firstVisible.AddDays (i);
+ return true;
+ }
+ }
+
+ date = default;
+ return false;
+ }
+
+ private bool TryGetMonthAt (Point point, out int month)
+ {
+ for (int i = 0; i < monthCellRects.Length; i++) {
+ if (monthCellRects[i].Contains (point)) {
+ month = i + 1;
+ return true;
+ }
+ }
+
+ month = 1;
+ return false;
+ }
+
+ private bool TryGetYearAt (Point point, out int year)
+ {
+ for (int i = 0; i < yearCellRects.Length; i++) {
+ if (yearCellRects[i].Contains (point)) {
+ year = yearRangeStart + i;
+ return true;
+ }
+ }
+
+ year = displayMonth.Year;
+ return false;
+ }
+
+ private static int GetYearRangeStart (int year)
+ {
+ return year - (year % 12);
+ }
+ }
+}
diff --git a/src/Modern.Forms/DateTimePickerCalendarViewMode.cs b/src/Modern.Forms/DateTimePickerCalendarViewMode.cs
new file mode 100644
index 0000000..62b851d
--- /dev/null
+++ b/src/Modern.Forms/DateTimePickerCalendarViewMode.cs
@@ -0,0 +1,9 @@
+namespace Modern.Forms
+{
+ internal enum DateTimePickerCalendarViewMode
+ {
+ Days,
+ Months,
+ Years
+ }
+}
diff --git a/src/Modern.Forms/DateTimePickerFormat.cs b/src/Modern.Forms/DateTimePickerFormat.cs
new file mode 100644
index 0000000..684aacf
--- /dev/null
+++ b/src/Modern.Forms/DateTimePickerFormat.cs
@@ -0,0 +1,28 @@
+namespace Modern.Forms
+{
+ ///
+ /// Specifies how the displays its value.
+ ///
+ public enum DateTimePickerFormat
+ {
+ ///
+ /// Displays the date using the current culture long date pattern.
+ ///
+ Long = 1,
+
+ ///
+ /// Displays the date using the current culture short date pattern.
+ ///
+ Short = 2,
+
+ ///
+ /// Displays the time using the current culture short time pattern.
+ ///
+ Time = 4,
+
+ ///
+ /// Displays the value using .
+ ///
+ Custom = 8
+ }
+}
diff --git a/src/Modern.Forms/FormTitleBar.cs b/src/Modern.Forms/FormTitleBar.cs
index 40bd552..1363941 100644
--- a/src/Modern.Forms/FormTitleBar.cs
+++ b/src/Modern.Forms/FormTitleBar.cs
@@ -14,7 +14,7 @@ public class FormTitleBar : Control
private readonly TitleBarButton maximize_button;
private readonly TitleBarButton close_button;
private readonly PictureBox form_image;
-
+
private bool show_image = true;
///
@@ -37,10 +37,7 @@ public FormTitleBar ()
maximize_button = Controls.AddImplicitControl (new TitleBarButton (TitleBarButton.TitleBarButtonGlyph.Maximize));
maximize_button.Click += (o, e) => {
- var form = FindForm ();
-
- if (form != null)
- form.WindowState = form.WindowState == FormWindowState.Maximized ? FormWindowState.Normal : FormWindowState.Maximized;
+ ToggleMaximizeRestore ();
};
close_button = Controls.AddImplicitControl (new TitleBarButton (TitleBarButton.TitleBarButtonGlyph.Close));
@@ -64,7 +61,8 @@ public bool AllowMaximize {
get => maximize_button.Visible;
set {
maximize_button.Visible = value;
- Invalidate (); // TODO: Shouldn't be necessary, should automatically be triggered
+ UpdateMaximizeButtonGlyph ();
+ Invalidate ();
}
}
@@ -75,7 +73,7 @@ public bool AllowMinimize {
get => minimize_button.Visible;
set {
minimize_button.Visible = value;
- Invalidate (); // TODO: Shouldn't be necessary, should automatically be triggered
+ Invalidate ();
}
}
@@ -101,19 +99,52 @@ public SKBitmap? Image {
}
}
+
+ ///
+ /// Gets or sets whether double-clicking the title bar toggles maximize/restore.
+ ///
+ public bool AllowDoubleClickMaximize { get; set; } = true;
+
///
protected override void OnMouseDown (MouseEventArgs e)
{
base.OnMouseDown (e);
+ if (e.Button != MouseButtons.Left)
+ return;
+
+ if (AllowDoubleClickMaximize && e.Clicks > 1)
+ return;
+
// We won't get a MouseUp from the system for this, so don't capture the mouse
Capture = false;
FindForm ()?.BeginMoveDrag ();
}
+
+ ///
+ /// Handles double-click on the title bar to toggle maximize/restore.
+ ///
+ /// The mouse event data.
+ protected override void OnDoubleClick (MouseEventArgs e)
+ {
+ base.OnDoubleClick (e);
+
+ if (e.Button != MouseButtons.Left)
+ return;
+
+ if (!AllowDoubleClickMaximize || !AllowMaximize)
+ return;
+
+ ToggleMaximizeRestore ();
+ }
+
+
///
protected override void OnPaint (PaintEventArgs e)
{
+ UpdateMaximizeButtonGlyph ();
+
base.OnPaint (e);
RenderManager.Render (this, e);
@@ -126,6 +157,8 @@ protected override void OnSizeChanged (EventArgs e)
// Keep our form image a square
form_image.Width = Height;
+
+ UpdateMaximizeButtonGlyph ();
}
///
@@ -136,8 +169,8 @@ public bool ShowImage {
set {
if (show_image != value) {
show_image = value;
- form_image.Visible = value && form_image is not null;
- Invalidate (); // TODO: Shouldn't be required
+ form_image.Visible = value && form_image.Image is not null;
+ Invalidate ();
}
}
}
@@ -145,12 +178,56 @@ public bool ShowImage {
///
public override ControlStyle Style { get; } = new ControlStyle (DefaultStyle);
+ private void ToggleMaximizeRestore ()
+ {
+ var form = FindForm ();
+
+ if (form == null || !maximize_button.Visible)
+ return;
+
+ form.WindowState = form.WindowState == FormWindowState.Maximized
+ ? FormWindowState.Normal
+ : FormWindowState.Maximized;
+
+ UpdateMaximizeButtonGlyph ();
+ }
+
+ private void UpdateMaximizeButtonGlyph ()
+ {
+ var form = FindForm ();
+
+ if (form == null || !maximize_button.Visible) {
+ maximize_button.Glyph = TitleBarButton.TitleBarButtonGlyph.Maximize;
+ return;
+ }
+
+ maximize_button.Glyph = form.WindowState == FormWindowState.Maximized
+ ? TitleBarButton.TitleBarButtonGlyph.Restore
+ : TitleBarButton.TitleBarButtonGlyph.Maximize;
+ }
+
internal class TitleBarButton : Button
{
protected const int BUTTON_PADDING = 10;
- private readonly TitleBarButtonGlyph glyph;
+ private TitleBarButtonGlyph glyph;
+
+ ///
+ /// Gets or sets the glyph displayed by the button.
+ ///
+ public TitleBarButtonGlyph Glyph {
+ get => glyph;
+ set {
+ if (glyph != value) {
+ glyph = value;
+ Invalidate ();
+ }
+ }
+ }
+ ///
+ /// Initializes a new instance of the TitleBarButton class.
+ ///
public TitleBarButton (TitleBarButtonGlyph glyph)
{
this.glyph = glyph;
@@ -162,6 +239,7 @@ public TitleBarButton (TitleBarButtonGlyph glyph)
StyleHover.Border.Width = 0;
}
+ ///
protected override void OnPaint (PaintEventArgs e)
{
base.OnPaint (e);
@@ -183,14 +261,36 @@ protected override void OnPaint (PaintEventArgs e)
case TitleBarButtonGlyph.Maximize:
ControlPaint.DrawMaximizeGlyph (e, glyph_bounds);
break;
+ case TitleBarButtonGlyph.Restore:
+ ControlPaint.DrawRestoreGlyph (e, glyph_bounds);
+ break;
}
}
+ ///
+ /// Specifies which glyph is displayed by the title bar button.
+ ///
public enum TitleBarButtonGlyph
{
+ ///
+ /// Close glyph.
+ ///
Close,
+
+ ///
+ /// Minimize glyph.
+ ///
Minimize,
- Maximize
+
+ ///
+ /// Maximize glyph.
+ ///
+ Maximize,
+
+ ///
+ /// Restore glyph.
+ ///
+ Restore
}
}
}
diff --git a/src/Modern.Forms/HueSlider.cs b/src/Modern.Forms/HueSlider.cs
new file mode 100644
index 0000000..dc172a8
--- /dev/null
+++ b/src/Modern.Forms/HueSlider.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Drawing;
+using Modern.Forms.Renderers;
+
+namespace Modern.Forms
+{
+ ///
+ /// Vertical hue selection slider.
+ /// Top = 0° (red), bottom = 360° (red again).
+ ///
+ public class HueSlider : Control
+ {
+ private bool isDragging;
+ private float hue;
+
+ public HueSlider ()
+ {
+ SetControlBehavior (ControlBehaviors.Selectable, false);
+ SetControlBehavior (ControlBehaviors.Hoverable);
+ Cursor = Cursors.Hand;
+ }
+
+ public new static ControlStyle DefaultStyle = new ControlStyle (Control.DefaultStyle,
+ style => {
+ style.Border.Width = 1;
+ style.BackgroundColor = Theme.ControlLowColor;
+ });
+
+ public override ControlStyle Style { get; } = new ControlStyle (DefaultStyle);
+
+ protected override Size DefaultSize => new Size (24, 260);
+
+ public float Hue {
+ get => hue;
+ set {
+ float normalized = ColorHelper.NormalizeHue (value);
+ if (Math.Abs (hue - normalized) > float.Epsilon) {
+ hue = normalized;
+ HueChanged?.Invoke (this, EventArgs.Empty);
+ Invalidate ();
+ }
+ }
+ }
+
+ public event EventHandler? HueChanged;
+
+ public void SetHueSilently (float value)
+ {
+ hue = ColorHelper.NormalizeHue (value);
+ Invalidate ();
+ }
+
+ protected override void OnMouseDown (MouseEventArgs e)
+ {
+ base.OnMouseDown (e);
+
+ if ((e.Button & MouseButtons.Left) == 0)
+ return;
+
+ isDragging = true;
+ UpdateFromPoint (e.Location);
+ }
+
+ protected override void OnMouseMove (MouseEventArgs e)
+ {
+ base.OnMouseMove (e);
+
+ if (!isDragging)
+ return;
+
+ UpdateFromPoint (e.Location);
+ }
+
+ protected override void OnMouseUp (MouseEventArgs e)
+ {
+ base.OnMouseUp (e);
+ isDragging = false;
+ }
+
+ protected override void OnPaint (PaintEventArgs e)
+ {
+ base.OnPaint (e);
+ RenderManager.Render (this, e);
+ }
+
+ private void UpdateFromPoint (Point location)
+ {
+ var renderer = RenderManager.GetRenderer ();
+ if (renderer is null)
+ return;
+
+ var bounds = renderer.GetContentBounds (this, null);
+ if (bounds.Height <= 1)
+ return;
+
+ float percent = (location.Y - bounds.Top) / (float)Math.Max (1, bounds.Height - 1);
+ percent = ColorHelper.Clamp01 (percent);
+
+ // Top = 0°, bottom approaches 360°.
+ float newHue = percent * 360f;
+
+ if (newHue >= 360f)
+ newHue = 359.999f;
+
+ hue = newHue;
+ HueChanged?.Invoke (this, EventArgs.Empty);
+ Invalidate ();
+ }
+ }
+}
diff --git a/src/Modern.Forms/Modern.Forms.csproj b/src/Modern.Forms/Modern.Forms.csproj
index 188917a..4edbd37 100644
--- a/src/Modern.Forms/Modern.Forms.csproj
+++ b/src/Modern.Forms/Modern.Forms.csproj
@@ -1,7 +1,7 @@
- $(_TargetFramework)
+ net10.0
enable
true
false
diff --git a/src/Modern.Forms/Renderers/ColorBoxRenderer.cs b/src/Modern.Forms/Renderers/ColorBoxRenderer.cs
new file mode 100644
index 0000000..92d6392
--- /dev/null
+++ b/src/Modern.Forms/Renderers/ColorBoxRenderer.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Drawing;
+using SkiaSharp;
+
+namespace Modern.Forms.Renderers
+{
+ public class ColorBoxRenderer : Renderer
+ {
+ protected override void Render (ColorBox control, PaintEventArgs e)
+ {
+ var bounds = GetContentBounds (control, e);
+ if (bounds.Width <= 0 || bounds.Height <= 0)
+ return;
+
+ var canvas = e.Canvas;
+ var rect = new SKRect (bounds.Left, bounds.Top, bounds.Right, bounds.Bottom);
+
+ using var huePaint = new SKPaint { IsAntialias = false };
+ using var whitePaint = new SKPaint { IsAntialias = false };
+ using var blackPaint = new SKPaint { IsAntialias = false };
+ using var border = new SKPaint {
+ Style = SKPaintStyle.Stroke,
+ Color = Theme.BorderLowColor,
+ IsAntialias = true
+ };
+
+ // base hue color
+ huePaint.Color = ColorHelper.FromHsv (control.Hue, 1f, 1f);
+ canvas.DrawRect (rect, huePaint);
+
+ // saturation gradient (white → transparent)
+ whitePaint.Shader = SKShader.CreateLinearGradient (
+ new SKPoint (rect.Left, rect.Top),
+ new SKPoint (rect.Right, rect.Top),
+ new[]
+ {
+ SKColors.White,
+ new SKColor(255,255,255,0)
+ },
+ null,
+ SKShaderTileMode.Clamp);
+
+ canvas.DrawRect (rect, whitePaint);
+
+ // value gradient (transparent → black)
+ blackPaint.Shader = SKShader.CreateLinearGradient (
+ new SKPoint (rect.Left, rect.Top),
+ new SKPoint (rect.Left, rect.Bottom),
+ new[]
+ {
+ new SKColor(0,0,0,0),
+ SKColors.Black
+ },
+ null,
+ SKShaderTileMode.Clamp);
+
+ canvas.DrawRect (rect, blackPaint);
+
+ canvas.DrawRect (rect, border);
+
+ DrawSelector (control, e, bounds);
+ }
+
+ public Rectangle GetContentBounds (ColorBox control, PaintEventArgs? e)
+ {
+ int border = e?.LogicalToDeviceUnits (1) ?? control.LogicalToDeviceUnits (1);
+ var rect = control.ClientRectangle;
+
+ return new Rectangle (
+ rect.Left + border,
+ rect.Top + border,
+ Math.Max (1, rect.Width - (border * 2)),
+ Math.Max (1, rect.Height - (border * 2)));
+ }
+
+ private void DrawSelector (ColorBox control, PaintEventArgs e, Rectangle bounds)
+ {
+ float x = bounds.Left + control.Saturation * Math.Max (1, bounds.Width - 1);
+ float y = bounds.Top + (1f - control.Value) * Math.Max (1, bounds.Height - 1);
+
+ using var outer = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 2,
+ Color = SKColors.Black
+ };
+
+ using var inner = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 1,
+ Color = SKColors.White
+ };
+
+ e.Canvas.DrawCircle (x, y, 7, outer);
+ e.Canvas.DrawCircle (x, y, 8.5f, inner);
+ }
+
+ private static SKRect ToRect (Rectangle rect)
+ => new SKRect (rect.Left, rect.Top, rect.Right, rect.Bottom);
+ }
+}
diff --git a/src/Modern.Forms/Renderers/DateTimePickerCalendarRenderer.cs b/src/Modern.Forms/Renderers/DateTimePickerCalendarRenderer.cs
new file mode 100644
index 0000000..b8a6191
--- /dev/null
+++ b/src/Modern.Forms/Renderers/DateTimePickerCalendarRenderer.cs
@@ -0,0 +1,313 @@
+using System;
+using System.Drawing;
+using System.Globalization;
+using SkiaSharp;
+
+namespace Modern.Forms.Renderers
+{
+ internal class DateTimePickerCalendarRenderer : Renderer
+ {
+ protected override void Render (DateTimePickerCalendar control, PaintEventArgs e)
+ {
+ var canvas = e.Canvas;
+
+ using var backgroundPaint = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Fill,
+ Color = control.CurrentStyle.BackgroundColor ?? SKColors.White
+ };
+
+ using var borderPaint = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 1,
+ Color = control.CurrentStyle.Border.Color ?? new SKColor (180, 180, 180)
+ };
+
+ using var textPaint = CreateTextPaint (control, control.DisplayMonth.ToString ("Y", CultureInfo.CurrentCulture));
+ using var mutedTextPaint = CreateTextPaint (control, "pon.");
+ mutedTextPaint.Color = mutedTextPaint.Color.WithAlpha (150);
+
+ using var accentPaint = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Fill,
+ Color = Theme.AccentColor
+ };
+
+ using var hoverPaint = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Fill,
+ Color = Blend (control.CurrentStyle.BackgroundColor ?? SKColors.White, SKColors.Black, 0.06f)
+ };
+
+ using var todayOutlinePaint = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 2,
+ Color = Theme.AccentColor
+ };
+
+ canvas.DrawRect (new SKRect (0, 0, control.Width, control.Height), backgroundPaint);
+ canvas.DrawRect (new SKRect (0, 0, control.Width, control.Height), borderPaint);
+
+ DrawHeader (control, canvas, textPaint, hoverPaint);
+
+ switch (control.ViewMode) {
+ case DateTimePickerCalendarViewMode.Days:
+ DrawDayHeaders (control, canvas, mutedTextPaint);
+ DrawDayCells (control, canvas, textPaint, mutedTextPaint, accentPaint, hoverPaint, borderPaint, todayOutlinePaint);
+ DrawTodayButton (control, canvas, textPaint, hoverPaint, borderPaint);
+ break;
+
+ case DateTimePickerCalendarViewMode.Months:
+ DrawMonthCells (control, canvas, textPaint, hoverPaint, borderPaint, accentPaint);
+ break;
+
+ case DateTimePickerCalendarViewMode.Years:
+ DrawYearCells (control, canvas, textPaint, hoverPaint, borderPaint, accentPaint);
+ break;
+ }
+ }
+
+ private static void DrawHeader (DateTimePickerCalendar control, SKCanvas canvas, SKPaint textPaint, SKPaint hoverPaint)
+ {
+ DrawButton (canvas, "<", control.PreviousButtonRectangle, textPaint, hoverPaint, control.HoveredRectangle == control.PreviousButtonRectangle);
+ DrawButton (canvas, ">", control.NextButtonRectangle, textPaint, hoverPaint, control.HoveredRectangle == control.NextButtonRectangle);
+
+ string monthText = CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName (control.DisplayMonth.Month);
+ if (!string.IsNullOrEmpty (monthText))
+ monthText = char.ToUpper (monthText[0], CultureInfo.CurrentCulture) + monthText.Substring (1);
+
+ string yearText = control.DisplayMonth.Year.ToString (CultureInfo.CurrentCulture);
+
+ if (control.HoveredRectangle == control.MonthTitleRectangle)
+ canvas.DrawRect (ToSKRect (control.MonthTitleRectangle), hoverPaint);
+
+ if (control.HoveredRectangle == control.YearTitleRectangle)
+ canvas.DrawRect (ToSKRect (control.YearTitleRectangle), hoverPaint);
+
+ using var monthPaint = CreateTextPaintFrom (textPaint, monthText);
+ using var yearPaint = CreateTextPaintFrom (textPaint, yearText);
+
+ DrawCenteredText (canvas, monthText, control.MonthTitleRectangle, monthPaint);
+ DrawCenteredText (canvas, yearText, control.YearTitleRectangle, yearPaint);
+ }
+
+ private static void DrawDayHeaders (DateTimePickerCalendar control, SKCanvas canvas, SKPaint textPaint)
+ {
+ var names = GetDayNames();
+ int y = control.MonthTitleRectangle.Bottom;
+
+ int firstDay = (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek;
+
+ for (int i = 0; i < 7; i++) {
+ int index = (firstDay + i) % 7;
+ var rect = new Rectangle (8 + i * 30, y, 30, 20);
+ DrawCenteredText (canvas, names[index], rect, textPaint);
+ }
+ }
+
+ private static string[] GetDayNames ()
+ {
+ if (CultureInfo.CurrentCulture.TwoLetterISOLanguageName == "pl")
+ return new[] { "nd", "pn", "wt", "śr", "cz", "pt", "sb" };
+
+ return CultureInfo.CurrentCulture.DateTimeFormat.ShortestDayNames;
+ }
+
+ private static void DrawDayCells (
+ DateTimePickerCalendar control,
+ SKCanvas canvas,
+ SKPaint textPaint,
+ SKPaint mutedTextPaint,
+ SKPaint accentPaint,
+ SKPaint hoverPaint,
+ SKPaint borderPaint,
+ SKPaint todayOutlinePaint)
+ {
+ var firstVisible = control.GetFirstVisibleDate ();
+ var today = DateTime.Today;
+
+ for (int i = 0; i < control.DayCellRectangles.Length; i++) {
+ var rect = control.DayCellRectangles[i];
+ var date = firstVisible.AddDays (i);
+
+ bool isCurrentMonth = date.Month == control.DisplayMonth.Month && date.Year == control.DisplayMonth.Year;
+ bool isSelected = date.Date == control.Value.Date;
+ bool isToday = date.Date == today;
+ bool isEnabled = date >= control.MinDate.Date && date <= control.MaxDate.Date;
+ bool isHovered = control.HoveredRectangle == rect;
+
+ if (isHovered && !isSelected)
+ canvas.DrawRect (ToSKRect (rect), hoverPaint);
+
+ if (isSelected)
+ canvas.DrawRect (ToSKRect (rect), accentPaint);
+
+ canvas.DrawRect (ToSKRect (rect), borderPaint);
+
+ if (isToday && !isSelected)
+ canvas.DrawRect (ToSKRect (rect).Deflate (2, 2), todayOutlinePaint);
+
+ var basePaint = isCurrentMonth ? textPaint : mutedTextPaint;
+ using var cellPaint = CreateTextPaintFrom (basePaint, date.Day.ToString (CultureInfo.CurrentCulture));
+
+ if (!isEnabled)
+ cellPaint.Color = cellPaint.Color.WithAlpha (90);
+ else if (isSelected)
+ cellPaint.Color = SKColors.White;
+
+ DrawCenteredText (canvas, date.Day.ToString (CultureInfo.CurrentCulture), rect, cellPaint);
+ }
+ }
+
+ private static void DrawMonthCells (DateTimePickerCalendar control, SKCanvas canvas, SKPaint textPaint, SKPaint hoverPaint, SKPaint borderPaint, SKPaint accentPaint)
+ {
+ var dtf = CultureInfo.CurrentCulture.DateTimeFormat;
+
+ for (int i = 0; i < control.MonthCellRectangles.Length; i++) {
+ var rect = control.MonthCellRectangles[i];
+ int month = i + 1;
+ bool isSelected = month == control.Value.Month && control.DisplayMonth.Year == control.Value.Year;
+ bool isHovered = control.HoveredRectangle == rect;
+
+ if (isHovered && !isSelected)
+ canvas.DrawRect (ToSKRect (rect), hoverPaint);
+
+ if (isSelected)
+ canvas.DrawRect (ToSKRect (rect), accentPaint);
+
+ canvas.DrawRect (ToSKRect (rect), borderPaint);
+
+ string text = dtf.AbbreviatedMonthNames[i];
+ using var paint = CreateTextPaintFrom (textPaint, text);
+ if (isSelected)
+ paint.Color = SKColors.White;
+
+ DrawCenteredText (canvas, text, rect, paint);
+ }
+ }
+
+ private static void DrawYearCells (DateTimePickerCalendar control, SKCanvas canvas, SKPaint textPaint, SKPaint hoverPaint, SKPaint borderPaint, SKPaint accentPaint)
+ {
+ for (int i = 0; i < control.YearCellRectangles.Length; i++) {
+ var rect = control.YearCellRectangles[i];
+ int year = control.YearRangeStart + i;
+ bool isSelected = year == control.Value.Year;
+ bool isHovered = control.HoveredRectangle == rect;
+
+ if (isHovered && !isSelected)
+ canvas.DrawRect (ToSKRect (rect), hoverPaint);
+
+ if (isSelected)
+ canvas.DrawRect (ToSKRect (rect), accentPaint);
+
+ canvas.DrawRect (ToSKRect (rect), borderPaint);
+
+ string text = year.ToString (CultureInfo.CurrentCulture);
+ using var paint = CreateTextPaintFrom (textPaint, text);
+ if (isSelected)
+ paint.Color = SKColors.White;
+
+ DrawCenteredText (canvas, text, rect, paint);
+ }
+ }
+
+ private static void DrawTodayButton (DateTimePickerCalendar control, SKCanvas canvas, SKPaint textPaint, SKPaint hoverPaint, SKPaint borderPaint)
+ {
+ bool hovered = control.HoveredRectangle == control.TodayButtonRectangle;
+
+ if (hovered)
+ canvas.DrawRect (ToSKRect (control.TodayButtonRectangle), hoverPaint);
+
+ canvas.DrawRect (ToSKRect (control.TodayButtonRectangle), borderPaint);
+
+ const string text = "Dzisiaj";
+ using var paint = CreateTextPaintFrom (textPaint, text);
+ DrawCenteredText (canvas, text, control.TodayButtonRectangle, paint);
+ }
+
+ private static void DrawButton (SKCanvas canvas, string text, Rectangle rect, SKPaint textPaint, SKPaint hoverPaint, bool hovered)
+ {
+ if (hovered)
+ canvas.DrawRect (ToSKRect (rect), hoverPaint);
+
+ using var paint = CreateTextPaintFrom (textPaint, text);
+ DrawCenteredText (canvas, text, rect, paint);
+ }
+
+ private static SKPaint CreateTextPaint (DateTimePickerCalendar control, string sampleText)
+ {
+ return new SKPaint {
+ IsAntialias = true,
+ Color = control.CurrentStyle.ForegroundColor ?? SKColors.Black,
+ TextSize = control.CurrentStyle.FontSize ?? Theme.FontSize,
+ Typeface = ResolveTypeface (control.CurrentStyle.Font ?? Theme.UIFont, sampleText)
+ };
+ }
+
+ private static SKPaint CreateTextPaintFrom (SKPaint source, string text)
+ {
+ return new SKPaint {
+ IsAntialias = source.IsAntialias,
+ Color = source.Color,
+ TextSize = source.TextSize,
+ Typeface = ResolveTypeface (source.Typeface, text)
+ };
+ }
+
+ private static SKTypeface? ResolveTypeface (SKTypeface? preferred, string text)
+ {
+ if (preferred is not null && SupportsText (preferred, text))
+ return preferred;
+
+ var segoe = SKTypeface.FromFamilyName ("Segoe UI");
+ if (segoe is not null && SupportsText (segoe, text))
+ return segoe;
+
+ return SKTypeface.Default;
+ }
+
+ private static bool SupportsText (SKTypeface typeface, string text)
+ {
+ foreach (char c in text) {
+ if (char.IsWhiteSpace (c) || char.IsPunctuation (c) || char.IsDigit (c))
+ continue;
+
+ if (typeface.GetGlyphs (c.ToString ())[0] == 0)
+ return false;
+ }
+
+ return true;
+ }
+
+ private static void DrawCenteredText (SKCanvas canvas, string text, Rectangle rect, SKPaint paint)
+ {
+ var metrics = paint.FontMetrics;
+ float x = rect.Left + (rect.Width - paint.MeasureText (text)) / 2f;
+ float y = rect.Top + ((rect.Height - (metrics.Descent - metrics.Ascent)) / 2f) - metrics.Ascent;
+ canvas.DrawText (text, x, y, paint);
+ }
+
+ private static SKRect ToSKRect (Rectangle rect)
+ => new (rect.Left, rect.Top, rect.Right, rect.Bottom);
+
+ private static SKColor Blend (SKColor from, SKColor to, float amount)
+ {
+ amount = Math.Max (0f, Math.Min (1f, amount));
+
+ byte r = (byte)(from.Red + ((to.Red - from.Red) * amount));
+ byte g = (byte)(from.Green + ((to.Green - from.Green) * amount));
+ byte b = (byte)(from.Blue + ((to.Blue - from.Blue) * amount));
+ byte a = (byte)(from.Alpha + ((to.Alpha - from.Alpha) * amount));
+
+ return new SKColor (r, g, b, a);
+ }
+ }
+
+ internal static class SkRectExtensions
+ {
+ public static SKRect Deflate (this SKRect rect, float dx, float dy)
+ => new (rect.Left + dx, rect.Top + dy, rect.Right - dx, rect.Bottom - dy);
+ }
+}
diff --git a/src/Modern.Forms/Renderers/DateTimePickerRenderer.cs b/src/Modern.Forms/Renderers/DateTimePickerRenderer.cs
new file mode 100644
index 0000000..1efc84b
--- /dev/null
+++ b/src/Modern.Forms/Renderers/DateTimePickerRenderer.cs
@@ -0,0 +1,345 @@
+using SkiaSharp;
+
+namespace Modern.Forms.Renderers
+{
+ ///
+ /// Provides rendering logic for .
+ ///
+ ///
+ /// This renderer is responsible only for visual output.
+ /// Control state, interaction, validation, and layout calculations
+ /// are handled by .
+ ///
+ internal class DateTimePickerRenderer : Renderer
+ {
+ private const float CheckStrokeWidth = 2f;
+ private const float ArrowInset = 6f;
+ private const float DividerPadding = 3f;
+ private const float TextHorizontalPadding = 2f;
+
+ ///
+ /// Renders the specified .
+ ///
+ /// The control to render.
+ /// The paint event arguments.
+ protected override void Render (DateTimePicker control, PaintEventArgs e)
+ {
+ var canvas = e.Canvas;
+
+ DrawCheckBox (control, canvas);
+ DrawButton (control, canvas);
+ DrawText (control, canvas);
+ DrawFocus (control, canvas);
+ }
+
+ ///
+ /// Draws the left-side checkbox if enabled.
+ ///
+ /// The owning control.
+ /// The target canvas.
+ private static void DrawCheckBox (DateTimePicker control, SKCanvas canvas)
+ {
+ if (!control.ShowCheckBox)
+ return;
+
+ var rect = ToSKRect (control.CheckBoxRectangle);
+
+ using var borderPaint = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 1,
+ Color = GetBorderColor (control)
+ };
+
+ using var fillPaint = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Fill,
+ Color = GetBackgroundColor (control)
+ };
+
+ canvas.DrawRect (rect, fillPaint);
+ canvas.DrawRect (rect, borderPaint);
+
+ if (!control.Checked)
+ return;
+
+ using var checkPaint = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = CheckStrokeWidth,
+ StrokeCap = SKStrokeCap.Round,
+ StrokeJoin = SKStrokeJoin.Round,
+ Color = GetForegroundColor (control)
+ };
+
+ float left = rect.Left + 3;
+ float midX = rect.MidX - 1;
+ float right = rect.Right - 3;
+ float top = rect.Top + 4;
+ float midY = rect.MidY + 1;
+ float bottom = rect.Bottom - 4;
+
+ using var path = new SKPath ();
+ path.MoveTo (left, midY);
+ path.LineTo (midX, bottom);
+ path.LineTo (right, top);
+
+ canvas.DrawPath (path, checkPaint);
+ }
+
+ ///
+ /// Draws the right-side button area.
+ ///
+ /// The owning control.
+ /// The target canvas.
+ private static void DrawButton (DateTimePicker control, SKCanvas canvas)
+ {
+ var rect = ToSKRect (control.ButtonRectangle);
+
+ using var fillPaint = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Fill,
+ Color = GetButtonBackgroundColor (control)
+ };
+
+ using var borderPaint = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 1,
+ Color = GetBorderColor (control)
+ };
+
+ canvas.DrawRect (rect, fillPaint);
+ canvas.DrawRect (rect, borderPaint);
+
+ if (control.ShowUpDown)
+ DrawUpDownGlyph (control, canvas, rect);
+ else
+ DrawDropDownGlyph (control, canvas, rect);
+ }
+
+ ///
+ /// Draws the up/down glyph when spinner mode is enabled.
+ ///
+ /// The owning control.
+ /// The target canvas.
+ /// The button rectangle.
+ private static void DrawUpDownGlyph (DateTimePicker control, SKCanvas canvas, SKRect rect)
+ {
+ float midY = rect.MidY;
+
+ using var dividerPaint = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 1,
+ Color = GetBorderColor (control)
+ };
+
+ canvas.DrawLine (rect.Left + DividerPadding, midY, rect.Right - DividerPadding, midY, dividerPaint);
+
+ using var glyphPaint = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Fill,
+ Color = GetForegroundColor (control)
+ };
+
+ using (var up = new SKPath ()) {
+ up.MoveTo (rect.MidX, midY - 5);
+ up.LineTo (rect.Left + ArrowInset, midY - 1);
+ up.LineTo (rect.Right - ArrowInset, midY - 1);
+ up.Close ();
+ canvas.DrawPath (up, glyphPaint);
+ }
+
+ using (var down = new SKPath ()) {
+ down.MoveTo (rect.Left + ArrowInset, midY + 1);
+ down.LineTo (rect.Right - ArrowInset, midY + 1);
+ down.LineTo (rect.MidX, midY + 5);
+ down.Close ();
+ canvas.DrawPath (down, glyphPaint);
+ }
+ }
+
+ ///
+ /// Draws the drop-down arrow glyph.
+ ///
+ /// The owning control.
+ /// The target canvas.
+ /// The button rectangle.
+ private static void DrawDropDownGlyph (DateTimePicker control, SKCanvas canvas, SKRect rect)
+ {
+ using var glyphPaint = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Fill,
+ Color = GetForegroundColor (control)
+ };
+
+ using var path = new SKPath ();
+ path.MoveTo (rect.MidX - 4, rect.MidY - 1);
+ path.LineTo (rect.MidX + 4, rect.MidY - 1);
+ path.LineTo (rect.MidX, rect.MidY + 4);
+ path.Close ();
+
+ canvas.DrawPath (path, glyphPaint);
+ }
+
+ ///
+ /// Draws the formatted text of the control.
+ ///
+ /// The owning control.
+ /// The target canvas.
+ private static void DrawText (DateTimePicker control, SKCanvas canvas)
+ {
+ if (string.IsNullOrEmpty (control.DisplayText))
+ return;
+
+ var rect = control.TextRectangle;
+
+ using var paint = new SKPaint {
+ IsAntialias = true,
+ Color = GetForegroundColor (control),
+ TextSize = GetTextSize (control),
+ Typeface = GetTypeface (control)
+ };
+
+ var metrics = paint.FontMetrics;
+ float baseline = rect.Top + ((rect.Height - (metrics.Descent - metrics.Ascent)) / 2f) - metrics.Ascent;
+
+ canvas.DrawText (
+ control.DisplayText,
+ rect.Left + TextHorizontalPadding,
+ baseline,
+ paint);
+ }
+
+ ///
+ /// Draws the focus cue around the text area when appropriate.
+ ///
+ /// The owning control.
+ /// The target canvas.
+ private static void DrawFocus (DateTimePicker control, SKCanvas canvas)
+ {
+ if (!control.Focused || !control.ShowFocusCues)
+ return;
+
+ var rect = ToSKRect (control.TextRectangle);
+ rect.Inflate (-1, -1);
+
+ using var focusPaint = new SKPaint {
+ IsAntialias = false,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 1,
+ Color = Theme.AccentColor,
+ PathEffect = SKPathEffect.CreateDash (new float[] { 2, 2 }, 0)
+ };
+
+ canvas.DrawRect (rect, focusPaint);
+ }
+
+ ///
+ /// Gets the control background color.
+ ///
+ /// The owning control.
+ /// The resolved background color.
+ private static SKColor GetBackgroundColor (DateTimePicker control)
+ {
+ return control.CurrentStyle.BackgroundColor ?? SKColors.White;
+ }
+
+ ///
+ /// Gets the border color for the control.
+ ///
+ /// The owning control.
+ /// The resolved border color.
+ private static SKColor GetBorderColor (DateTimePicker control)
+ {
+ return control.CurrentStyle.Border.Color ?? SKColors.Gray;
+ }
+
+ ///
+ /// Gets the foreground color for text and glyphs.
+ ///
+ /// The owning control.
+ /// The resolved foreground color.
+ private static SKColor GetForegroundColor (DateTimePicker control)
+ {
+ var color = control.CurrentStyle.ForegroundColor ?? SKColors.Black;
+
+ if (!control.Enabled)
+ color = color.WithAlpha ((byte)(color.Alpha * 0.55f));
+
+ return color;
+ }
+
+ ///
+ /// Gets the background color for the right-side button based on its current state.
+ ///
+ /// The owning control.
+ /// The resolved button background color.
+ private static SKColor GetButtonBackgroundColor (DateTimePicker control)
+ {
+ var baseColor = GetBackgroundColor (control);
+
+ if (!control.Enabled)
+ return baseColor.WithAlpha ((byte)(baseColor.Alpha * 0.75f));
+
+ if (control.IsDropDownOpen || control.IsButtonPressed)
+ return Blend (baseColor, SKColors.Black, 0.10f);
+
+ if (control.IsButtonHovered)
+ return Blend (baseColor, SKColors.White, 0.08f);
+
+ return baseColor;
+ }
+
+ ///
+ /// Gets the text size to use when drawing the control.
+ ///
+ /// The owning control.
+ /// The resolved text size.
+ private static float GetTextSize (DateTimePicker control)
+ {
+ if (control.CurrentStyle.FontSize.HasValue && control.CurrentStyle.FontSize.Value > 0)
+ return control.CurrentStyle.FontSize.Value;
+
+ return Theme.FontSize;
+ }
+
+ ///
+ /// Gets the typeface used for text rendering.
+ ///
+ /// The owning control.
+ /// The resolved typeface.
+ private static SKTypeface? GetTypeface (DateTimePicker control)
+ {
+ return control.CurrentStyle.Font ?? Theme.UIFont;
+ }
+
+ ///
+ /// Blends two colors together.
+ ///
+ /// The base color.
+ /// The overlay color.
+ /// Blend amount in range 0..1.
+ /// The blended color.
+ private static SKColor Blend (SKColor from, SKColor to, float amount)
+ {
+ amount = amount < 0f ? 0f : (amount > 1f ? 1f : amount);
+
+ byte r = (byte)(from.Red + ((to.Red - from.Red) * amount));
+ byte g = (byte)(from.Green + ((to.Green - from.Green) * amount));
+ byte b = (byte)(from.Blue + ((to.Blue - from.Blue) * amount));
+ byte a = (byte)(from.Alpha + ((to.Alpha - from.Alpha) * amount));
+
+ return new SKColor (r, g, b, a);
+ }
+
+ ///
+ /// Converts a to .
+ ///
+ /// The source rectangle.
+ /// The converted rectangle.
+ private static SKRect ToSKRect (System.Drawing.Rectangle rect)
+ => new SKRect (rect.Left, rect.Top, rect.Right, rect.Bottom);
+ }
+}
diff --git a/src/Modern.Forms/Renderers/HueSliderRenderer.cs b/src/Modern.Forms/Renderers/HueSliderRenderer.cs
new file mode 100644
index 0000000..12515a2
--- /dev/null
+++ b/src/Modern.Forms/Renderers/HueSliderRenderer.cs
@@ -0,0 +1,80 @@
+using System.Drawing;
+using SkiaSharp;
+
+namespace Modern.Forms.Renderers
+{
+ public class HueSliderRenderer : Renderer
+ {
+ protected override void Render (HueSlider control, PaintEventArgs e)
+ {
+ var bounds = GetContentBounds (control, e);
+ if (bounds.Width <= 0 || bounds.Height <= 0)
+ return;
+
+ var canvas = e.Canvas;
+ var rect = new SKRect (bounds.Left, bounds.Top, bounds.Right, bounds.Bottom);
+
+ using (var paint = new SKPaint { IsAntialias = false })
+ using (var border = new SKPaint {
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ Color = Theme.BorderLowColor
+ }) {
+ paint.Shader = SKShader.CreateLinearGradient (
+ new SKPoint (rect.Left, rect.Top),
+ new SKPoint (rect.Left, rect.Bottom),
+ new[]
+ {
+ new SKColor(255, 0, 0), // 0 red
+ new SKColor(255, 255, 0), // 60 yellow
+ new SKColor(0, 255, 0), // 120 green
+ new SKColor(0, 255, 255), // 180 cyan
+ new SKColor(0, 0, 255), // 240 blue
+ new SKColor(255, 0, 255), // 300 magenta
+ new SKColor(255, 0, 0) // 360 red
+ },
+ new[] { 0f, 1f / 6f, 2f / 6f, 3f / 6f, 4f / 6f, 5f / 6f, 1f },
+ SKShaderTileMode.Clamp);
+
+ canvas.DrawRect (rect, paint);
+ canvas.DrawRect (rect, border);
+ }
+
+ DrawMarker (control, e, bounds);
+ }
+
+ public Rectangle GetContentBounds (HueSlider control, PaintEventArgs? e)
+ {
+ int border = e?.LogicalToDeviceUnits (1) ?? control.LogicalToDeviceUnits (1);
+ var rect = control.ClientRectangle;
+
+ return new Rectangle (
+ rect.Left + border,
+ rect.Top + border,
+ System.Math.Max (1, rect.Width - (border * 2)),
+ System.Math.Max (1, rect.Height - (border * 2)));
+ }
+
+ private void DrawMarker (HueSlider control, PaintEventArgs e, Rectangle bounds)
+ {
+ // Top = 0°, bottom = 360°.
+ float percent = control.Hue / 360f;
+ float y = bounds.Top + percent * System.Math.Max (1, bounds.Height - 1);
+
+ using var outlinePaint = new SKPaint {
+ IsAntialias = true,
+ Color = SKColors.Black,
+ StrokeWidth = 3
+ };
+
+ using var linePaint = new SKPaint {
+ IsAntialias = true,
+ Color = SKColors.White,
+ StrokeWidth = 1.5f
+ };
+
+ e.Canvas.DrawLine (bounds.Left - 3, y, bounds.Right + 3, y, outlinePaint);
+ e.Canvas.DrawLine (bounds.Left - 2, y, bounds.Right + 2, y, linePaint);
+ }
+ }
+}
diff --git a/src/Modern.Forms/Renderers/RenderManager.cs b/src/Modern.Forms/Renderers/RenderManager.cs
index 9e5ed3c..27e5520 100644
--- a/src/Modern.Forms/Renderers/RenderManager.cs
+++ b/src/Modern.Forms/Renderers/RenderManager.cs
@@ -38,6 +38,11 @@ static RenderManager ()
SetRenderer (new TextBoxRenderer ());
SetRenderer (new ToolBarRenderer ());
SetRenderer (new TreeViewRenderer ());
+ SetRenderer (new TrackBarRenderer ());
+ SetRenderer (new ColorBoxRenderer ());
+ SetRenderer (new HueSliderRenderer ());
+ SetRenderer (new DateTimePickerRenderer ());
+ SetRenderer (new DateTimePickerCalendarRenderer ());
}
///
diff --git a/src/Modern.Forms/Renderers/TrackBarRenderer.cs b/src/Modern.Forms/Renderers/TrackBarRenderer.cs
new file mode 100644
index 0000000..2806e68
--- /dev/null
+++ b/src/Modern.Forms/Renderers/TrackBarRenderer.cs
@@ -0,0 +1,241 @@
+using System;
+using System.Collections.Generic;
+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/ScrollableControl.cs b/src/Modern.Forms/ScrollableControl.cs
index 993bcfe..f6ae546 100644
--- a/src/Modern.Forms/ScrollableControl.cs
+++ b/src/Modern.Forms/ScrollableControl.cs
@@ -67,6 +67,9 @@ public bool AutoScroll {
}
}
+ private bool IsInternalScrollControl (Control c)
+ => ReferenceEquals (c, hscrollbar) || ReferenceEquals (c, vscrollbar) || ReferenceEquals (c, sizegrip);
+
// Calculates and sets the current canvas size.
private void CalculateCanvasSize ()
{
@@ -76,6 +79,9 @@ private void CalculateCanvasSize ()
var extra_height = vscrollbar.Value + Padding.Bottom;
foreach (var c in Controls) {
+ if (IsInternalScrollControl (c))
+ continue;
+
if (c.Dock == DockStyle.Right)
extra_width += c.Width;
else if (c.Dock == DockStyle.Bottom)
@@ -88,17 +94,23 @@ private void CalculateCanvasSize ()
}
foreach (var c in Controls) {
+ if (IsInternalScrollControl (c))
+ continue;
+
switch (c.Dock) {
case DockStyle.Left:
width = Math.Max (width, c.Right + extra_width);
continue;
+
case DockStyle.Top:
height = Math.Max (height, c.Bottom + extra_height);
continue;
+
case DockStyle.Bottom:
case DockStyle.Right:
case DockStyle.Fill:
continue;
+
default:
var anchor = c.Anchor;
@@ -128,7 +140,6 @@ public override Rectangle DisplayRectangle {
if (vscrollbar.Visible)
rect.Width -= vscrollbar.Width;
- // TODO: Scale padding?
return LayoutUtils.DeflateRect (rect, Padding);
}
}
@@ -173,7 +184,7 @@ protected override void OnPaint (PaintEventArgs e)
private void Recalculate (bool doLayout)
{
var canvas = canvas_size;
- var client = Bounds;
+ var client = ClientRectangle;
canvas.Width += auto_scroll_margin.Width;
canvas.Height += auto_scroll_margin.Height;
@@ -194,7 +205,7 @@ private void Recalculate (bool doLayout)
if ((force_hscroll_visible || (canvas.Width > right_edge && auto_scroll)) && client.Width > 0) {
hscroll_visible = true;
- bottom_edge = client.Height - bar_size;// SystemInformation.HorizontalScrollBarHeight;
+ bottom_edge = client.Height - bar_size;
} else {
hscroll_visible = false;
bottom_edge = client.Height;
@@ -202,26 +213,27 @@ private void Recalculate (bool doLayout)
if ((force_vscroll_visible || (canvas.Height > bottom_edge && auto_scroll)) && client.Height > 0) {
vscroll_visible = true;
- right_edge = client.Width - bar_size;// SystemInformation.VerticalScrollBarWidth;
+ right_edge = client.Width - bar_size;
} else {
vscroll_visible = false;
right_edge = client.Width;
}
- } while (right_edge != prev_right_edge || bottom_edge != prev_bottom_edge);
+ }
+ while (right_edge != prev_right_edge || bottom_edge != prev_bottom_edge);
right_edge = Math.Max (right_edge, 0);
bottom_edge = Math.Max (bottom_edge, 0);
if (!vscroll_visible)
vscrollbar.Value = 0;
+
if (!hscroll_visible)
hscrollbar.Value = 0;
if (hscroll_visible) {
hscrollbar.LargeChange = right_edge;
hscrollbar.SmallChange = 5;
- hscrollbar.Maximum = canvas.Width - client.Width + bar_size;
-
+ hscrollbar.Maximum = Math.Max (0, canvas.Width - client.Width + bar_size);
} else {
if (hscrollbar.Visible)
ScrollWindow (-scroll_position.X, 0);
@@ -232,26 +244,40 @@ private void Recalculate (bool doLayout)
if (vscroll_visible) {
vscrollbar.LargeChange = bottom_edge;
vscrollbar.SmallChange = 5;
- vscrollbar.Maximum = canvas.Height - client.Height + bar_size;
-
+ vscrollbar.Maximum = Math.Max (0, canvas.Height - client.Height + bar_size);
} else {
if (vscrollbar.Visible)
ScrollWindow (0, -scroll_position.Y);
- scroll_position.X = 0;
+ scroll_position.Y = 0;
}
SuspendLayout ();
var sizegrip_visible = hscroll_visible && vscroll_visible;
- hscrollbar.SetBounds (0, client.Height - bar_size, sizegrip_visible ? Bounds.Width - bar_size : Bounds.Height, bar_size);
+ hscrollbar.SetBounds (
+ 0,
+ client.Height - bar_size,
+ sizegrip_visible ? client.Width - bar_size : client.Width,
+ bar_size);
+
hscrollbar.Visible = hscroll_visible;
- vscrollbar.SetBounds (client.Width - bar_size, 0, bar_size, sizegrip_visible ? Bounds.Height - bar_size : Bounds.Height);
+ vscrollbar.SetBounds (
+ client.Width - bar_size,
+ 0,
+ bar_size,
+ sizegrip_visible ? client.Height - bar_size : client.Height);
+
vscrollbar.Visible = vscroll_visible;
- sizegrip.SetBounds (client.Width - bar_size, client.Height - bar_size, bar_size, bar_size);
+ sizegrip.SetBounds (
+ client.Width - bar_size,
+ client.Height - bar_size,
+ bar_size,
+ bar_size);
+
sizegrip.Visible = sizegrip_visible;
ResumeLayout (doLayout);
@@ -270,8 +296,12 @@ private void ScrollWindow (int xOffset, int yOffset)
SuspendLayout ();
- foreach (var c in Controls)
+ foreach (var c in Controls) {
+ if (IsInternalScrollControl (c))
+ continue;
+
c.Location = new Point (c.Left - xOffset, c.Top - yOffset);
+ }
scroll_position.Offset (xOffset, yOffset);
diff --git a/src/Modern.Forms/TickStyle.cs b/src/Modern.Forms/TickStyle.cs
new file mode 100644
index 0000000..d6075be
--- /dev/null
+++ b/src/Modern.Forms/TickStyle.cs
@@ -0,0 +1,13 @@
+namespace Modern.Forms
+{
+ ///
+ /// Specifies where tick marks are drawn on a TrackBar.
+ ///
+ public enum TickStyle
+ {
+ None,
+ TopLeft,
+ BottomRight,
+ Both
+ }
+}
diff --git a/src/Modern.Forms/Timer.cs b/src/Modern.Forms/Timer.cs
new file mode 100644
index 0000000..0cf7f2f
--- /dev/null
+++ b/src/Modern.Forms/Timer.cs
@@ -0,0 +1,153 @@
+using System;
+using System.ComponentModel;
+using Modern.WindowKit;
+using Modern.WindowKit.Threading;
+
+namespace Modern.Forms
+{
+ ///
+ /// Represents a timer that raises the event
+ /// at user-defined intervals on the UI thread.
+ ///
+ [DefaultProperty (nameof (Interval))]
+ [DefaultEvent (nameof (Tick))]
+ [ToolboxItemFilter ("Modern.Forms")]
+ public class Timer : Component
+ {
+ private DispatcherTimer dispatcherTimer;
+ private int interval = 100;
+ private bool enabled;
+ private EventHandler onTimer;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public Timer ()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with the specified container.
+ ///
+ public Timer (IContainer container) : this ()
+ {
+ container?.Add (this);
+ }
+
+ ///
+ /// Occurs when the timer interval has elapsed.
+ ///
+ public event EventHandler Tick {
+ add => onTimer += value;
+ remove => onTimer -= value;
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the timer is running.
+ ///
+ [DefaultValue (false)]
+ public bool Enabled {
+ get => enabled;
+ set {
+ if (enabled == value)
+ return;
+
+ enabled = value;
+
+ if (enabled)
+ StartTimer ();
+ else
+ StopTimer ();
+ }
+ }
+
+ ///
+ /// Gets or sets the interval between timer ticks in milliseconds.
+ ///
+ [DefaultValue (100)]
+ public int Interval {
+ get => interval;
+ set {
+ if (value < 1)
+ throw new ArgumentOutOfRangeException (nameof (value));
+
+ interval = value;
+
+ if (dispatcherTimer != null) {
+ dispatcherTimer.Interval = TimeSpan.FromMilliseconds (interval);
+ }
+ }
+ }
+
+ ///
+ /// Starts the timer.
+ ///
+ public void Start () => Enabled = true;
+
+ ///
+ /// Stops the timer.
+ ///
+ public void Stop () => Enabled = false;
+
+ ///
+ /// Raises the event.
+ ///
+ /// An that contains the event data.
+ protected virtual void OnTick (EventArgs e)
+ {
+ onTimer?.Invoke (this, e);
+ }
+
+ private void StartTimer ()
+ {
+ dispatcherTimer ??= new DispatcherTimer ();
+
+ dispatcherTimer.Interval = TimeSpan.FromMilliseconds (interval);
+ dispatcherTimer.Tick -= DispatcherTimer_Tick;
+ dispatcherTimer.Tick += DispatcherTimer_Tick;
+
+ dispatcherTimer.Start ();
+ }
+
+ private void StopTimer ()
+ {
+ if (dispatcherTimer != null) {
+ dispatcherTimer.Stop ();
+ }
+ }
+
+ private void DispatcherTimer_Tick (object sender, EventArgs e)
+ {
+ OnTick (EventArgs.Empty);
+ }
+
+ ///
+ /// Releases the resources used by the .
+ ///
+ ///
+ /// to release managed resources; otherwise, .
+ ///
+ protected override void Dispose (bool disposing)
+ {
+ if (disposing) {
+ StopTimer ();
+
+ if (dispatcherTimer != null) {
+ dispatcherTimer.Tick -= DispatcherTimer_Tick;
+ dispatcherTimer = null;
+ }
+ }
+
+ base.Dispose (disposing);
+ }
+
+ ///
+ /// Returns a string that represents the current timer.
+ ///
+ /// A string containing the type name and interval.
+ public override string ToString ()
+ {
+ return $"{base.ToString ()}, Interval: {Interval}";
+ }
+ }
+}
diff --git a/src/Modern.Forms/TrackBar.cs b/src/Modern.Forms/TrackBar.cs
new file mode 100644
index 0000000..b191bd6
--- /dev/null
+++ b/src/Modern.Forms/TrackBar.cs
@@ -0,0 +1,451 @@
+using System;
+using System.Drawing;
+using Modern.Forms.Renderers;
+
+namespace Modern.Forms
+{
+ ///
+ /// Represents a custom painted TrackBar control.
+ ///
+ 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 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;
+
+ ///
+ /// Initializes a new instance of the TrackBar class.
+ ///
+ public TrackBar ()
+ {
+ AutoSize = true;
+ TabStop = true;
+
+ SetAutoSizeMode (AutoSizeMode.GrowOnly);
+ SetControlBehavior (ControlBehaviors.Hoverable | ControlBehaviors.Selectable);
+ }
+
+ ///
+ /// The default ControlStyle for all instances of TrackBar.
+ ///
+ 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 => new Size (104, DEFAULT_PREFERRED_THICKNESS);
+
+ ///
+ /// Gets or sets whether the control automatically keeps its thickness
+ /// appropriate for the current orientation.
+ ///
+ 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 PageUp/PageDown is used.
+ ///
+ 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 TrackBar.
+ ///
+ public int Maximum {
+ get => maximum;
+ set {
+ if (maximum != value) {
+ maximum = value;
+
+ if (maximum < minimum)
+ minimum = maximum;
+
+ if (current_value > maximum)
+ SetValueCore (maximum, raiseScroll: false);
+
+ Invalidate ();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the minimum value of the TrackBar.
+ ///
+ public int Minimum {
+ get => minimum;
+ set {
+ if (minimum != value) {
+ minimum = value;
+
+ if (minimum > maximum)
+ maximum = minimum;
+
+ if (current_value < minimum)
+ SetValueCore (minimum, raiseScroll: false);
+
+ Invalidate ();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the orientation of the TrackBar.
+ ///
+ public Orientation Orientation {
+ get => orientation;
+ set {
+ if (orientation != value) {
+ orientation = value;
+ AdjustAutoSizeDimension ();
+ Invalidate ();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the amount by which the value changes using the arrow keys.
+ ///
+ 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 the spacing between tick marks in value units.
+ ///
+ 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;
+ Invalidate ();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets where tick marks are drawn.
+ ///
+ public TickStyle TickStyle {
+ get => tick_style;
+ set {
+ if (tick_style != value) {
+ tick_style = value;
+ Invalidate ();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the current value of the TrackBar.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// Raised when the TrackBar is scrolled.
+ ///
+ public event EventHandler? Scroll;
+
+ ///
+ /// Raised when the Value of the TrackBar changes.
+ ///
+ public event EventHandler? ValueChanged;
+
+ internal bool ThumbHovered => thumb_hovered;
+ internal bool ThumbPressed => thumb_pressed;
+
+ 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 ()
+ => RenderManager.GetRenderer ()!.GetThumbBounds (this);
+
+ private int PositionToValue (Point location)
+ => RenderManager.GetRenderer ()!.PositionToValue (this, location);
+
+ ///
+ public override Size GetPreferredSize (Size proposedSize)
+ {
+ var current = Size;
+
+ if (Orientation == Orientation.Horizontal) {
+ var width = Math.Max (current.Width, DefaultSize.Width);
+ return new Size (width, DEFAULT_PREFERRED_THICKNESS);
+ }
+
+ var height = Math.Max (current.Height, DefaultSize.Height);
+ 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
+ ? e.X - thumb_bounds.X
+ : 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 = RenderManager.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 Scroll event.
+ ///
+ protected virtual void OnScroll (EventArgs e) => Scroll?.Invoke (this, e);
+
+ ///
+ /// Raises the ValueChanged event.
+ ///
+ protected virtual void OnValueChanged (EventArgs e) => ValueChanged?.Invoke (this, e);
+
+ private void SetValueCore (int value, bool raiseScroll)
+ {
+ value = Clamp (value);
+
+ if (current_value == value) {
+ Invalidate ();
+ return;
+ }
+
+ current_value = value;
+
+ if (raiseScroll)
+ OnScroll (EventArgs.Empty);
+
+ OnValueChanged (EventArgs.Empty);
+ Invalidate ();
+ }
+ }
+}
diff --git a/tests/Modern.Forms.Tests/Modern.Forms.Tests.csproj b/tests/Modern.Forms.Tests/Modern.Forms.Tests.csproj
index 4eaa654..979d292 100644
--- a/tests/Modern.Forms.Tests/Modern.Forms.Tests.csproj
+++ b/tests/Modern.Forms.Tests/Modern.Forms.Tests.csproj
@@ -1,7 +1,7 @@
- $(_TargetFramework)
+ net10.0
false
$(NoWarn);xUnit2013