diff --git a/samples/ControlGallery/MainForm.cs b/samples/ControlGallery/MainForm.cs index e6dbad6..2957d95 100644 --- a/samples/ControlGallery/MainForm.cs +++ b/samples/ControlGallery/MainForm.cs @@ -22,6 +22,7 @@ public MainForm () tree.Items.Add ("Button", ImageLoader.Get ("button.png")); tree.Items.Add ("CheckBox", ImageLoader.Get ("button.png")); tree.Items.Add ("ComboBox", ImageLoader.Get ("button.png")); + tree.Items.Add ("DataGridView", ImageLoader.Get ("button.png")); tree.Items.Add ("Dialogs", ImageLoader.Get ("button.png")); tree.Items.Add ("FileDialogs", ImageLoader.Get ("button.png")); tree.Items.Add ("FlowLayoutPanel", ImageLoader.Get ("button.png")); @@ -90,6 +91,8 @@ private void Tree_ItemSelected (object? sender, EventArgs e) return new CheckBoxPanel (); case "ComboBox": return new ComboBoxPanel (); + case "DataGridView": + return new DataGridViewPanel (); case "Dialogs": return new DialogPanel (); case "FileDialogs": diff --git a/samples/ControlGallery/Panels/DataGridViewPanel.cs b/samples/ControlGallery/Panels/DataGridViewPanel.cs new file mode 100644 index 0000000..7c1ddbb --- /dev/null +++ b/samples/ControlGallery/Panels/DataGridViewPanel.cs @@ -0,0 +1,92 @@ +using Modern.Forms; +using SkiaSharp; + +namespace ControlGallery.Panels +{ + public class DataGridViewPanel : BasePanel + { + public DataGridViewPanel () + { + Controls.Add (new Label { Text = "DataGridView - Cell Selection with Row Headers (Tab/Shift-Tab to navigate, double-click or F2 to edit)", Left = 10, Top = 10, Width = 760 }); + + var dgv1 = new DataGridView { + Left = 10, + Top = 30, + Width = 750, + Height = 250, + SelectionMode = DataGridViewSelectionMode.CellSelect, + ColumnHeadersHeight = 36, + RowHeadersVisible = true, + RowHeadersWidth = 30 + }; + + // Customize column header style + dgv1.ColumnHeadersDefaultCellStyle.BackgroundColor = Theme.AccentColor; + dgv1.ColumnHeadersDefaultCellStyle.ForegroundColor = SKColors.White; + + // Customize alternating row style + dgv1.AlternatingRowsDefaultCellStyle.BackgroundColor = new SKColor (150, 200, 225); + + dgv1.Columns.Add ("Name", 150); + dgv1.Columns.Add ("Age", 60); + dgv1.Columns.Add ("City", 120); + dgv1.Columns.Add ("Occupation", 150); + dgv1.Columns.Add ("Email", 200); + + dgv1.Rows.Add ("Alice Johnson", "32", "New York", "Engineer", "alice@example.com"); + dgv1.Rows.Add ("Bob Smith", "45", "Los Angeles", "Designer", "bob@example.com"); + dgv1.Rows.Add ("Carol Williams", "28", "Chicago", "Teacher", "carol@example.com"); + dgv1.Rows.Add ("David Brown", "51", "Houston", "Doctor", "david@example.com"); + dgv1.Rows.Add ("Eve Davis", "39", "Phoenix", "Lawyer", "eve@example.com"); + dgv1.Rows.Add ("Frank Miller", "22", "Philadelphia", "Student", "frank@example.com"); + dgv1.Rows.Add ("Grace Wilson", "36", "San Antonio", "Architect", "grace@example.com"); + dgv1.Rows.Add ("Henry Moore", "48", "San Diego", "Manager", "henry@example.com"); + dgv1.Rows.Add ("Ivy Taylor", "31", "Dallas", "Analyst", "ivy@example.com"); + dgv1.Rows.Add ("Jack Anderson", "55", "San Jose", "Director", "jack@example.com"); + dgv1.Rows.Add ("Karen Thomas", "42", "Austin", "Consultant", "karen@example.com"); + dgv1.Rows.Add ("Leo Jackson", "27", "Jacksonville", "Developer", "leo@example.com"); + dgv1.Rows.Add ("Mia White", "34", "Fort Worth", "Scientist", "mia@example.com"); + dgv1.Rows.Add ("Noah Harris", "60", "Columbus", "Professor", "noah@example.com"); + dgv1.Rows.Add ("Olivia Martin", "25", "Charlotte", "Intern", "olivia@example.com"); + + dgv1.SelectedRowIndex = 2; + dgv1.SelectedColumnIndex = 0; + + Controls.Add (dgv1); + + Controls.Add (new Label { Text = "DataGridView - Full Row Selection + Data Binding (column resizing disabled)", Left = 10, Top = 300, Width = 500 }); + + var dgv3 = new DataGridView { + Left = 10, + Top = 320, + Width = 500, + Height = 200, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + AllowUserToResizeColumns = false + }; + + var products = new List { + new Product { Name = "Widget", Price = 9.99, Quantity = 100, Category = "Hardware" }, + new Product { Name = "Gadget", Price = 24.95, Quantity = 50, Category = "Electronics" }, + new Product { Name = "Doohickey", Price = 4.50, Quantity = 200, Category = "Hardware" }, + new Product { Name = "Thingamajig", Price = 15.00, Quantity = 75, Category = "Electronics" }, + new Product { Name = "Whatchamacallit", Price = 7.25, Quantity = 150, Category = "Misc" }, + new Product { Name = "Contraption", Price = 49.99, Quantity = 10, Category = "Electronics" }, + new Product { Name = "Gizmo", Price = 12.50, Quantity = 80, Category = "Hardware" }, + new Product { Name = "Doodad", Price = 3.75, Quantity = 300, Category = "Misc" } + }; + + dgv3.DataSource = products; + + Controls.Add (dgv3); + } + + private sealed class Product + { + public string Name { get; set; } = string.Empty; + public double Price { get; set; } + public int Quantity { get; set; } + public string Category { get; set; } = string.Empty; + } + } +} diff --git a/src/Modern.Forms/Control.Events.cs b/src/Modern.Forms/Control.Events.cs index 40e11c5..5e100ae 100644 --- a/src/Modern.Forms/Control.Events.cs +++ b/src/Modern.Forms/Control.Events.cs @@ -20,6 +20,7 @@ public partial class Control private static readonly object s_doubleClickEvent = new object (); private static readonly object s_enabledChangedEvent = new object (); private static readonly object s_gotFocusEvent = new object (); + private static readonly object s_lostFocusEvent = new object (); private static readonly object s_invalidatedEvent = new object (); private static readonly object s_keyDownEvent = new object (); private static readonly object s_keyPressEvent = new object (); @@ -130,6 +131,14 @@ public event EventHandler>? Invalidated { remove => Events.RemoveHandler (s_invalidatedEvent, value); } + /// + /// Raised when the control loses focus. + /// + public event EventHandler? LostFocus { + add => Events.AddHandler (s_lostFocusEvent, value); + remove => Events.RemoveHandler (s_lostFocusEvent, value); + } + /// /// Raised when the user presses down a key. /// diff --git a/src/Modern.Forms/Control.cs b/src/Modern.Forms/Control.cs index 35f5c37..daa4e32 100644 --- a/src/Modern.Forms/Control.cs +++ b/src/Modern.Forms/Control.cs @@ -990,7 +990,10 @@ protected virtual void OnCreateControl () /// /// Called when the control is deselected. /// - protected virtual void OnDeselected (EventArgs e) { } + protected virtual void OnDeselected (EventArgs e) + { + OnLostFocus (e); + } /// /// Raises the DoubleClick event. @@ -1023,6 +1026,11 @@ protected virtual void OnEnabledChanged (EventArgs e) /// protected virtual void OnInvalidated (EventArgs e) => (Events[s_invalidatedEvent] as EventHandler>)?.Invoke (this, e); + /// + /// Raises the LostFocus event. + /// + protected virtual void OnLostFocus (EventArgs e) => (Events[s_lostFocusEvent] as EventHandler)?.Invoke (this, e); + /// /// Raises the KeyDown event. /// diff --git a/src/Modern.Forms/DataGridView.cs b/src/Modern.Forms/DataGridView.cs new file mode 100644 index 0000000..2e23909 --- /dev/null +++ b/src/Modern.Forms/DataGridView.cs @@ -0,0 +1,1358 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Reflection; +using Modern.Forms.Renderers; + +namespace Modern.Forms +{ + /// + /// Represents a DataGridView control for displaying tabular data. + /// + public class DataGridView : Control + { + private int header_height = 30; + private int row_height = 25; + private int row_headers_width = 40; + private bool row_headers_visible; + private int top_index; + private int horizontal_scroll_offset; + private int selected_row_index = -1; + private int selected_column_index = -1; + private int hovered_row_index = -1; + private int resize_column_index = -1; + private int resize_row_index = -1; + private int resize_start_x; + private int resize_start_y; + private int resize_start_width; + private int resize_start_height; + private bool is_resizing_column; + private bool is_resizing_row; + private bool column_headers_visible = true; + private DataGridViewSelectionMode selection_mode = DataGridViewSelectionMode.FullRowSelect; + private bool read_only; + private IList? data_source; + private TextBox? edit_textbox; + private int editing_row_index = -1; + private int editing_column_index = -1; + + private readonly VerticalScrollBar vscrollbar; + private readonly HorizontalScrollBar hscrollbar; + + /// + /// Initializes a new instance of the DataGridView class. + /// + public DataGridView () + { + Columns = new DataGridViewColumnCollection (this); + Rows = new DataGridViewRowCollection (this); + + vscrollbar = new VerticalScrollBar { + Minimum = 0, + Maximum = 0, + SmallChange = 1, + LargeChange = 1, + Visible = false, + Dock = DockStyle.Right + }; + + vscrollbar.ValueChanged += (o, e) => { + top_index = Math.Max (vscrollbar.Value, 0); + UpdateEditTextBoxPosition (); + Invalidate (); + }; + + hscrollbar = new HorizontalScrollBar { + Minimum = 0, + Maximum = 0, + SmallChange = 10, + LargeChange = 50, + Visible = false, + Dock = DockStyle.Bottom + }; + + hscrollbar.ValueChanged += (o, e) => { + horizontal_scroll_offset = Math.Max (hscrollbar.Value, 0); + UpdateEditTextBoxPosition (); + Invalidate (); + }; + + Controls.AddImplicitControl (vscrollbar); + Controls.AddImplicitControl (hscrollbar); + } + + /// + /// Begins editing the specified cell. + /// + public void BeginEdit (int rowIndex, int columnIndex) + { + if (read_only || rowIndex < 0 || rowIndex >= Rows.Count || columnIndex < 0 || columnIndex >= Columns.Count) + return; + + // End any current edit + EndEdit (); + + editing_row_index = rowIndex; + editing_column_index = columnIndex; + + var cell_bounds = GetCellBounds (rowIndex, columnIndex); + + if (cell_bounds.IsEmpty) + return; + + var cell_value = columnIndex < Rows[rowIndex].Cells.Count + ? Rows[rowIndex].Cells[columnIndex].Value + : string.Empty; + + // Raise CellBeginEdit event + var begin_args = new DataGridViewCellEditEventArgs (rowIndex, columnIndex); + OnCellBeginEdit (begin_args); + + if (begin_args.Cancel) + return; + + // GetCellBounds returns device pixel coordinates; child control bounds are + // in logical units, so convert before positioning the TextBox. + edit_textbox = new TextBox { + Left = DeviceToLogicalUnits (cell_bounds.Left) + 1, + Top = DeviceToLogicalUnits (cell_bounds.Top) + 1, + Width = DeviceToLogicalUnits (cell_bounds.Width) - 2, + Height = DeviceToLogicalUnits (cell_bounds.Height) - 2, + Text = cell_value + }; + + edit_textbox.Style.Border.Width = 0; + + edit_textbox.KeyDown += EditTextBox_KeyDown; + edit_textbox.LostFocus += EditTextBox_LostFocus; + + Controls.Add (edit_textbox); + + edit_textbox.Select (); + edit_textbox.SelectAll (); + } + + /// + /// Raised when a cell begins editing. + /// + public event EventHandler? CellBeginEdit; + + /// + /// Raised when a cell ends editing. + /// + public event EventHandler? CellEndEdit; + + /// + /// Raised when a cell value has changed. + /// + public event EventHandler? CellValueChanged; + + /// + /// Gets or sets whether column headers are visible. + /// + public bool ColumnHeadersVisible { + get => column_headers_visible; + set { + if (column_headers_visible != value) { + column_headers_visible = value; + Invalidate (); + } + } + } + + /// + /// Gets the collection of columns in the DataGridView. + /// + public DataGridViewColumnCollection Columns { get; } + + /// + /// Gets or sets the data source for the DataGridView. + /// Setting this property auto-generates columns from the item type's public properties + /// and populates the rows from the collection. + /// + public IList? DataSource { + get => data_source; + set { + data_source = value; + OnDataSourceChanged (); + } + } + + /// + protected override Size DefaultSize => new Size (450, 300); + + /// + public new static readonly ControlStyle DefaultStyle = new ControlStyle (Control.DefaultStyle, + (style) => { + style.BackgroundColor = Theme.ControlLowColor; + style.Border.Width = 1; + }); + + /// + /// Gets or sets whether the user can resize columns by dragging column header borders. + /// + public bool AllowUserToResizeColumns { get; set; } = true; + + /// + /// Gets or sets whether the user can resize rows by dragging row header borders. + /// + public bool AllowUserToResizeRows { get; set; } = true; + + /// + /// Gets the default cell style applied to alternating rows. + /// + public ControlStyle AlternatingRowsDefaultCellStyle { get; } = new ControlStyle (DataGridViewCell.DefaultCellStyleInternal); + + /// + /// Gets the default cell style applied to cells in the DataGridView. + /// + public ControlStyle DefaultCellStyle { get; } = new ControlStyle (DataGridViewCell.DefaultCellStyleInternal); + + /// + /// Gets the default cell style applied to column header cells. + /// + public ControlStyle ColumnHeadersDefaultCellStyle { get; } = new ControlStyle (DataGridViewCell.DefaultCellStyleInternal); + + /// + /// Gets the default cell style applied to row header cells. + /// + public ControlStyle RowHeadersDefaultCellStyle { get; } = new ControlStyle (DataGridViewCell.DefaultCellStyleInternal); + + /// + /// Commits the current edit and hides the edit TextBox. + /// + [UnconditionalSuppressMessage ("Trimming", "IL2075", Justification = "Data binding requires runtime reflection over user-provided types.")] + public bool EndEdit () + { + if (edit_textbox is null || editing_row_index < 0 || editing_column_index < 0) + return false; + + var new_value = edit_textbox.Text; + var row = Rows[editing_row_index]; + + // Ensure enough cells exist + while (row.Cells.Count <= editing_column_index) + row.Cells.Add (string.Empty); + + var old_value = row.Cells[editing_column_index].Value; + + if (old_value != new_value) { + row.Cells[editing_column_index].Value = new_value; + var committed = true; + + // Update the data source if bound + if (data_source is not null && editing_row_index < data_source.Count) { + var item = data_source[editing_row_index]; + + if (item is not null && editing_column_index < Columns.Count) { + var prop = item.GetType ().GetProperty (Columns[editing_column_index].HeaderText, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance); + if (prop?.CanWrite == true) { + try { + var converted = Convert.ChangeType (new_value, prop.PropertyType); + prop.SetValue (item, converted); + } catch { + // Conversion failed - revert cell value + row.Cells[editing_column_index].Value = old_value; + committed = false; + } + } + } + } + + if (committed) { + var changed_args = new DataGridViewCellEditEventArgs (editing_row_index, editing_column_index); + OnCellValueChanged (changed_args); + } + } + + var end_args = new DataGridViewCellEditEventArgs (editing_row_index, editing_column_index); + OnCellEndEdit (end_args); + + // Clean up the TextBox + edit_textbox.KeyDown -= EditTextBox_KeyDown; + edit_textbox.LostFocus -= EditTextBox_LostFocus; + Controls.Remove (edit_textbox); + edit_textbox.Dispose (); + edit_textbox = null; + editing_row_index = -1; + editing_column_index = -1; + + Invalidate (); + return true; + } + + // Handle key events during editing. + private void EditTextBox_KeyDown (object? sender, KeyEventArgs e) + { + if (e.KeyCode == Keys.Enter) { + EndEdit (); + e.Handled = true; + } else if (e.KeyCode == Keys.Escape) { + CancelEdit (); + e.Handled = true; + } else if (e.KeyCode == Keys.Tab) { + EndEdit (); + + if (e.Shift) + NavigateToPreviousCell (); + else + NavigateToNextCell (); + + // Begin editing the newly selected cell + if (selected_row_index >= 0 && selected_column_index >= 0) + BeginEdit (selected_row_index, selected_column_index); + + e.Handled = true; + } + } + + // Handle lost focus during editing. + private void EditTextBox_LostFocus (object? sender, EventArgs e) + { + EndEdit (); + } + + /// + /// Cancels the current edit without committing changes. + /// + public void CancelEdit () + { + if (edit_textbox is null) + return; + + edit_textbox.KeyDown -= EditTextBox_KeyDown; + edit_textbox.LostFocus -= EditTextBox_LostFocus; + Controls.Remove (edit_textbox); + edit_textbox.Dispose (); + edit_textbox = null; + editing_row_index = -1; + editing_column_index = -1; + + Invalidate (); + } + + // Repositions the editing TextBox after a scroll; cancels the edit if the cell has scrolled out of view. + private void UpdateEditTextBoxPosition () + { + if (edit_textbox is null || editing_row_index < 0 || editing_column_index < 0) + return; + + var cell_bounds = GetCellBounds (editing_row_index, editing_column_index); + + if (cell_bounds.IsEmpty) { + CancelEdit (); + return; + } + + edit_textbox.Left = DeviceToLogicalUnits (cell_bounds.Left) + 1; + edit_textbox.Top = DeviceToLogicalUnits (cell_bounds.Top) + 1; + edit_textbox.Width = DeviceToLogicalUnits (cell_bounds.Width) - 2; + edit_textbox.Height = DeviceToLogicalUnits (cell_bounds.Height) - 2; + } + + /// + /// Gets or sets the index of the first row displayed on the DataGridView. + /// + public int FirstDisplayedScrollingRowIndex { + get => top_index; + set { + if (top_index == value) + return; + + if (value < 0 || value >= Rows.Count) + return; + + vscrollbar.Value = Math.Min (value, vscrollbar.Maximum); + } + } + + /// + /// Gets the bounding rectangle for a cell. + /// + public Rectangle GetCellBounds (int rowIndex, int columnIndex) + { + if (rowIndex < 0 || rowIndex >= Rows.Count || columnIndex < 0 || columnIndex >= Columns.Count) + return Rectangle.Empty; + + if (rowIndex < top_index) + return Rectangle.Empty; + + var client = GetContentArea (); + var row_top = client.Top + (ColumnHeadersVisible ? ScaledHeaderHeight : 0); + + // Accumulate y by summing individual row heights from the first visible row + var y = row_top; + + for (var i = top_index; i < rowIndex; i++) + y += LogicalToDeviceUnits (Rows[i].Height); + + var scaled_row_height = LogicalToDeviceUnits (Rows[rowIndex].Height); + + // Row is below the visible area + if (y >= client.Bottom) + return Rectangle.Empty; + + var row_header_offset = row_headers_visible ? ScaledRowHeadersWidth : 0; + var x = client.Left + row_header_offset - horizontal_scroll_offset; + + for (var i = 0; i < columnIndex; i++) { + if (Columns[i].Visible) + x += LogicalToDeviceUnits (Columns[i].Width); + } + + var col_width = LogicalToDeviceUnits (Columns[columnIndex].Width); + return new Rectangle (x, y, col_width, scaled_row_height); + } + + /// + /// Gets the content area, accounting for scrollbars. + /// Use Math.Ceiling to avoid fractional DPI rounding artifacts. + /// + internal Rectangle GetContentArea () + { + var client = ClientRectangle; + var w = client.Width - (vscrollbar.Visible ? (int)Math.Ceiling (vscrollbar.Width * ScaleFactor.Width) : 0); + var h = client.Height - (hscrollbar.Visible ? (int)Math.Ceiling (hscrollbar.Height * ScaleFactor.Height) : 0); + return new Rectangle (client.Left, client.Top, w, h); + } + + /// + /// Gets the column index at the specified location. + /// + internal int GetColumnAtLocation (Point location) + { + var client = GetContentArea (); + var row_header_offset = row_headers_visible ? ScaledRowHeadersWidth : 0; + var x = client.Left + row_header_offset - horizontal_scroll_offset; + + for (var i = 0; i < Columns.Count; i++) { + if (!Columns[i].Visible) + continue; + + var col_width = LogicalToDeviceUnits (Columns[i].Width); + + if (location.X >= x && location.X < x + col_width) + return i; + + x += col_width; + } + + return -1; + } + + /// + /// Gets the resize column index if the mouse is near a column border. + /// + private int GetResizeColumnAtLocation (Point location) + { + var client = GetContentArea (); + var header_rect = new Rectangle (client.Left, client.Top, client.Width, ScaledHeaderHeight); + + if (!header_rect.Contains (location)) + return -1; + + var row_header_offset = row_headers_visible ? ScaledRowHeadersWidth : 0; + var x = client.Left + row_header_offset - horizontal_scroll_offset; + var resize_zone = LogicalToDeviceUnits (4); + + for (var i = 0; i < Columns.Count; i++) { + if (!Columns[i].Visible) + continue; + + x += LogicalToDeviceUnits (Columns[i].Width); + + if (Math.Abs (location.X - x) <= resize_zone) + return i; + } + + return -1; + } + + /// + /// Gets the row index if the mouse is near a row border in the row header area. + /// + private int GetResizeRowAtLocation (Point location) + { + if (!row_headers_visible) + return -1; + + var client = GetContentArea (); + var header_offset = ColumnHeadersVisible ? ScaledHeaderHeight : 0; + var row_header_rect = new Rectangle (client.Left, client.Top + header_offset, ScaledRowHeadersWidth, Math.Max (0, client.Height - header_offset)); + + if (!row_header_rect.Contains (location)) + return -1; + + var row_top = client.Top + header_offset; + var resize_zone = LogicalToDeviceUnits (4); + + for (var i = top_index; i < Rows.Count; i++) { + var scaled_row_height = LogicalToDeviceUnits (Rows[i].Height); + row_top += scaled_row_height; + + if (row_top > client.Bottom) + break; + + if (Math.Abs (location.Y - row_top) <= resize_zone) + return i; + } + + return -1; + } + + /// + /// Gets the row index at the specified location. + /// + internal int GetRowAtLocation (Point location) + { + var client = GetContentArea (); + var row_top = client.Top + (ColumnHeadersVisible ? ScaledHeaderHeight : 0); + + if (location.Y < row_top) + return -1; + + var y = row_top; + + for (var i = top_index; i < Rows.Count; i++) { + var h = LogicalToDeviceUnits (Rows[i].Height); + + if (location.Y >= y && location.Y < y + h) + return i; + + y += h; + + if (y >= client.Bottom) + break; + } + + return -1; + } + + /// + /// Gets or sets the height, in pixels, of the column headers row. + /// + public int ColumnHeadersHeight { + get => header_height; + set { + if (header_height != value) { + header_height = Math.Max (value, 10); + Invalidate (); + } + } + } + + /// + /// Gets the currently selected cell, or null if no cell is selected. + /// + public DataGridViewCell? CurrentCell { + get { + if (selected_row_index < 0 || selected_row_index >= Rows.Count) + return null; + + if (selected_column_index < 0 || selected_column_index >= Rows[selected_row_index].Cells.Count) + return null; + + return Rows[selected_row_index].Cells[selected_column_index]; + } + } + + /// + /// Gets the row and column indices of the currently selected cell. + /// + public Point CurrentCellAddress => new Point (selected_column_index, selected_row_index); + + /// + /// Gets the row containing the currently selected cell, or null if no row is selected. + /// + public DataGridViewRow? CurrentRow { + get { + if (selected_row_index >= 0 && selected_row_index < Rows.Count) + return Rows[selected_row_index]; + + return null; + } + } + + /// + /// Gets the horizontal scroll offset. + /// + internal int HorizontalScrollOffset => horizontal_scroll_offset; + + /// + /// Gets or sets the index of the currently hovered row. + /// + internal int HoveredRowIndex { + get => hovered_row_index; + set { + if (hovered_row_index != value) { + hovered_row_index = value; + Invalidate (); + } + } + } + + /// + /// Gets whether a cell is currently being edited. + /// + public bool IsCurrentCellInEditMode => edit_textbox is not null; + + /// + /// Raises the CellBeginEdit event. + /// + protected virtual void OnCellBeginEdit (DataGridViewCellEditEventArgs e) => CellBeginEdit?.Invoke (this, e); + + /// + /// Raises the CellEndEdit event. + /// + protected virtual void OnCellEndEdit (DataGridViewCellEditEventArgs e) => CellEndEdit?.Invoke (this, e); + + /// + /// Raises the CellValueChanged event. + /// + protected virtual void OnCellValueChanged (DataGridViewCellEditEventArgs e) => CellValueChanged?.Invoke (this, e); + + /// + /// Handles a column header click for sorting. + /// + private void OnColumnHeaderClick (int columnIndex) + { + var column = Columns[columnIndex]; + + // Toggle sort order + var new_order = column.SortOrder == SortOrder.Ascending + ? SortOrder.Descending + : SortOrder.Ascending; + + // Reset all other columns + foreach (var col in Columns) + col.SortOrder = SortOrder.None; + + column.SortOrder = new_order; + + // Sort the data + SortByColumn (columnIndex, new_order); + + // Raise the event + ColumnHeaderClick?.Invoke (this, new EventArgs (column)); + + Invalidate (); + } + + /// + /// Raised when a column header is clicked. + /// + public event EventHandler>? ColumnHeaderClick; + + // Populates rows and columns from the DataSource. + [UnconditionalSuppressMessage ("Trimming", "IL2075", Justification = "Data binding requires runtime reflection over user-provided types.")] + private void OnDataSourceChanged () + { + Columns.Clear (); + Rows.Clear (); + + if (data_source is null || data_source.Count == 0) + return; + + // Get the element type + var element_type = GetElementType (data_source); + + if (element_type is null) + return; + + // Auto-generate columns from public readable properties + var properties = element_type.GetProperties (BindingFlags.Public | BindingFlags.Instance) + .Where (p => p.CanRead) + .ToArray (); + + foreach (var prop in properties) + Columns.Add (prop.Name, EstimateColumnWidth (prop.Name)); + + // Populate rows + foreach (var item in data_source) { + if (item is null) + continue; + + var values = new string[properties.Length]; + + for (var i = 0; i < properties.Length; i++) + values[i] = properties[i].GetValue (item)?.ToString () ?? string.Empty; + + Rows.Add (values); + } + } + + // Gets the element type from an IList. + [UnconditionalSuppressMessage ("Trimming", "IL2075", Justification = "Data binding requires runtime reflection over user-provided types.")] + private static Type? GetElementType (IList list) + { + var list_type = list.GetType (); + + // Check for generic IList + foreach (var iface in list_type.GetInterfaces ()) { + if (iface.IsGenericType && iface.GetGenericTypeDefinition () == typeof (IList<>)) + return iface.GetGenericArguments ()[0]; + } + + // Fallback: use type of first item + if (list.Count > 0 && list[0] is not null) + return list[0]!.GetType (); + + return null; + } + + // Estimates a column width based on header text length. + private static int EstimateColumnWidth (string headerText) + { + return Math.Max (80, headerText.Length * 10 + 20); + } + + /// + protected override void OnDoubleClick (MouseEventArgs e) + { + base.OnDoubleClick (e); + + if (read_only || !Enabled) + return; + + var row = GetRowAtLocation (e.Location); + var col = GetColumnAtLocation (e.Location); + + if (row >= 0 && col >= 0) + BeginEdit (row, col); + } + + /// + protected override void OnMouseDown (MouseEventArgs e) + { + base.OnMouseDown (e); + + if (!Enabled || !e.Button.HasFlag (MouseButtons.Left)) + return; + + // If editing, end edit when clicking outside the editor + if (edit_textbox is not null) { + var edit_bounds = edit_textbox.ScaledBounds; + + if (!edit_bounds.Contains (e.Location)) + EndEdit (); + } + + // Check for column resize + if (ColumnHeadersVisible && AllowUserToResizeColumns) { + var resize_col = GetResizeColumnAtLocation (e.Location); + + if (resize_col >= 0) { + is_resizing_column = true; + resize_column_index = resize_col; + resize_start_x = e.Location.X; + resize_start_width = LogicalToDeviceUnits (Columns[resize_col].Width); + return; + } + } + + // Check for row resize + if (row_headers_visible && AllowUserToResizeRows) { + var resize_row = GetResizeRowAtLocation (e.Location); + + if (resize_row >= 0) { + is_resizing_row = true; + resize_row_index = resize_row; + resize_start_y = e.Location.Y; + resize_start_height = LogicalToDeviceUnits (Rows[resize_row].Height); + return; + } + } + + // Check for header click (sorting) + if (ColumnHeadersVisible) { + var client = GetContentArea (); + var header_rect = new Rectangle (client.Left, client.Top, client.Width, ScaledHeaderHeight); + + if (header_rect.Contains (e.Location)) { + var col = GetColumnAtLocation (e.Location); + + if (col >= 0 && Columns[col].Sortable) + OnColumnHeaderClick (col); + + return; + } + } + + // Select row/cell + var row = GetRowAtLocation (e.Location); + + if (row >= 0) { + if (selection_mode == DataGridViewSelectionMode.FullRowSelect) { + SelectedRowIndex = row; + } else { + var col = GetColumnAtLocation (e.Location); + SelectedRowIndex = row; + SelectedColumnIndex = col; + } + } + } + + /// + protected override void OnMouseLeave (EventArgs e) + { + base.OnMouseLeave (e); + HoveredRowIndex = -1; + + if (!is_resizing_column && !is_resizing_row) + SetCursorDirect (Cursors.Arrow); + } + + /// + protected override void OnMouseMove (MouseEventArgs e) + { + base.OnMouseMove (e); + + if (is_resizing_column) { + var delta = e.Location.X - resize_start_x; + var new_width = DeviceToLogicalUnits (resize_start_width + delta); + Columns[resize_column_index].Width = new_width; + UpdateScrollBars (); + return; + } + + if (is_resizing_row) { + var delta = e.Location.Y - resize_start_y; + var new_height = DeviceToLogicalUnits (resize_start_height + delta); + Rows[resize_row_index].Height = Math.Max (new_height, 10); + UpdateScrollBars (); + return; + } + + // Update cursor for column resize zones + if (ColumnHeadersVisible && AllowUserToResizeColumns) { + var resize_col = GetResizeColumnAtLocation (e.Location); + + if (resize_col >= 0) { + if (Cursor != Cursors.SizeWestEast) + SetCursorDirect (Cursors.SizeWestEast); + + // Update hovered row + HoveredRowIndex = GetRowAtLocation (e.Location); + return; + } + } + + // Update cursor for row resize zones + if (row_headers_visible && AllowUserToResizeRows) { + var resize_row = GetResizeRowAtLocation (e.Location); + + if (resize_row >= 0) { + if (Cursor != Cursors.SizeNorthSouth) + SetCursorDirect (Cursors.SizeNorthSouth); + + HoveredRowIndex = GetRowAtLocation (e.Location); + return; + } + } + + if (Cursor != Cursors.Arrow) + SetCursorDirect (Cursors.Arrow); + + // Update hovered row + var row = GetRowAtLocation (e.Location); + HoveredRowIndex = row; + } + + /// + protected override void OnMouseUp (MouseEventArgs e) + { + base.OnMouseUp (e); + + if (is_resizing_column) { + is_resizing_column = false; + resize_column_index = -1; + SetCursorDirect (Cursors.Arrow); + } + + if (is_resizing_row) { + is_resizing_row = false; + resize_row_index = -1; + SetCursorDirect (Cursors.Arrow); + } + } + + /// + protected override void OnMouseWheel (MouseEventArgs e) + { + base.OnMouseWheel (e); + + if (vscrollbar.Visible) + vscrollbar.RaiseMouseWheel (e); + } + + /// + protected override void OnPaint (PaintEventArgs e) + { + RenderManager.Render (this, e); + + base.OnPaint (e); + } + + /// + protected override void OnKeyUp (KeyEventArgs e) + { + // F2 begins editing + if (e.KeyCode == Keys.F2 && !read_only && selected_row_index >= 0 && selected_column_index >= 0) { + BeginEdit (selected_row_index, selected_column_index); + e.Handled = true; + return; + } + + if (e.KeyCode == Keys.Down) { + if (selected_row_index < Rows.Count - 1) { + SelectedRowIndex = selected_row_index + 1; + EnsureRowVisible (selected_row_index); + e.Handled = true; + return; + } + } + + if (e.KeyCode == Keys.Up) { + if (selected_row_index > 0) { + SelectedRowIndex = selected_row_index - 1; + EnsureRowVisible (selected_row_index); + e.Handled = true; + return; + } + } + + if (e.KeyCode == Keys.PageDown) { + var new_index = Math.Min (selected_row_index + DisplayedRowCount, Rows.Count - 1); + SelectedRowIndex = new_index; + EnsureRowVisible (new_index); + e.Handled = true; + return; + } + + if (e.KeyCode == Keys.PageUp) { + var new_index = Math.Max (selected_row_index - DisplayedRowCount, 0); + SelectedRowIndex = new_index; + EnsureRowVisible (new_index); + e.Handled = true; + return; + } + + if (e.KeyCode == Keys.Home) { + SelectedRowIndex = 0; + EnsureRowVisible (0); + e.Handled = true; + return; + } + + if (e.KeyCode == Keys.End) { + SelectedRowIndex = Rows.Count - 1; + EnsureRowVisible (Rows.Count - 1); + e.Handled = true; + return; + } + + if (selection_mode != DataGridViewSelectionMode.FullRowSelect) { + if (e.KeyCode == Keys.Left && selected_column_index > 0) { + SelectedColumnIndex = selected_column_index - 1; + e.Handled = true; + return; + } + + if (e.KeyCode == Keys.Right && selected_column_index < Columns.Count - 1) { + SelectedColumnIndex = selected_column_index + 1; + e.Handled = true; + return; + } + + if (e.KeyCode == Keys.Tab) { + if (e.Shift) + NavigateToPreviousCell (); + else + NavigateToNextCell (); + + e.Handled = true; + return; + } + } + + base.OnKeyUp (e); + } + + /// + /// Called when the row collection changes. + /// + internal void OnRowsChanged () + { + UpdateScrollBars (); + Invalidate (); + } + + /// + /// Called when the column collection changes. + /// + internal void OnColumnsChanged () + { + UpdateScrollBars (); + Invalidate (); + } + + /// + /// Gets or sets whether the DataGridView is read-only. + /// + public bool ReadOnly { + get => read_only; + set { + if (read_only != value) { + read_only = value; + + if (read_only) + CancelEdit (); + } + } + } + + /// + /// Gets the collection of rows in the DataGridView. + /// + public DataGridViewRowCollection Rows { get; } + + /// + /// Gets or sets the default height, in pixels, of each row. + /// + public int RowHeight { + get => row_height; + set { + if (row_height != value) { + row_height = Math.Max (value, 10); + UpdateScrollBars (); + Invalidate (); + } + } + } + + /// + /// Gets or sets whether the row header column is displayed. + /// + public bool RowHeadersVisible { + get => row_headers_visible; + set { + if (row_headers_visible != value) { + row_headers_visible = value; + UpdateScrollBars (); + Invalidate (); + } + } + } + + /// + /// Gets or sets the width, in pixels, of the row header column. + /// + public int RowHeadersWidth { + get => row_headers_width; + set { + if (row_headers_width != value) { + row_headers_width = Math.Max (value, 10); + UpdateScrollBars (); + Invalidate (); + } + } + } + + /// + /// Gets or sets how cells in the DataGridView can be selected. + /// + public DataGridViewSelectionMode SelectionMode { + get => selection_mode; + set { + if (selection_mode != value) { + selection_mode = value; + Invalidate (); + } + } + } + + /// + /// Gets the scaled height of the header row. + /// + internal int ScaledHeaderHeight => LogicalToDeviceUnits (header_height); + + /// + /// Gets the scaled height of each data row. + /// + internal int ScaledRowHeight => LogicalToDeviceUnits (row_height); + + /// + /// Gets the scaled width of the row header column. + /// + internal int ScaledRowHeadersWidth => LogicalToDeviceUnits (row_headers_width); + + /// + /// Gets or sets the index of the currently selected column. + /// + public int SelectedColumnIndex { + get => selected_column_index; + set { + if (selected_column_index != value) { + selected_column_index = value; + OnSelectionChanged (EventArgs.Empty); + Invalidate (); + } + } + } + + /// + /// Gets or sets the index of the currently selected row. + /// + public int SelectedRowIndex { + get => selected_row_index; + set { + if (selected_row_index != value) { + // Deselect old row + if (selected_row_index >= 0 && selected_row_index < Rows.Count) + Rows[selected_row_index].Selected = false; + + selected_row_index = value; + + // Select new row + if (selected_row_index >= 0 && selected_row_index < Rows.Count) + Rows[selected_row_index].Selected = true; + + OnSelectionChanged (EventArgs.Empty); + Invalidate (); + } + } + } + + /// + /// Raises the SelectionChanged event. + /// + protected virtual void OnSelectionChanged (EventArgs e) => SelectionChanged?.Invoke (this, e); + + /// + /// Raised when the selection changes. + /// + public event EventHandler? SelectionChanged; + + // Sets the cursor and immediately updates the OS cursor. + // Setting Cursor alone only takes effect on next OnMouseEnter, so we + // must call SetCursor directly to update the cursor during mouse move. + private void SetCursorDirect (Cursor cursor) + { + Cursor = cursor; + FindForm ()?.SetCursor (cursor); + } + + /// + protected override void SetBoundsCore (int x, int y, int width, int height, BoundsSpecified specified) + { + base.SetBoundsCore (x, y, width, height, specified); + + UpdateScrollBars (); + } + + /// + /// Sorts the rows by the specified column. + /// + public void SortByColumn (int columnIndex, SortOrder order) + { + if (columnIndex < 0 || columnIndex >= Columns.Count || order == SortOrder.None || Rows.Count == 0) + return; + + // Sort the rows in-place (note: List.Sort is not guaranteed to be stable) + var sorted = Rows.ToList (); + + sorted.Sort ((a, b) => { + var val_a = columnIndex < a.Cells.Count ? a.Cells[columnIndex].Value : string.Empty; + var val_b = columnIndex < b.Cells.Count ? b.Cells[columnIndex].Value : string.Empty; + + // Try numeric comparison first + if (double.TryParse (val_a, out var num_a) && double.TryParse (val_b, out var num_b)) { + var cmp = num_a.CompareTo (num_b); + return order == SortOrder.Descending ? -cmp : cmp; + } + + // Fall back to string comparison + var result = string.Compare (val_a, val_b, StringComparison.CurrentCultureIgnoreCase); + return order == SortOrder.Descending ? -result : result; + }); + + // Replace rows without triggering per-item change notifications + Rows.ReplaceAll (sorted); + } + + /// + public override ControlStyle Style { get; } = new ControlStyle (DefaultStyle); + + /// + /// Gets the total width needed to display all columns. + /// + internal int TotalColumnsWidth { + get { + var total = 0; + + foreach (var col in Columns) + if (col.Visible) + total += LogicalToDeviceUnits (col.Width); + + return total; + } + } + + /// + /// Updates the scrollbars based on the current content. + /// + private void UpdateScrollBars () + { + var client = GetContentArea (); + var header_offset = ColumnHeadersVisible ? ScaledHeaderHeight : 0; + var content_height = client.Height - header_offset; + + // Count how many rows fit in the content area using their actual heights + var visible_rows = 0; + var rows_height = 0; + + for (var i = 0; i < Rows.Count; i++) { + var rh = LogicalToDeviceUnits (Rows[i].Height); + + if (rows_height + rh <= content_height) { + visible_rows++; + rows_height += rh; + } else { + break; + } + } + + // Vertical scrollbar + if (Rows.Count > visible_rows && visible_rows > 0) { + vscrollbar.Visible = true; + vscrollbar.Maximum = Rows.Count - visible_rows; + vscrollbar.LargeChange = Math.Max (0, visible_rows); + } else { + vscrollbar.Visible = false; + vscrollbar.Value = 0; + top_index = 0; + } + + // Horizontal scrollbar + var available_width = client.Width - (vscrollbar.Visible ? (int)Math.Ceiling (vscrollbar.Width * ScaleFactor.Width) : 0); + + if (TotalColumnsWidth > available_width && available_width > 0) { + hscrollbar.Visible = true; + hscrollbar.Maximum = TotalColumnsWidth - available_width; + hscrollbar.LargeChange = Math.Max (0, available_width); + } else { + hscrollbar.Visible = false; + hscrollbar.Value = 0; + horizontal_scroll_offset = 0; + } + } + + /// + /// Gets the number of full rows that can be displayed at a time. + /// + public int DisplayedRowCount { + get { + var content = GetContentArea (); + var available = content.Height - (ColumnHeadersVisible ? ScaledHeaderHeight : 0); + var count = 0; + var h = 0; + + for (var i = 0; i < Rows.Count; i++) { + var rh = LogicalToDeviceUnits (Rows[i].Height); + + if (h + rh <= available) { + count++; + h += rh; + } else { + break; + } + } + + return count; + } + } + + /// + /// Ensures the specified row is visible by scrolling if necessary. + /// + private void EnsureRowVisible (int index) + { + if (DisplayedRowCount >= Rows.Count) + return; + + if (index < top_index) + FirstDisplayedScrollingRowIndex = index; + else if (index >= top_index + DisplayedRowCount) + FirstDisplayedScrollingRowIndex = index - DisplayedRowCount + 1; + } + + // Moves the selection to the next cell, wrapping to the next row. + private void NavigateToNextCell () + { + if (Columns.Count == 0 || Rows.Count == 0) + return; + + if (selected_column_index < Columns.Count - 1) { + SelectedColumnIndex = selected_column_index + 1; + } else if (selected_row_index < Rows.Count - 1) { + SelectedColumnIndex = 0; + SelectedRowIndex = selected_row_index + 1; + EnsureRowVisible (selected_row_index); + } + } + + // Moves the selection to the previous cell, wrapping to the previous row. + private void NavigateToPreviousCell () + { + if (Columns.Count == 0 || Rows.Count == 0) + return; + + if (selected_column_index > 0) { + SelectedColumnIndex = selected_column_index - 1; + } else if (selected_row_index > 0) { + SelectedColumnIndex = Columns.Count - 1; + SelectedRowIndex = selected_row_index - 1; + EnsureRowVisible (selected_row_index); + } + } + + /// + /// Converts device units to logical units. + /// + internal int DeviceToLogicalUnits (int value) + { + var factor = Scaling; + return factor > 0 ? (int)(value / factor) : value; + } + } + + /// + /// Provides data for cell editing events. + /// + public class DataGridViewCellEditEventArgs : EventArgs + { + /// + /// Initializes a new instance of the DataGridViewCellEditEventArgs class. + /// + public DataGridViewCellEditEventArgs (int rowIndex, int columnIndex) + { + RowIndex = rowIndex; + ColumnIndex = columnIndex; + } + + /// + /// Gets or sets whether the editing operation should be canceled. + /// + public bool Cancel { get; set; } + + /// + /// Gets the column index of the cell. + /// + public int ColumnIndex { get; } + + /// + /// Gets the row index of the cell. + /// + public int RowIndex { get; } + } +} diff --git a/src/Modern.Forms/DataGridViewCell.cs b/src/Modern.Forms/DataGridViewCell.cs new file mode 100644 index 0000000..50dfd78 --- /dev/null +++ b/src/Modern.Forms/DataGridViewCell.cs @@ -0,0 +1,93 @@ +using System.Drawing; + +namespace Modern.Forms +{ + /// + /// Represents a cell in a DataGridView control. + /// + public class DataGridViewCell + { + private string value = string.Empty; + private DataGridViewRow? owner; + + // Default style used as the base parent for all cell Style instances. + internal static readonly ControlStyle DefaultCellStyleInternal = new ControlStyle (null, + (style) => { + style.BackgroundColor = Theme.ControlLowColor; + style.ForegroundColor = Theme.ForegroundColor; + }); + + /// + /// Initializes a new instance of the DataGridViewCell class. + /// + public DataGridViewCell () + { + } + + /// + /// Initializes a new instance of the DataGridViewCell class with the specified value. + /// + public DataGridViewCell (string value) + { + this.value = value; + } + + /// + /// Gets the bounding rectangle of the cell. + /// + internal Rectangle Bounds { get; set; } + + /// + /// Gets the column index of this cell. + /// + public int ColumnIndex => owner?.Cells.IndexOf (this) ?? -1; + + /// + /// Gets the DataGridView that contains this cell. + /// + public DataGridView? DataGridView => owner?.DataGridView; + + /// + /// Gets the row that contains this cell. + /// + public DataGridViewRow? OwningRow => owner; + + /// + /// Gets the row index of this cell. + /// + public int RowIndex => owner?.Index ?? -1; + + /// + /// Gets or sets whether this cell is selected. + /// + public bool Selected { get; set; } + + /// + /// Gets or sets the style for this cell. + /// + public ControlStyle Style { get; set; } = new ControlStyle (DefaultCellStyleInternal); + + /// + /// Gets or sets an object that contains data to associate with the cell. + /// + public object? Tag { get; set; } + + /// + /// Gets or sets the value of this cell. + /// + public string Value { + get => value; + set { + if (this.value != value) { + this.value = value; + owner?.DataGridView?.Invalidate (); + } + } + } + + /// + /// Sets the owning row. + /// + internal void SetOwner (DataGridViewRow? row) => owner = row; + } +} diff --git a/src/Modern.Forms/DataGridViewCellCollection.cs b/src/Modern.Forms/DataGridViewCellCollection.cs new file mode 100644 index 0000000..9523be0 --- /dev/null +++ b/src/Modern.Forms/DataGridViewCellCollection.cs @@ -0,0 +1,65 @@ +using System.Collections.ObjectModel; + +namespace Modern.Forms +{ + /// + /// Represents a collection of DataGridViewCell objects in a DataGridViewRow. + /// + public class DataGridViewCellCollection : Collection + { + private readonly DataGridViewRow owner; + + /// + /// Initializes a new instance of the DataGridViewCellCollection class. + /// + internal DataGridViewCellCollection (DataGridViewRow owner) + { + this.owner = owner; + } + + /// + /// Adds a cell with the specified value to the collection. + /// + public DataGridViewCell Add (string value) + { + var cell = new DataGridViewCell (value); + Add (cell); + return cell; + } + + /// + protected override void ClearItems () + { + foreach (var cell in this) + cell.SetOwner (null); + + base.ClearItems (); + owner.DataGridView?.Invalidate (); + } + + /// + protected override void InsertItem (int index, DataGridViewCell item) + { + item.SetOwner (owner); + base.InsertItem (index, item); + owner.DataGridView?.Invalidate (); + } + + /// + protected override void RemoveItem (int index) + { + this[index].SetOwner (null); + base.RemoveItem (index); + owner.DataGridView?.Invalidate (); + } + + /// + protected override void SetItem (int index, DataGridViewCell item) + { + this[index].SetOwner (null); + item.SetOwner (owner); + base.SetItem (index, item); + owner.DataGridView?.Invalidate (); + } + } +} diff --git a/src/Modern.Forms/DataGridViewColumn.cs b/src/Modern.Forms/DataGridViewColumn.cs new file mode 100644 index 0000000..83ef5c8 --- /dev/null +++ b/src/Modern.Forms/DataGridViewColumn.cs @@ -0,0 +1,126 @@ +using System.Drawing; + +namespace Modern.Forms +{ + /// + /// Represents a column in a DataGridView control. + /// + public class DataGridViewColumn + { + private string header_text = string.Empty; + private int width = 100; + private DataGridView? owner; + + /// + /// Initializes a new instance of the DataGridViewColumn class. + /// + public DataGridViewColumn () + { + } + + /// + /// Initializes a new instance of the DataGridViewColumn class with the specified header text. + /// + public DataGridViewColumn (string headerText) + { + header_text = headerText; + } + + /// + /// Gets the bounding rectangle of the column header. + /// + internal Rectangle HeaderBounds { get; set; } + + /// + /// Gets the header cell for this column. + /// + public DataGridViewColumnHeaderCell HeaderCell { get; } = new DataGridViewColumnHeaderCell (); + + /// + /// Gets or sets the header text for this column. + /// + public string HeaderText { + get => header_text; + set { + if (header_text != value) { + header_text = value; + owner?.Invalidate (); + } + } + } + + /// + /// Gets the index of this column in the DataGridView. + /// + public int Index => owner?.Columns.IndexOf (this) ?? -1; + + /// + /// Gets or sets the minimum width, in pixels, of the column. + /// + public int MinimumWidth { get; set; } = 30; + + /// + /// Gets the DataGridView control that contains this column. + /// + public DataGridView? DataGridView => owner; + + /// + /// Gets or sets a value indicating whether the column is sortable. + /// + public bool Sortable { get; set; } = true; + + /// + /// Gets or sets the sort order for this column. + /// + public SortOrder SortOrder { get; set; } = SortOrder.None; + + /// + /// Gets or sets an object that contains data to associate with the column. + /// + public object? Tag { get; set; } + + /// + /// Gets or sets whether the column is visible. + /// + public bool Visible { get; set; } = true; + + /// + /// Gets or sets the width, in pixels, of the column. + /// + public int Width { + get => width; + set { + value = Math.Max (value, MinimumWidth); + + if (width != value) { + width = value; + owner?.OnColumnsChanged (); + } + } + } + + /// + /// Sets the owning DataGridView. + /// + internal void SetOwner (DataGridView? dataGridView) => owner = dataGridView; + } + + /// + /// Specifies the sort order for a column. + /// + public enum SortOrder + { + /// + /// No sort order. + /// + None, + /// + /// Items are sorted in ascending order. + /// + Ascending, + /// + /// Items are sorted in descending order. + /// + Descending + } +} diff --git a/src/Modern.Forms/DataGridViewColumnCollection.cs b/src/Modern.Forms/DataGridViewColumnCollection.cs new file mode 100644 index 0000000..316a049 --- /dev/null +++ b/src/Modern.Forms/DataGridViewColumnCollection.cs @@ -0,0 +1,75 @@ +using System.Collections.ObjectModel; + +namespace Modern.Forms +{ + /// + /// Represents a collection of DataGridViewColumn objects in a DataGridView control. + /// + public class DataGridViewColumnCollection : Collection + { + private readonly DataGridView owner; + + /// + /// Initializes a new instance of the DataGridViewColumnCollection class. + /// + internal DataGridViewColumnCollection (DataGridView owner) + { + this.owner = owner; + } + + /// + /// Adds a column with the specified header text to the collection. + /// + public DataGridViewColumn Add (string headerText) + { + var column = new DataGridViewColumn (headerText); + Add (column); + return column; + } + + /// + /// Adds a column with the specified header text and width to the collection. + /// + public DataGridViewColumn Add (string headerText, int width) + { + var column = new DataGridViewColumn (headerText) { Width = width }; + Add (column); + return column; + } + + /// + protected override void ClearItems () + { + foreach (var column in this) + column.SetOwner (null); + + base.ClearItems (); + owner.OnColumnsChanged (); + } + + /// + protected override void InsertItem (int index, DataGridViewColumn item) + { + item.SetOwner (owner); + base.InsertItem (index, item); + owner.OnColumnsChanged (); + } + + /// + protected override void RemoveItem (int index) + { + this[index].SetOwner (null); + base.RemoveItem (index); + owner.OnColumnsChanged (); + } + + /// + protected override void SetItem (int index, DataGridViewColumn item) + { + this[index].SetOwner (null); + item.SetOwner (owner); + base.SetItem (index, item); + owner.OnColumnsChanged (); + } + } +} diff --git a/src/Modern.Forms/DataGridViewHeaderCell.cs b/src/Modern.Forms/DataGridViewHeaderCell.cs new file mode 100644 index 0000000..adf9990 --- /dev/null +++ b/src/Modern.Forms/DataGridViewHeaderCell.cs @@ -0,0 +1,62 @@ +namespace Modern.Forms +{ + /// + /// Represents a header cell in a DataGridView control. + /// + public class DataGridViewHeaderCell : DataGridViewCell + { + /// + /// Initializes a new instance of the DataGridViewHeaderCell class. + /// + public DataGridViewHeaderCell () + { + } + + /// + /// Initializes a new instance of the DataGridViewHeaderCell class with the specified value. + /// + public DataGridViewHeaderCell (string value) : base (value) + { + } + } + + /// + /// Represents a column header cell in a DataGridView control. + /// + public class DataGridViewColumnHeaderCell : DataGridViewHeaderCell + { + /// + /// Initializes a new instance of the DataGridViewColumnHeaderCell class. + /// + public DataGridViewColumnHeaderCell () + { + } + + /// + /// Initializes a new instance of the DataGridViewColumnHeaderCell class with the specified value. + /// + public DataGridViewColumnHeaderCell (string value) : base (value) + { + } + } + + /// + /// Represents a row header cell in a DataGridView control. + /// + public class DataGridViewRowHeaderCell : DataGridViewHeaderCell + { + /// + /// Initializes a new instance of the DataGridViewRowHeaderCell class. + /// + public DataGridViewRowHeaderCell () + { + } + + /// + /// Initializes a new instance of the DataGridViewRowHeaderCell class with the specified value. + /// + public DataGridViewRowHeaderCell (string value) : base (value) + { + } + } +} diff --git a/src/Modern.Forms/DataGridViewRow.cs b/src/Modern.Forms/DataGridViewRow.cs new file mode 100644 index 0000000..147b6b7 --- /dev/null +++ b/src/Modern.Forms/DataGridViewRow.cs @@ -0,0 +1,74 @@ +using System.Drawing; + +namespace Modern.Forms +{ + /// + /// Represents a row in a DataGridView control. + /// + public class DataGridViewRow + { + private int height = 25; + private DataGridView? owner; + + /// + /// Initializes a new instance of the DataGridViewRow class. + /// + public DataGridViewRow () + { + Cells = new DataGridViewCellCollection (this); + } + + /// + /// Gets the bounding rectangle of the row. + /// + internal Rectangle Bounds { get; set; } + + /// + /// Gets the collection of cells in this row. + /// + public DataGridViewCellCollection Cells { get; } + + /// + /// Gets the header cell for this row. + /// + public DataGridViewRowHeaderCell HeaderCell { get; } = new DataGridViewRowHeaderCell (); + + /// + /// Gets the DataGridView that contains this row. + /// + public DataGridView? DataGridView => owner; + + /// + /// Gets or sets the height, in pixels, of the row. + /// + public int Height { + get => height; + set { + if (height != value) { + height = Math.Max (value, 10); + owner?.OnRowsChanged (); + } + } + } + + /// + /// Gets the index of this row in the DataGridView. + /// + public int Index => owner?.Rows.IndexOf (this) ?? -1; + + /// + /// Gets or sets whether this row is selected. + /// + public bool Selected { get; set; } + + /// + /// Gets or sets an object that contains data to associate with the row. + /// + public object? Tag { get; set; } + + /// + /// Sets the owning DataGridView. + /// + internal void SetOwner (DataGridView? dataGridView) => owner = dataGridView; + } +} diff --git a/src/Modern.Forms/DataGridViewRowCollection.cs b/src/Modern.Forms/DataGridViewRowCollection.cs new file mode 100644 index 0000000..b980880 --- /dev/null +++ b/src/Modern.Forms/DataGridViewRowCollection.cs @@ -0,0 +1,88 @@ +using System.Collections.ObjectModel; + +namespace Modern.Forms +{ + /// + /// Represents a collection of DataGridViewRow objects in a DataGridView control. + /// + public class DataGridViewRowCollection : Collection + { + private readonly DataGridView owner; + + /// + /// Initializes a new instance of the DataGridViewRowCollection class. + /// + internal DataGridViewRowCollection (DataGridView owner) + { + this.owner = owner; + } + + /// + /// Adds a new row with the specified cell values. + /// + public DataGridViewRow Add (params string[] values) + { + var row = new DataGridViewRow (); + + foreach (var value in values) + row.Cells.Add (value); + + Add (row); + return row; + } + + /// + protected override void ClearItems () + { + foreach (var row in this) + row.SetOwner (null); + + base.ClearItems (); + owner.OnRowsChanged (); + } + + /// + protected override void InsertItem (int index, DataGridViewRow item) + { + item.SetOwner (owner); + base.InsertItem (index, item); + owner.OnRowsChanged (); + } + + /// + protected override void RemoveItem (int index) + { + this[index].SetOwner (null); + base.RemoveItem (index); + owner.OnRowsChanged (); + } + + /// + /// Replaces all rows with the specified list, raising a single change notification. + /// + internal void ReplaceAll (List rows) + { + // Clear without per-item notifications + foreach (var row in this) + row.SetOwner (null); + + Items.Clear (); + + foreach (var row in rows) { + row.SetOwner (owner); + Items.Add (row); + } + + owner.OnRowsChanged (); + } + + /// + protected override void SetItem (int index, DataGridViewRow item) + { + this[index].SetOwner (null); + item.SetOwner (owner); + base.SetItem (index, item); + owner.OnRowsChanged (); + } + } +} diff --git a/src/Modern.Forms/DataGridViewSelectionMode.cs b/src/Modern.Forms/DataGridViewSelectionMode.cs new file mode 100644 index 0000000..e7a2e20 --- /dev/null +++ b/src/Modern.Forms/DataGridViewSelectionMode.cs @@ -0,0 +1,29 @@ +namespace Modern.Forms +{ + /// + /// Describes the selection behavior of a DataGridView. + /// + public enum DataGridViewSelectionMode + { + /// + /// One or more individual cells can be selected. + /// + CellSelect = 0, + /// + /// The entire row will be selected by clicking its row's header or a cell contained in that row. + /// + FullRowSelect = 1, + /// + /// The entire column will be selected by clicking the column's header or a cell contained in that column. + /// + FullColumnSelect = 2, + /// + /// The row is selected by clicking in the row's header cell. An individual cell can be selected by clicking that cell. + /// + RowHeaderSelect = 3, + /// + /// The column is selected by clicking in the column's header cell. An individual cell can be selected by clicking that cell. + /// + ColumnHeaderSelect = 4 + } +} diff --git a/src/Modern.Forms/Renderers/DataGridViewRenderer.cs b/src/Modern.Forms/Renderers/DataGridViewRenderer.cs new file mode 100644 index 0000000..90fc277 --- /dev/null +++ b/src/Modern.Forms/Renderers/DataGridViewRenderer.cs @@ -0,0 +1,274 @@ +using System.Drawing; +using SkiaSharp; + +namespace Modern.Forms.Renderers +{ + /// + /// Represents a class that can render a DataGridView. + /// + public class DataGridViewRenderer : Renderer + { + /// + protected override void Render (DataGridView control, PaintEventArgs e) + { + var content = control.GetContentArea (); + + e.Canvas.Save (); + e.Canvas.Clip (content); + + // Draw column headers + if (control.ColumnHeadersVisible) + RenderColumnHeaders (control, e, content); + + // Draw rows + RenderRows (control, e, content); + + e.Canvas.Restore (); + } + + /// + /// Renders the column headers. + /// + protected virtual void RenderColumnHeaders (DataGridView control, PaintEventArgs e, Rectangle contentArea) + { + var header_height = control.ScaledHeaderHeight; + var row_header_offset = control.RowHeadersVisible ? control.ScaledRowHeadersWidth : 0; + var x = contentArea.Left + row_header_offset - control.HorizontalScrollOffset; + var y = contentArea.Top; + + // Draw header background + var header_rect = new Rectangle (contentArea.Left, y, contentArea.Width, header_height); + var header_bg = control.ColumnHeadersDefaultCellStyle.BackgroundColor ?? Theme.ControlMidColor; + e.Canvas.FillRectangle (header_rect, header_bg); + + // Draw row header corner cell + if (control.RowHeadersVisible) { + var corner_rect = new Rectangle (contentArea.Left, y, row_header_offset, header_height); + e.Canvas.FillRectangle (corner_rect, header_bg); + e.Canvas.DrawLine (corner_rect.Right - 1, corner_rect.Top, corner_rect.Right - 1, corner_rect.Bottom, Theme.BorderLowColor); + } + + for (var i = 0; i < control.Columns.Count; i++) { + var column = control.Columns[i]; + + if (!column.Visible) + continue; + + var col_width = control.LogicalToDeviceUnits (column.Width); + var cell_rect = new Rectangle (x, y, col_width, header_height); + + column.HeaderBounds = cell_rect; + + RenderColumnHeader (control, column, i, cell_rect, e); + + x += col_width; + } + + // Draw header bottom border + e.Canvas.DrawLine (contentArea.Left, y + header_height - 1, contentArea.Right, y + header_height - 1, Theme.BorderMidColor); + } + + /// + /// Renders a single column header. + /// + protected virtual void RenderColumnHeader (DataGridView control, DataGridViewColumn column, int columnIndex, Rectangle bounds, PaintEventArgs e) + { + // Draw right border + e.Canvas.DrawLine (bounds.Right - 1, bounds.Top, bounds.Right - 1, bounds.Bottom, Theme.BorderLowColor); + + // Draw text + var text_bounds = bounds; + text_bounds.Inflate (-6, 0); + + var fg = control.ColumnHeadersDefaultCellStyle.ForegroundColor ?? Theme.ForegroundColor; + var font = control.ColumnHeadersDefaultCellStyle.Font ?? Theme.UIFontBold; + var font_size = control.ColumnHeadersDefaultCellStyle.FontSize ?? Theme.ItemFontSize; + + e.Canvas.DrawText (column.HeaderText, font, control.LogicalToDeviceUnits (font_size), text_bounds, fg, ContentAlignment.MiddleLeft, maxLines: 1); + + // Draw sort indicator + if (column.SortOrder != SortOrder.None) + RenderSortGlyph (e, bounds, column.SortOrder, fg); + } + + /// + /// Renders the sort direction glyph. + /// + protected virtual void RenderSortGlyph (PaintEventArgs e, Rectangle bounds, SortOrder sortOrder, SKColor color) + { + var glyph_size = 6; + var glyph_x = bounds.Right - glyph_size - 8; + var glyph_y = bounds.Top + (bounds.Height - glyph_size) / 2; + + using var path = new SKPath (); + + if (sortOrder == SortOrder.Ascending) { + path.MoveTo (glyph_x, glyph_y + glyph_size); + path.LineTo (glyph_x + glyph_size / 2, glyph_y); + path.LineTo (glyph_x + glyph_size, glyph_y + glyph_size); + path.Close (); + } else { + path.MoveTo (glyph_x, glyph_y); + path.LineTo (glyph_x + glyph_size / 2, glyph_y + glyph_size); + path.LineTo (glyph_x + glyph_size, glyph_y); + path.Close (); + } + + using var paint = new SKPaint { Color = color, IsAntialias = true }; + e.Canvas.DrawPath (path, paint); + } + + /// + /// Renders the data rows. + /// + protected virtual void RenderRows (DataGridView control, PaintEventArgs e, Rectangle contentArea) + { + var header_offset = control.ColumnHeadersVisible ? control.ScaledHeaderHeight : 0; + var y = contentArea.Top + header_offset; + + for (var i = control.FirstDisplayedScrollingRowIndex; i < control.Rows.Count; i++) { + if (y >= contentArea.Bottom) + break; + + var row = control.Rows[i]; + var row_height = control.LogicalToDeviceUnits (row.Height); + var row_rect = new Rectangle (contentArea.Left, y, contentArea.Width, Math.Min (row_height, contentArea.Bottom - y)); + + row.Bounds = row_rect; + + RenderRow (control, row, i, row_rect, e); + + y += row_height; + } + } + + /// + /// Renders a single row. + /// + protected virtual void RenderRow (DataGridView control, DataGridViewRow row, int rowIndex, Rectangle bounds, PaintEventArgs e) + { + // Determine background color from cell styles + SKColor? bg = null; + + if (control.SelectedRowIndex == rowIndex) + bg = Theme.ControlHighlightLowColor; + else if (control.HoveredRowIndex == rowIndex) + bg = Theme.ControlMidColor; + else if (rowIndex % 2 == 1 && control.AlternatingRowsDefaultCellStyle.BackgroundColor.HasValue) + bg = control.AlternatingRowsDefaultCellStyle.BackgroundColor.Value; + else if (rowIndex % 2 == 1) + bg = AlternatingRowColor (); + else if (control.DefaultCellStyle.BackgroundColor.HasValue) + bg = control.DefaultCellStyle.BackgroundColor.Value; + + if (bg.HasValue) + e.Canvas.FillRectangle (bounds, bg.Value); + + // Draw row header + if (control.RowHeadersVisible) { + var rh_width = control.ScaledRowHeadersWidth; + var rh_rect = new Rectangle (bounds.Left, bounds.Top, rh_width, bounds.Height); + RenderRowHeader (control, row, rowIndex, rh_rect, e); + } + + // Draw cells + var row_header_offset = control.RowHeadersVisible ? control.ScaledRowHeadersWidth : 0; + var x = bounds.Left + row_header_offset - control.HorizontalScrollOffset; + + for (var i = 0; i < control.Columns.Count; i++) { + var column = control.Columns[i]; + + if (!column.Visible) + continue; + + var col_width = control.LogicalToDeviceUnits (column.Width); + var cell_rect = new Rectangle (x, bounds.Top, col_width, bounds.Height); + + var cell_value = i < row.Cells.Count ? row.Cells[i].Value : string.Empty; + + if (i < row.Cells.Count) + row.Cells[i].Bounds = cell_rect; + + var cell_style = i < row.Cells.Count ? row.Cells[i].Style : null; + RenderCell (control, cell_value, rowIndex, i, cell_rect, cell_style, e); + + x += col_width; + } + + // Draw row bottom border + e.Canvas.DrawLine (bounds.Left, bounds.Bottom - 1, bounds.Right, bounds.Bottom - 1, Theme.BorderLowColor); + } + + /// + /// Renders a row header cell. + /// + protected virtual void RenderRowHeader (DataGridView control, DataGridViewRow row, int rowIndex, Rectangle bounds, PaintEventArgs e) + { + var bg = control.RowHeadersDefaultCellStyle.BackgroundColor ?? Theme.ControlMidColor; + e.Canvas.FillRectangle (bounds, bg); + + // Draw right border + e.Canvas.DrawLine (bounds.Right - 1, bounds.Top, bounds.Right - 1, bounds.Bottom, Theme.BorderLowColor); + + // Draw selection indicator triangle for the selected row + if (control.SelectedRowIndex == rowIndex) { + var tri_size = 6; + var tri_x = bounds.Left + (bounds.Width - tri_size) / 2; + var tri_y = bounds.Top + (bounds.Height - tri_size) / 2; + + using var path = new SKPath (); + path.MoveTo (tri_x, tri_y); + path.LineTo (tri_x + tri_size, tri_y + tri_size / 2); + path.LineTo (tri_x, tri_y + tri_size); + path.Close (); + + using var paint = new SKPaint { Color = Theme.ForegroundColor, IsAntialias = true }; + e.Canvas.DrawPath (path, paint); + } + } + + /// + /// Renders a single cell. + /// + protected virtual void RenderCell (DataGridView control, string value, int rowIndex, int columnIndex, Rectangle bounds, ControlStyle? cellStyle, PaintEventArgs e) + { + // Draw per-cell background if set + var cell_bg = cellStyle?.BackgroundColor; + + if (cell_bg.HasValue && control.SelectedRowIndex != rowIndex && control.HoveredRowIndex != rowIndex) + e.Canvas.FillRectangle (bounds, cell_bg.Value); + + // Draw cell right border + e.Canvas.DrawLine (bounds.Right - 1, bounds.Top, bounds.Right - 1, bounds.Bottom, Theme.BorderLowColor); + + // Draw cell selection for cell mode + if (control.SelectionMode != DataGridViewSelectionMode.FullRowSelect && control.SelectedRowIndex == rowIndex && control.SelectedColumnIndex == columnIndex) + e.Canvas.DrawRectangle (bounds, Theme.AccentColor, 2); + + // Draw text using cell style or default cell style + var text_bounds = bounds; + text_bounds.Inflate (-4, 0); + + var fg = cellStyle?.ForegroundColor ?? control.DefaultCellStyle.ForegroundColor ?? Theme.ForegroundColor; + var font = cellStyle?.Font ?? control.DefaultCellStyle.Font ?? Theme.UIFont; + var font_size = cellStyle?.FontSize ?? control.DefaultCellStyle.FontSize ?? Theme.ItemFontSize; + + e.Canvas.DrawText (value, font, control.LogicalToDeviceUnits (font_size), text_bounds, fg, ContentAlignment.MiddleLeft, maxLines: 1); + } + + /// + /// Gets the alternating row background color. + /// + private static SKColor AlternatingRowColor () + { + // Slightly different from the default background + var bg = Theme.ControlLowColor; + return new SKColor ( + (byte)Math.Max (0, bg.Red - 5), + (byte)Math.Max (0, bg.Green - 5), + (byte)Math.Max (0, bg.Blue - 5), + bg.Alpha + ); + } + } +} diff --git a/src/Modern.Forms/Renderers/RenderManager.cs b/src/Modern.Forms/Renderers/RenderManager.cs index 1f42164..10ff64e 100644 --- a/src/Modern.Forms/Renderers/RenderManager.cs +++ b/src/Modern.Forms/Renderers/RenderManager.cs @@ -12,6 +12,7 @@ static RenderManager () SetRenderer