From 1db55c08afa3d8744e465d911f2708358ceb3ee9 Mon Sep 17 00:00:00 2001 From: Kiko kiko Date: Sun, 8 Mar 2026 14:37:52 +0100 Subject: [PATCH 1/4] Aktualizacja .NET 10, Timer i usprawnienia UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zmieniono docelową wersję .NET na 10.0 we wszystkich projektach. Dodano klasę Modern.Forms.Timer opartą o DispatcherTimer. Usprawniono obsługę pasków przewijania i sizegripa w ScrollableControl, eliminując błędy layoutu. Dodano dynamiczną zmianę ikony „Maksymalizuj/Przywróć” w FormTitleBar oraz wsparcie dla nowego glyphu Restore. Poprawiono widoczność obrazka formularza na pasku tytułu. --- samples/ControlGallery/ControlGallery.csproj | 2 +- samples/Explorer/Explore.csproj | 2 +- samples/Outlaw/Outlaw.csproj | 2 +- src/Modern.Forms/ControlPaint.cs | 25 +++ src/Modern.Forms/FormTitleBar.cs | 75 ++++++++- src/Modern.Forms/Modern.Forms.csproj | 2 +- src/Modern.Forms/ScrollableControl.cs | 58 +++++-- src/Modern.Forms/Timer.cs | 153 ++++++++++++++++++ .../Modern.Forms.Tests.csproj | 2 +- 9 files changed, 296 insertions(+), 25 deletions(-) create mode 100644 src/Modern.Forms/Timer.cs 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/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/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/FormTitleBar.cs b/src/Modern.Forms/FormTitleBar.cs index 40bd552..061630f 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; /// @@ -39,8 +39,13 @@ public FormTitleBar () maximize_button.Click += (o, e) => { var form = FindForm (); - if (form != null) - form.WindowState = form.WindowState == FormWindowState.Maximized ? FormWindowState.Normal : FormWindowState.Maximized; + if (form != null) { + form.WindowState = form.WindowState == FormWindowState.Maximized + ? FormWindowState.Normal + : FormWindowState.Maximized; + + UpdateMaximizeButtonGlyph (); + } }; close_button = Controls.AddImplicitControl (new TitleBarButton (TitleBarButton.TitleBarButtonGlyph.Close)); @@ -64,6 +69,7 @@ public bool AllowMaximize { get => maximize_button.Visible; set { maximize_button.Visible = value; + UpdateMaximizeButtonGlyph (); Invalidate (); // TODO: Shouldn't be necessary, should automatically be triggered } } @@ -114,6 +120,8 @@ protected override void OnMouseDown (MouseEventArgs e) /// protected override void OnPaint (PaintEventArgs e) { + UpdateMaximizeButtonGlyph (); + base.OnPaint (e); RenderManager.Render (this, e); @@ -126,6 +134,8 @@ protected override void OnSizeChanged (EventArgs e) // Keep our form image a square form_image.Width = Height; + + UpdateMaximizeButtonGlyph (); } /// @@ -136,7 +146,7 @@ public bool ShowImage { set { if (show_image != value) { show_image = value; - form_image.Visible = value && form_image is not null; + form_image.Visible = value && form_image.Image is not null; Invalidate (); // TODO: Shouldn't be required } } @@ -145,12 +155,42 @@ public bool ShowImage { /// public override ControlStyle Style { get; } = new ControlStyle (DefaultStyle); + 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 +202,7 @@ public TitleBarButton (TitleBarButtonGlyph glyph) StyleHover.Border.Width = 0; } + /// protected override void OnPaint (PaintEventArgs e) { base.OnPaint (e); @@ -183,14 +224,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/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/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/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/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 From 051d2c3af57f714fcd8dd1bef431b53876538752 Mon Sep 17 00:00:00 2001 From: Kiko kiko Date: Sun, 8 Mar 2026 16:08:21 +0100 Subject: [PATCH 2/4] =?UTF-8?q?Dodaj=20w=C5=82asny=20ColorDialog=20z=20obs?= =?UTF-8?q?=C5=82ug=C4=85=20HSV/HSL/ARGB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dodano własny panel i okno dialogowe wyboru koloru (ColorDialog, ColorDialogForm) z nowoczesnym interfejsem, obsługą modeli kolorów HSV/HSL/ARGB oraz podglądem. Wprowadzono nowe kontrolki ColorBox, HueSlider i TrackBar wraz z rendererami i obsługą zdarzeń. Rozszerzono RenderManager o nowe renderery. Dodano obsługę podwójnego kliknięcia w pasek tytułu do maksymalizacji okna. Uzupełniono MainForm o nowy panel demonstracyjny. --- samples/ControlGallery/MainForm.cs | 4 + .../ControlGallery/Panels/ColorDialogPanel.cs | 46 ++ src/Modern.Forms/ColorBox.cs | 144 ++++++ src/Modern.Forms/ColorDialog.cs | 27 ++ src/Modern.Forms/ColorDialogForm.cs | 277 +++++++++++ src/Modern.Forms/ColorHelper.cs | 139 ++++++ src/Modern.Forms/FormTitleBar.cs | 61 ++- src/Modern.Forms/HueSlider.cs | 106 ++++ .../Renderers/ColorBoxRenderer.cs | 95 ++++ .../Renderers/HueSliderRenderer.cs | 82 ++++ src/Modern.Forms/Renderers/RenderManager.cs | 3 + .../Renderers/TrackBarRenderer.cs | 241 ++++++++++ src/Modern.Forms/TickStyle.cs | 13 + src/Modern.Forms/TrackBar.cs | 451 ++++++++++++++++++ 14 files changed, 1677 insertions(+), 12 deletions(-) create mode 100644 samples/ControlGallery/Panels/ColorDialogPanel.cs create mode 100644 src/Modern.Forms/ColorBox.cs create mode 100644 src/Modern.Forms/ColorDialog.cs create mode 100644 src/Modern.Forms/ColorDialogForm.cs create mode 100644 src/Modern.Forms/ColorHelper.cs create mode 100644 src/Modern.Forms/HueSlider.cs create mode 100644 src/Modern.Forms/Renderers/ColorBoxRenderer.cs create mode 100644 src/Modern.Forms/Renderers/HueSliderRenderer.cs create mode 100644 src/Modern.Forms/Renderers/TrackBarRenderer.cs create mode 100644 src/Modern.Forms/TickStyle.cs create mode 100644 src/Modern.Forms/TrackBar.cs diff --git a/samples/ControlGallery/MainForm.cs b/samples/ControlGallery/MainForm.cs index 5e88d0d..ae51ffa 100644 --- a/samples/ControlGallery/MainForm.cs +++ b/samples/ControlGallery/MainForm.cs @@ -53,12 +53,14 @@ 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.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 +147,8 @@ private void Tree_ItemSelected (object? sender, EventArgs e) return new ToolBarPanel (); case "TreeView": return new TreeViewPanel (); + case "ColorDialog": + return new ColorDialogPanel(); } 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/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..2b74511 --- /dev/null +++ b/src/Modern.Forms/ColorDialogForm.cs @@ -0,0 +1,277 @@ +using System; +using System.Drawing; +using SkiaSharp; + +namespace Modern.Forms +{ + internal class ColorDialogForm : Form + { + public SKColor SelectedColor { get; private set; } + + 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 ColorDialogForm (SKColor initialColor) + { + Text = "Select Color"; + 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, 20), + Size = new Size (320, 320) + }; + colorBox.SetColorComponents (hue, saturation, value); + + hueSlider = new HueSlider { + Location = new Point (350, 20), + Size = new Size (28, 320) + }; + hueSlider.SetHueSilently (hue); + + var rightColumnX = 400; + + var oldLabel = CreateCaptionLabel ("Current", rightColumnX, 20); + oldPreview = CreatePreviewPanel (rightColumnX, 42, initialColor); + + var newLabel = CreateCaptionLabel ("New", rightColumnX + 90, 20); + newPreview = CreatePreviewPanel (rightColumnX + 90, 42, initialColor); + + var valuesTitle = CreateCaptionLabel ("Values", rightColumnX, 110); + valuesTitle.Width = 220; + + var argbLabel = CreateCaptionLabel ("ARGB:", rightColumnX, 140); + argbValueLabel = CreateValueLabel (rightColumnX + 55, 140, 260); + + var hexLabel = CreateCaptionLabel ("Hex:", rightColumnX, 168); + hexValueLabel = CreateValueLabel (rightColumnX + 55, 168, 260); + + var hsvLabel = CreateCaptionLabel ("HSV:", rightColumnX, 196); + hsvValueLabel = CreateValueLabel (rightColumnX + 55, 196, 260); + + var hslLabel = CreateCaptionLabel ("HSL:", rightColumnX, 224); + hslValueLabel = CreateValueLabel (rightColumnX + 55, 224, 260); + + var slidersTop = 360; + + aTrackBar = CreateChannelTrackBar (50, slidersTop); + rTrackBar = CreateChannelTrackBar (50, slidersTop + 32); + gTrackBar = CreateChannelTrackBar (50, slidersTop + 64); + bTrackBar = CreateChannelTrackBar (50, slidersTop + 96); + + Controls.Add (CreateCaptionLabel ("A", 20, slidersTop + 6)); + Controls.Add (CreateCaptionLabel ("R", 20, slidersTop + 38)); + Controls.Add (CreateCaptionLabel ("G", 20, slidersTop + 70)); + Controls.Add (CreateCaptionLabel ("B", 20, slidersTop + 102)); + + okButton = new Button { + Text = "OK", + Location = new Point (560, 440), + Size = new Size (80, 30) + }; + + cancelButton = new Button { + Text = "Cancel", + Location = new Point (650, 440), + 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 hue, out saturation, out value); + + 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 = color; + 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/FormTitleBar.cs b/src/Modern.Forms/FormTitleBar.cs index 061630f..1363941 100644 --- a/src/Modern.Forms/FormTitleBar.cs +++ b/src/Modern.Forms/FormTitleBar.cs @@ -37,15 +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; - - UpdateMaximizeButtonGlyph (); - } + ToggleMaximizeRestore (); }; close_button = Controls.AddImplicitControl (new TitleBarButton (TitleBarButton.TitleBarButtonGlyph.Close)); @@ -70,7 +62,7 @@ public bool AllowMaximize { set { maximize_button.Visible = value; UpdateMaximizeButtonGlyph (); - Invalidate (); // TODO: Shouldn't be necessary, should automatically be triggered + Invalidate (); } } @@ -81,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 (); } } @@ -107,16 +99,47 @@ 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) { @@ -147,7 +170,7 @@ public bool ShowImage { if (show_image != value) { show_image = value; form_image.Visible = value && form_image.Image is not null; - Invalidate (); // TODO: Shouldn't be required + Invalidate (); } } } @@ -155,6 +178,20 @@ 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 (); diff --git a/src/Modern.Forms/HueSlider.cs b/src/Modern.Forms/HueSlider.cs new file mode 100644 index 0000000..22f76bd --- /dev/null +++ b/src/Modern.Forms/HueSlider.cs @@ -0,0 +1,106 @@ +using System; +using System.Drawing; +using Modern.Forms.Renderers; + +namespace Modern.Forms +{ + /// + /// Vertical hue selection slider. + /// + 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); + + hue = 360f - (percent * 360f); + if (hue >= 360f) + hue = 0f; + + HueChanged?.Invoke (this, EventArgs.Empty); + Invalidate (); + } + } +} diff --git a/src/Modern.Forms/Renderers/ColorBoxRenderer.cs b/src/Modern.Forms/Renderers/ColorBoxRenderer.cs new file mode 100644 index 0000000..f9eede9 --- /dev/null +++ b/src/Modern.Forms/Renderers/ColorBoxRenderer.cs @@ -0,0 +1,95 @@ +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; + + using (var borderPaint = new SKPaint { + IsAntialias = true, + Style = SKPaintStyle.Stroke, + Color = Theme.BorderLowColor + }) + using (var basePaint = new SKPaint { IsAntialias = false }) + using (var whiteOverlayPaint = new SKPaint { IsAntialias = false }) + using (var blackOverlayPaint = new SKPaint { IsAntialias = false }) { + var rect = ToRect (bounds); + + canvas.DrawRect (rect, borderPaint); + + var innerRect = new SKRect (rect.Left + 1, rect.Top + 1, rect.Right - 1, rect.Bottom - 1); + var hueColor = ColorHelper.FromHsv (control.Hue, 1f, 1f, 255); + + basePaint.Color = hueColor; + canvas.DrawRect (innerRect, basePaint); + + whiteOverlayPaint.Shader = SKShader.CreateLinearGradient ( + new SKPoint (innerRect.Left, innerRect.Top), + new SKPoint (innerRect.Right, innerRect.Top), + new[] { SKColors.White, new SKColor (255, 255, 255, 0) }, + null, + SKShaderTileMode.Clamp); + + canvas.DrawRect (innerRect, whiteOverlayPaint); + + blackOverlayPaint.Shader = SKShader.CreateLinearGradient ( + new SKPoint (innerRect.Left, innerRect.Top), + new SKPoint (innerRect.Left, innerRect.Bottom), + new[] { new SKColor (0, 0, 0, 0), SKColors.Black }, + null, + SKShaderTileMode.Clamp); + + canvas.DrawRect (innerRect, blackOverlayPaint); + } + + 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/HueSliderRenderer.cs b/src/Modern.Forms/Renderers/HueSliderRenderer.cs new file mode 100644 index 0000000..c71433a --- /dev/null +++ b/src/Modern.Forms/Renderers/HueSliderRenderer.cs @@ -0,0 +1,82 @@ +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), + new SKColor(255, 255, 0), + new SKColor(0, 255, 0), + new SKColor(0, 255, 255), + new SKColor(0, 0, 255), + new SKColor(255, 0, 255), + new SKColor(255, 0, 0) + }, + 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) + { + float percent = 1f - (control.Hue / 360f); + if (control.Hue <= 0f) + percent = 1f; + + float y = bounds.Top + percent * System.Math.Max (1, bounds.Height - 1); + + using var linePaint = new SKPaint { + IsAntialias = true, + Color = SKColors.White, + StrokeWidth = 2 + }; + + using var outlinePaint = new SKPaint { + IsAntialias = true, + Color = SKColors.Black, + StrokeWidth = 1 + }; + + e.Canvas.DrawLine (bounds.Left - 2, y, bounds.Right + 2, y, outlinePaint); + e.Canvas.DrawLine (bounds.Left - 1, y, bounds.Right + 1, y, linePaint); + } + } +} diff --git a/src/Modern.Forms/Renderers/RenderManager.cs b/src/Modern.Forms/Renderers/RenderManager.cs index 9e5ed3c..3a1f7ff 100644 --- a/src/Modern.Forms/Renderers/RenderManager.cs +++ b/src/Modern.Forms/Renderers/RenderManager.cs @@ -38,6 +38,9 @@ static RenderManager () SetRenderer (new TextBoxRenderer ()); SetRenderer (new ToolBarRenderer ()); SetRenderer (new TreeViewRenderer ()); + SetRenderer (new TrackBarRenderer ()); + SetRenderer (new ColorBoxRenderer ()); + SetRenderer (new HueSliderRenderer ()); } /// 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/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/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 (); + } + } +} From d38979a5ae379418df46b44bf33f6d8a5e56b178 Mon Sep 17 00:00:00 2001 From: Kiko kiko Date: Sun, 8 Mar 2026 16:41:17 +0100 Subject: [PATCH 3/4] Ulepszenia ColorDialogForm: UI, lokalizacja, precyzja MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wprowadzono szereg poprawek do formularza wyboru koloru oraz powiązanych komponentów: - Dodano możliwość ustawiania tekstów formularza i przycisków dla łatwiejszej lokalizacji. - Poprawiono rozmieszczenie i wygląd elementów UI (ColorBox, HueSlider, suwaki ARGB, panele podglądu). - Oryginalny kolor jest przechowywany oddzielnie, co poprawia działanie podglądu "Current". - Ulepszono synchronizację HSV, eliminując nieoczekiwane przeskoki odcienia. - Zmieniono logikę suwaka odcienia: 0° na górze, 360° na dole, poprawiono marker i gradient. - Przepisano renderowanie ColorBox na bardziej przejrzyste i wydajne. - Zmiany poprawiają ergonomię, precyzję i przygotowują kod pod lokalizację. --- src/Modern.Forms/ColorDialogForm.cs | 76 +++++++++++------- src/Modern.Forms/HueSlider.cs | 10 ++- .../Renderers/ColorBoxRenderer.cs | 77 ++++++++++--------- .../Renderers/HueSliderRenderer.cs | 36 ++++----- 4 files changed, 113 insertions(+), 86 deletions(-) diff --git a/src/Modern.Forms/ColorDialogForm.cs b/src/Modern.Forms/ColorDialogForm.cs index 2b74511..a581e2b 100644 --- a/src/Modern.Forms/ColorDialogForm.cs +++ b/src/Modern.Forms/ColorDialogForm.cs @@ -8,6 +8,8 @@ internal class ColorDialogForm : Form { public SKColor SelectedColor { get; private set; } + private readonly SKColor originalColor; + private readonly ColorBox colorBox; private readonly HueSlider hueSlider; @@ -32,9 +34,18 @@ internal class ColorDialogForm : Form 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) { - Text = "Select Color"; + originalColor = initialColor; + SelectedColor = initialColor; + Text = TextForm; Size = new Size (760, 520); StartPosition = FormStartPosition.CenterParent; Resizeable = false; @@ -45,61 +56,61 @@ public ColorDialogForm (SKColor initialColor) ColorHelper.ToHsv (initialColor, out hue, out saturation, out value); colorBox = new ColorBox { - Location = new Point (20, 20), + Location = new Point (20, 40), Size = new Size (320, 320) }; colorBox.SetColorComponents (hue, saturation, value); hueSlider = new HueSlider { - Location = new Point (350, 20), + Location = new Point (350, 40), Size = new Size (28, 320) }; hueSlider.SetHueSilently (hue); var rightColumnX = 400; - var oldLabel = CreateCaptionLabel ("Current", rightColumnX, 20); - oldPreview = CreatePreviewPanel (rightColumnX, 42, initialColor); + var oldLabel = CreateCaptionLabel (TextCurrent, rightColumnX, 40); + oldPreview = CreatePreviewPanel (rightColumnX, 62, initialColor); - var newLabel = CreateCaptionLabel ("New", rightColumnX + 90, 20); - newPreview = CreatePreviewPanel (rightColumnX + 90, 42, initialColor); + var newLabel = CreateCaptionLabel (TextNew, rightColumnX + 90, 40); + newPreview = CreatePreviewPanel (rightColumnX + 90, 62, initialColor); - var valuesTitle = CreateCaptionLabel ("Values", rightColumnX, 110); + var valuesTitle = CreateCaptionLabel (TextValues, rightColumnX, 130); valuesTitle.Width = 220; - var argbLabel = CreateCaptionLabel ("ARGB:", rightColumnX, 140); - argbValueLabel = CreateValueLabel (rightColumnX + 55, 140, 260); + var argbLabel = CreateCaptionLabel ("ARGB:", rightColumnX, 160); + argbValueLabel = CreateValueLabel (rightColumnX + 55, 160, 260); - var hexLabel = CreateCaptionLabel ("Hex:", rightColumnX, 168); - hexValueLabel = CreateValueLabel (rightColumnX + 55, 168, 260); + var hexLabel = CreateCaptionLabel ("Hex:", rightColumnX, 188); + hexValueLabel = CreateValueLabel (rightColumnX + 55, 188, 260); - var hsvLabel = CreateCaptionLabel ("HSV:", rightColumnX, 196); - hsvValueLabel = CreateValueLabel (rightColumnX + 55, 196, 260); + var hsvLabel = CreateCaptionLabel ("HSV:", rightColumnX, 216); + hsvValueLabel = CreateValueLabel (rightColumnX + 55, 216, 260); - var hslLabel = CreateCaptionLabel ("HSL:", rightColumnX, 224); - hslValueLabel = CreateValueLabel (rightColumnX + 55, 224, 260); + var hslLabel = CreateCaptionLabel ("HSL:", rightColumnX, 244); + hslValueLabel = CreateValueLabel (rightColumnX + 55, 244, 260); var slidersTop = 360; aTrackBar = CreateChannelTrackBar (50, slidersTop); - rTrackBar = CreateChannelTrackBar (50, slidersTop + 32); - gTrackBar = CreateChannelTrackBar (50, slidersTop + 64); - bTrackBar = CreateChannelTrackBar (50, slidersTop + 96); + rTrackBar = CreateChannelTrackBar (50, slidersTop + 52); + gTrackBar = CreateChannelTrackBar (50, slidersTop + 84); + bTrackBar = CreateChannelTrackBar (50, slidersTop + 116); - Controls.Add (CreateCaptionLabel ("A", 20, slidersTop + 6)); - Controls.Add (CreateCaptionLabel ("R", 20, slidersTop + 38)); - Controls.Add (CreateCaptionLabel ("G", 20, slidersTop + 70)); - Controls.Add (CreateCaptionLabel ("B", 20, slidersTop + 102)); + 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 = "OK", - Location = new Point (560, 440), + Text = TextButtonOk, + Location = new Point (560, 460), Size = new Size (80, 30) }; cancelButton = new Button { - Text = "Cancel", - Location = new Point (650, 440), + Text = TextButtonCancel, + Location = new Point (650, 460), Size = new Size (80, 30) }; @@ -193,7 +204,14 @@ private void ApplyColorToUi (SKColor color, bool updateOriginalPreview) try { SelectedColor = color; - ColorHelper.ToHsv (color, out hue, out saturation, out value); + 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); @@ -204,7 +222,7 @@ private void ApplyColorToUi (SKColor color, bool updateOriginalPreview) bTrackBar.Value = color.Blue; if (updateOriginalPreview) { - oldPreview.Style.BackgroundColor = color; + oldPreview.Style.BackgroundColor = originalColor; oldPreview.Invalidate (); } diff --git a/src/Modern.Forms/HueSlider.cs b/src/Modern.Forms/HueSlider.cs index 22f76bd..dc172a8 100644 --- a/src/Modern.Forms/HueSlider.cs +++ b/src/Modern.Forms/HueSlider.cs @@ -6,6 +6,7 @@ namespace Modern.Forms { /// /// Vertical hue selection slider. + /// Top = 0° (red), bottom = 360° (red again). /// public class HueSlider : Control { @@ -95,10 +96,13 @@ private void UpdateFromPoint (Point location) float percent = (location.Y - bounds.Top) / (float)Math.Max (1, bounds.Height - 1); percent = ColorHelper.Clamp01 (percent); - hue = 360f - (percent * 360f); - if (hue >= 360f) - hue = 0f; + // 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/Renderers/ColorBoxRenderer.cs b/src/Modern.Forms/Renderers/ColorBoxRenderer.cs index f9eede9..92d6392 100644 --- a/src/Modern.Forms/Renderers/ColorBoxRenderer.cs +++ b/src/Modern.Forms/Renderers/ColorBoxRenderer.cs @@ -13,43 +13,50 @@ protected override void Render (ColorBox control, PaintEventArgs e) return; var canvas = e.Canvas; + var rect = new SKRect (bounds.Left, bounds.Top, bounds.Right, bounds.Bottom); - using (var borderPaint = new SKPaint { - IsAntialias = true, + 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 - }) - using (var basePaint = new SKPaint { IsAntialias = false }) - using (var whiteOverlayPaint = new SKPaint { IsAntialias = false }) - using (var blackOverlayPaint = new SKPaint { IsAntialias = false }) { - var rect = ToRect (bounds); - - canvas.DrawRect (rect, borderPaint); - - var innerRect = new SKRect (rect.Left + 1, rect.Top + 1, rect.Right - 1, rect.Bottom - 1); - var hueColor = ColorHelper.FromHsv (control.Hue, 1f, 1f, 255); - - basePaint.Color = hueColor; - canvas.DrawRect (innerRect, basePaint); - - whiteOverlayPaint.Shader = SKShader.CreateLinearGradient ( - new SKPoint (innerRect.Left, innerRect.Top), - new SKPoint (innerRect.Right, innerRect.Top), - new[] { SKColors.White, new SKColor (255, 255, 255, 0) }, - null, - SKShaderTileMode.Clamp); - - canvas.DrawRect (innerRect, whiteOverlayPaint); - - blackOverlayPaint.Shader = SKShader.CreateLinearGradient ( - new SKPoint (innerRect.Left, innerRect.Top), - new SKPoint (innerRect.Left, innerRect.Bottom), - new[] { new SKColor (0, 0, 0, 0), SKColors.Black }, - null, - SKShaderTileMode.Clamp); - - canvas.DrawRect (innerRect, blackOverlayPaint); - } + 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); } diff --git a/src/Modern.Forms/Renderers/HueSliderRenderer.cs b/src/Modern.Forms/Renderers/HueSliderRenderer.cs index c71433a..12515a2 100644 --- a/src/Modern.Forms/Renderers/HueSliderRenderer.cs +++ b/src/Modern.Forms/Renderers/HueSliderRenderer.cs @@ -25,13 +25,13 @@ protected override void Render (HueSlider control, PaintEventArgs e) new SKPoint (rect.Left, rect.Bottom), new[] { - new SKColor(255, 0, 0), - new SKColor(255, 255, 0), - new SKColor(0, 255, 0), - new SKColor(0, 255, 255), - new SKColor(0, 0, 255), - new SKColor(255, 0, 255), - new SKColor(255, 0, 0) + 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); @@ -57,26 +57,24 @@ public Rectangle GetContentBounds (HueSlider control, PaintEventArgs? e) private void DrawMarker (HueSlider control, PaintEventArgs e, Rectangle bounds) { - float percent = 1f - (control.Hue / 360f); - if (control.Hue <= 0f) - percent = 1f; - + // Top = 0°, bottom = 360°. + float percent = control.Hue / 360f; float y = bounds.Top + percent * System.Math.Max (1, bounds.Height - 1); - using var linePaint = new SKPaint { + using var outlinePaint = new SKPaint { IsAntialias = true, - Color = SKColors.White, - StrokeWidth = 2 + Color = SKColors.Black, + StrokeWidth = 3 }; - using var outlinePaint = new SKPaint { + using var linePaint = new SKPaint { IsAntialias = true, - Color = SKColors.Black, - StrokeWidth = 1 + Color = SKColors.White, + StrokeWidth = 1.5f }; - e.Canvas.DrawLine (bounds.Left - 2, y, bounds.Right + 2, y, outlinePaint); - e.Canvas.DrawLine (bounds.Left - 1, y, bounds.Right + 1, y, linePaint); + e.Canvas.DrawLine (bounds.Left - 3, y, bounds.Right + 3, y, outlinePaint); + e.Canvas.DrawLine (bounds.Left - 2, y, bounds.Right + 2, y, linePaint); } } } From fc7bd836ee1c8b6b8ad6730b3080848636244522 Mon Sep 17 00:00:00 2001 From: Kiko kiko Date: Fri, 13 Mar 2026 19:38:38 +0100 Subject: [PATCH 4/4] Add DateTimePicker control with custom popup calendar, day/month/year navigation, localized week layout, and renderer updates ControlGallery sample was also updated to showcase the new control. --- samples/ControlGallery/MainForm.cs | 5 +- .../Panels/DateTimePickerPanel.cs | 21 + src/Modern.Forms/DateTimePicker.cs | 792 ++++++++++++++++++ src/Modern.Forms/DateTimePickerCalendar.cs | 423 ++++++++++ .../DateTimePickerCalendarViewMode.cs | 9 + src/Modern.Forms/DateTimePickerFormat.cs | 28 + .../DateTimePickerCalendarRenderer.cs | 313 +++++++ .../Renderers/DateTimePickerRenderer.cs | 345 ++++++++ src/Modern.Forms/Renderers/RenderManager.cs | 2 + 9 files changed, 1937 insertions(+), 1 deletion(-) create mode 100644 samples/ControlGallery/Panels/DateTimePickerPanel.cs create mode 100644 src/Modern.Forms/DateTimePicker.cs create mode 100644 src/Modern.Forms/DateTimePickerCalendar.cs create mode 100644 src/Modern.Forms/DateTimePickerCalendarViewMode.cs create mode 100644 src/Modern.Forms/DateTimePickerFormat.cs create mode 100644 src/Modern.Forms/Renderers/DateTimePickerCalendarRenderer.cs create mode 100644 src/Modern.Forms/Renderers/DateTimePickerRenderer.cs diff --git a/samples/ControlGallery/MainForm.cs b/samples/ControlGallery/MainForm.cs index ae51ffa..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; @@ -54,6 +54,7 @@ public MainForm () 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); @@ -149,6 +150,8 @@ private void Tree_ItemSelected (object? sender, EventArgs e) return new TreeViewPanel (); case "ColorDialog": return new ColorDialogPanel(); + case "DateTimePicker": + return new DateTimePickerPanel(); } return null; 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/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/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/RenderManager.cs b/src/Modern.Forms/Renderers/RenderManager.cs index 3a1f7ff..27e5520 100644 --- a/src/Modern.Forms/Renderers/RenderManager.cs +++ b/src/Modern.Forms/Renderers/RenderManager.cs @@ -41,6 +41,8 @@ static RenderManager () SetRenderer (new TrackBarRenderer ()); SetRenderer (new ColorBoxRenderer ()); SetRenderer (new HueSliderRenderer ()); + SetRenderer (new DateTimePickerRenderer ()); + SetRenderer (new DateTimePickerCalendarRenderer ()); } ///