Michael Bucari-Tovo bff9b67b72 Remove GridEntry derrived types and interfaces
Use existing BaseUtil.LoadImage delegate, obviating need for derrived classes to load images

Since GridEntry types are no longer generic, interfaces are unnecessary and deleted.
2025-07-21 10:47:10 -06:00

626 lines
22 KiB
C#

using DataLayer;
using Dinah.Core;
using Dinah.Core.Collections.Generic;
using Dinah.Core.WindowsDesktop.Forms;
using LibationFileManager;
using LibationUiBase.GridView;
using System;
using System.Collections.Generic;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
#nullable enable
namespace LibationWinForms.GridView
{
public delegate void GridEntryClickedEventHandler(GridEntry liveGridEntry);
public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry);
public delegate void GridEntryRectangleClickedEventHandler(GridEntry liveGridEntry, Rectangle cellRectangle);
public delegate void ProductsGridCellContextMenuStripNeededEventHandler(GridEntry[] liveGridEntry, ContextMenuStrip ctxMenu);
public partial class ProductsGrid : UserControl
{
/// <summary>Number of visible rows has changed</summary>
public event EventHandler<int>? VisibleCountChanged;
public event LibraryBookEntryClickedEventHandler? LiberateClicked;
public event GridEntryClickedEventHandler? CoverClicked;
public event LibraryBookEntryClickedEventHandler? DetailsClicked;
public event GridEntryRectangleClickedEventHandler? DescriptionClicked;
public new event EventHandler<ScrollEventArgs>? Scroll;
public event EventHandler? RemovableCountChanged;
public event ProductsGridCellContextMenuStripNeededEventHandler? LiberateContextMenuStripNeeded;
private GridEntryBindingList? bindingList;
internal IEnumerable<LibraryBook> GetVisibleBooks()
=> bindingList
?.GetFilteredInItems()
.Select(lbe => lbe.LibraryBook) ?? Enumerable.Empty<LibraryBook>();
internal IEnumerable<LibraryBookEntry> GetAllBookEntries()
=> bindingList?.AllItems().BookEntries() ?? Enumerable.Empty<LibraryBookEntry>();
public ProductsGrid()
{
InitializeComponent();
EnableDoubleBuffering();
gridEntryDataGridView.Scroll += (_, s) => Scroll?.Invoke(this, s);
gridEntryDataGridView.CellContextMenuStripNeeded += GridEntryDataGridView_CellContextMenuStripNeeded;
removeGVColumn.Frozen = false;
defaultFont = gridEntryDataGridView.DefaultCellStyle.Font;
setGridFontScale(Configuration.Instance.GridFontScaleFactor);
setGridScale(Configuration.Instance.GridScaleFactor);
Configuration.Instance.PropertyChanged += Configuration_ScaleChanged;
Configuration.Instance.PropertyChanged += Configuration_FontScaleChanged;
gridEntryDataGridView.Disposed += (_, _) =>
{
Configuration.Instance.PropertyChanged -= Configuration_ScaleChanged;
Configuration.Instance.PropertyChanged -= Configuration_FontScaleChanged;
};
}
#region Scaling
[PropertyChangeFilter(nameof(Configuration.GridFontScaleFactor))]
private void Configuration_FontScaleChanged(object sender, PropertyChangedEventArgsEx e)
{
if (e.NewValue is float v)
setGridFontScale(v);
}
[PropertyChangeFilter(nameof(Configuration.GridScaleFactor))]
private void Configuration_ScaleChanged(object sender, PropertyChangedEventArgsEx e)
{
if (e.NewValue is float v)
setGridScale(v);
}
/// <summary>
/// Keep track of the original dimensions for rescaling
/// </summary>
private static readonly Dictionary<DataGridViewElement, int> originalDims = new();
private readonly Font defaultFont;
private void setGridScale(float scale)
{
foreach (var col in gridEntryDataGridView.Columns.Cast<DataGridViewColumn>())
{
//Only resize fixed-width columns. The rest can be adjusted by users.
if (col.Resizable is DataGridViewTriState.False)
{
if (!originalDims.ContainsKey(col))
originalDims[col] = col.Width;
col.Width = this.DpiScale(originalDims[col], scale);
}
if (col is IDataGridScaleColumn scCol)
scCol.ScaleFactor = scale;
}
if (!originalDims.ContainsKey(gridEntryDataGridView.RowTemplate))
originalDims[gridEntryDataGridView.RowTemplate] = gridEntryDataGridView.RowTemplate.Height;
var height = gridEntryDataGridView.RowTemplate.Height = this.DpiScale(originalDims[gridEntryDataGridView.RowTemplate], scale);
foreach (var row in gridEntryDataGridView.Rows.Cast<DataGridViewRow>())
row.Height = height;
}
private void setGridFontScale(float scale)
=> gridEntryDataGridView.DefaultCellStyle.Font = new Font(defaultFont.FontFamily, defaultFont.Size * scale);
#endregion
private static string? RemoveLineBreaks(string? text)
=> text?.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' ');
private void GridEntryDataGridView_CellContextMenuStripNeeded(object? sender, DataGridViewCellContextMenuStripNeededEventArgs e)
{
// header
if (e.RowIndex < 0 || sender is not DataGridView dgv)
return;
e.ContextMenuStrip = new ContextMenuStrip();
// any column except cover & stop light
if (e.ColumnIndex != liberateGVColumn.Index && e.ColumnIndex != coverGVColumn.Index)
{
e.ContextMenuStrip.Items.Add("Copy Cell Contents", null, (_, __) =>
{
try
{
string clipboardText;
if (dgv.SelectedCells.Count <= 1)
{
//Copy contents only of cell that was right-clicked on.
clipboardText = dgv[e.ColumnIndex, e.RowIndex].FormattedValue?.ToString() ?? string.Empty;
}
else
{
//Copy contents of selected cells. Each row is a new line,
//and columns are separated with tabs. Similar formatting to Microsoft Excel.
var selectedCells
= dgv.SelectedCells
.OfType<DataGridViewCell>()
.Where(c => c.OwningColumn is not null && c.OwningRow is not null)
.OrderBy(c => c.RowIndex)
.ThenBy(c => c.OwningColumn!.DisplayIndex)
.ToList();
var headerText
= string.Join("\t",
selectedCells
.Select(c => c.OwningColumn)
.Distinct()
.Select(c => RemoveLineBreaks(c?.HeaderText))
.OfType<string>());
List<string> linesOfText = [headerText];
foreach (var distinctRow in selectedCells.Select(c => c.RowIndex).Distinct())
{
linesOfText.Add(string.Join("\t",
selectedCells
.Where(c => c.RowIndex == distinctRow)
.Select(c => RemoveLineBreaks(c.FormattedValue?.ToString()) ?? string.Empty)
));
}
clipboardText = string.Join(Environment.NewLine, linesOfText);
}
Clipboard.SetDataObject(clipboardText, false, 5, 150);
}
catch(Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error copying text to clipboard");
}
});
e.ContextMenuStrip.Items.Add(new ToolStripSeparator());
}
var clickedEntry = getGridEntry(e.RowIndex);
var allSelected
= gridEntryDataGridView
.SelectedCells
.OfType<DataGridViewCell>()
.Select(c => c.OwningRow)
.OfType<DataGridViewRow>()
.Distinct()
.OrderBy(r => r.Index)
.Select(r => r.DataBoundItem)
.OfType<GridEntry>()
.ToArray();
var clickedIndex = Array.IndexOf(allSelected, clickedEntry);
if (clickedIndex == -1)
{
//User didn't right-click on a selected cell
gridEntryDataGridView.ClearSelection();
gridEntryDataGridView[e.ColumnIndex, e.RowIndex].Selected = true;
allSelected = [clickedEntry];
}
else if (clickedIndex > 0)
{
//Ensure the clicked entry is first in the list
(allSelected[0], allSelected[clickedIndex]) = (allSelected[clickedIndex], allSelected[0]);
}
LiberateContextMenuStripNeeded?.Invoke(allSelected, e.ContextMenuStrip);
}
private void EnableDoubleBuffering()
{
var propertyInfo = gridEntryDataGridView.GetType().GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
propertyInfo?.SetValue(gridEntryDataGridView, true, null);
}
#region Button controls
private void DataGridView_CellContentClick(object sender, DataGridViewCellEventArgs e)
{
try
{
// handle grid button click: https://stackoverflow.com/a/13687844
if (e.RowIndex < 0)
return;
var entry = getGridEntry(e.RowIndex);
if (entry is LibraryBookEntry lbEntry)
{
if (e.ColumnIndex == liberateGVColumn.Index)
LiberateClicked?.Invoke(lbEntry);
else if (e.ColumnIndex == tagAndDetailsGVColumn.Index)
DetailsClicked?.Invoke(lbEntry);
else if (e.ColumnIndex == descriptionGVColumn.Index)
DescriptionClicked?.Invoke(lbEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false));
else if (e.ColumnIndex == coverGVColumn.Index)
CoverClicked?.Invoke(lbEntry);
}
else if (entry is SeriesEntry sEntry)
{
if (e.ColumnIndex == liberateGVColumn.Index)
{
if (sEntry.Liberate.Expanded)
bindingList?.CollapseItem(sEntry);
else
bindingList?.ExpandItem(sEntry);
VisibleCountChanged?.Invoke(this, bindingList?.GetFilteredInItems().Count() ?? 0);
}
else if (e.ColumnIndex == descriptionGVColumn.Index)
DescriptionClicked?.Invoke(sEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false));
else if (e.ColumnIndex == coverGVColumn.Index)
CoverClicked?.Invoke(sEntry);
}
if (e.ColumnIndex == removeGVColumn.Index)
{
gridEntryDataGridView.CommitEdit(DataGridViewDataErrorContexts.Commit);
RemovableCountChanged?.Invoke(this, EventArgs.Empty);
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"An error was encountered while processing a user click in the {nameof(ProductsGrid)}");
}
}
private GridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem<GridEntry>(rowIndex);
#endregion
#region UI display functions
internal bool RemoveColumnVisible
{
get => removeGVColumn.Visible;
set
{
if (value && bindingList is not null)
{
foreach (var book in bindingList.AllItems())
book.Remove = false;
}
removeGVColumn.DisplayIndex = 0;
removeGVColumn.Frozen = value;
removeGVColumn.Visible = value;
}
}
internal async Task BindToGridAsync(List<LibraryBook> dbBooks)
{
//Get the UI thread's synchronization context and set it on the current thread to ensure
//it's available for GetAllProductsAsync and GetAllSeriesEntriesAsync
var sc = Invoke(() => System.Threading.SynchronizationContext.Current);
System.Threading.SynchronizationContext.SetSynchronizationContext(sc);
var geList = await LibraryBookEntry.GetAllProductsAsync(dbBooks);
var seriesEntries = await SeriesEntry.GetAllSeriesEntriesAsync(dbBooks);
geList.AddRange(seriesEntries);
//Sort descending by date (default sort property)
var comparer = new RowComparer();
geList.Sort((a, b) => comparer.Compare(b, a));
//Add all children beneath their parent
foreach (var series in seriesEntries)
{
var seriesIndex = geList.IndexOf(series);
foreach (var child in series.Children)
geList.Insert(++seriesIndex, child);
}
System.Threading.SynchronizationContext.SetSynchronizationContext(null);
bindingList = new GridEntryBindingList(geList);
bindingList.CollapseAll();
//The syncBindingSource ensures that the IGridEntry list is added on the UI thread
syncBindingSource.DataSource = bindingList;
VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count());
}
internal void UpdateGrid(List<LibraryBook> dbBooks)
{
if (bindingList == null)
throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(UpdateGrid)}");
//First row that is in view in the DataGridView
var topRow = gridEntryDataGridView.Rows.Cast<DataGridViewRow>().FirstOrDefault(r => r.Displayed)?.Index ?? 0;
#region Add new or update existing grid entries
//Remove filter prior to adding/updating books
string? existingFilter = syncBindingSource.Filter;
Filter(null);
//Add absent entries to grid, or update existing entry
var allEntries = bindingList.AllItems().BookEntries().ToDictionarySafe(b => b.AudibleProductId);
var seriesEntries = bindingList.AllItems().SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet();
//Get the UI thread's synchronization context and set it on the current thread to ensure
//it's available for creation of new IGridEntry items during upsert
var sc = Invoke(() => System.Threading.SynchronizationContext.Current);
System.Threading.SynchronizationContext.SetSynchronizationContext(sc);
bindingList.RaiseListChangedEvents = false;
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded))
{
var existingEntry = allEntries.TryGetValue(libraryBook.Book.AudibleProductId, out var e) ? e : null;
if (libraryBook.Book.IsProduct())
{
AddOrUpdateBook(libraryBook, existingEntry);
continue;
}
if (parentedEpisodes.Contains(libraryBook))
{
//Only try to add or update is this LibraryBook is a know child of a parent
AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks);
}
}
bindingList.RaiseListChangedEvents = true;
//Re-apply filter after adding new/updating existing books to capture any changes
//The Filter call also ensures that the binding list is reset so the DataGridView
//is made aware of all changes that were made while RaiseListChangedEvents was false
Filter(existingFilter);
#endregion
// remove deleted from grid.
// note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this
var removedBooks =
bindingList
.AllItems()
.BookEntries()
.ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId);
RemoveBooks(removedBooks);
gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow;
}
public void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks)
{
if (bindingList == null)
throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(RemoveBooks)}");
//Remove books in series from their parents' Children list
foreach (var removed in removedBooks.Where(b => b.Liberate.IsEpisode))
removed.Parent.RemoveChild(removed);
//Remove series that have no children
var removedSeries =
bindingList
.AllItems()
.EmptySeries();
foreach (var removed in removedBooks.Cast<GridEntry>().Concat(removedSeries))
//no need to re-filter for removed books
bindingList.Remove(removed);
VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count());
}
private void AddOrUpdateBook(LibraryBook book, LibraryBookEntry? existingBookEntry)
{
if (bindingList == null)
throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(AddOrUpdateBook)}");
if (existingBookEntry is null)
// Add the new product to top
bindingList.Insert(0, new LibraryBookEntry(book));
else
// update existing
existingBookEntry.UpdateLibraryBook(book);
}
private void AddOrUpdateEpisode(LibraryBook episodeBook, LibraryBookEntry? existingEpisodeEntry, List<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
{
if (bindingList == null)
throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(AddOrUpdateEpisode)}");
if (existingEpisodeEntry is null)
{
LibraryBookEntry episodeEntry;
var seriesEntry = seriesEntries.FindSeriesParent(episodeBook);
if (seriesEntry is null)
{
//Series doesn't exist yet, so create and add it
var seriesBook = dbBooks.FindSeriesParent(episodeBook);
if (seriesBook is null)
{
//This is only possible if the user's db has some malformed
//entries from earlier Libation releases that could not be
//automatically fixed. Log, but don't throw.
Serilog.Log.Logger.Error("Episode={0}, Episode Series: {1}", episodeBook, episodeBook.Book.SeriesNames());
return;
}
seriesEntry = new SeriesEntry(seriesBook, episodeBook);
seriesEntries.Add(seriesEntry);
episodeEntry = seriesEntry.Children[0];
seriesEntry.Liberate.Expanded = true;
bindingList.Insert(0, seriesEntry);
}
else
{
//Series exists. Create and add episode child then update the SeriesEntry
episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry);
seriesEntry.Children.Add(episodeEntry);
seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex));
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
seriesEntry.UpdateLibraryBook(seriesBook);
}
//Series entry must be expanded so its child can
//be placed in the correct position beneath it.
var isExpanded = seriesEntry.Liberate.Expanded;
bindingList.ExpandItem(seriesEntry);
//Add episode to the grid beneath the parent
int seriesIndex = bindingList.IndexOf(seriesEntry);
int episodeIndex = seriesEntry.Children.IndexOf(episodeEntry);
bindingList.Insert(seriesIndex + 1 + episodeIndex, episodeEntry);
if (isExpanded)
bindingList.ExpandItem(seriesEntry);
else
bindingList.CollapseItem(seriesEntry);
}
else
existingEpisodeEntry.UpdateLibraryBook(episodeBook);
}
#endregion
#region Filter
public void Filter(string? searchString)
{
if (bindingList is null) return;
int visibleCount = bindingList.Count;
if (string.IsNullOrEmpty(searchString))
syncBindingSource.RemoveFilter();
else
syncBindingSource.Filter = searchString;
if (visibleCount != bindingList.Count)
VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count());
}
#endregion
#region Column Customizations
private void ProductsGrid_Load(object sender, EventArgs e)
{
//https://stackoverflow.com/a/4498512/3335599
if (System.ComponentModel.LicenseManager.UsageMode == System.ComponentModel.LicenseUsageMode.Designtime) return;
gridEntryDataGridView.ColumnWidthChanged += gridEntryDataGridView_ColumnWidthChanged;
gridEntryDataGridView.ColumnDisplayIndexChanged += gridEntryDataGridView_ColumnDisplayIndexChanged;
showHideColumnsContextMenuStrip.Items.Add(new ToolStripLabel("Show / Hide Columns"));
showHideColumnsContextMenuStrip.Items.Add(new ToolStripSeparator());
//Restore Grid Display Settings
var config = Configuration.Instance;
var gridColumnsVisibilities = config.GridColumnsVisibilities;
var gridColumnsWidths = config.GridColumnsWidths;
var displayIndices = config.GridColumnsDisplayIndices;
var cmsKiller = new ContextMenuStrip();
foreach (DataGridViewColumn column in gridEntryDataGridView.Columns)
{
var itemName = column.DataPropertyName;
var visible = gridColumnsVisibilities.GetValueOrDefault(itemName, true);
var menuItem = new ToolStripMenuItem(column.HeaderText)
{
Checked = visible,
Tag = itemName
};
menuItem.Click += HideMenuItem_Click;
showHideColumnsContextMenuStrip.Items.Add(menuItem);
//Only set column widths for user resizable columns.
//Fixed column widths are set by setGridScale()
if (column.Resizable is not DataGridViewTriState.False)
column.Width = gridColumnsWidths.GetValueOrDefault(itemName, this.DpiScale(column.Width));
column.MinimumWidth = 10;
column.HeaderCell.ContextMenuStrip = showHideColumnsContextMenuStrip;
column.Visible = visible;
//Setting a default ContextMenuStrip will allow the columns to handle the
//Show() event so it is not passed up to the _dataGridView.ContextMenuStrip.
//This allows the ContextMenuStrip to be shown if right-clicking in the gray
//background of _dataGridView but not shown if right-clicking inside cells.
column.ContextMenuStrip = cmsKiller;
}
//We must set DisplayIndex properties in ascending order
foreach (var itemName in displayIndices.OrderBy(i => i.Value).Select(i => i.Key))
{
var column = gridEntryDataGridView.Columns
.Cast<DataGridViewColumn>()
.SingleOrDefault(c => c.DataPropertyName == itemName);
if (column is null) continue;
column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, column.Index);
}
//Remove column is always first;
removeGVColumn.DisplayIndex = 0;
removeGVColumn.Visible = false;
removeGVColumn.ValueType = typeof(bool?);
removeGVColumn.FalseValue = false;
removeGVColumn.TrueValue = true;
removeGVColumn.IndeterminateValue = null;
}
private void HideMenuItem_Click(object? sender, EventArgs e)
{
var menuItem = sender as ToolStripMenuItem;
var propertyName = menuItem?.Tag as string;
var column = gridEntryDataGridView.Columns
.Cast<DataGridViewColumn>()
.FirstOrDefault(c => c.DataPropertyName == propertyName);
if (column != null && menuItem != null && propertyName != null)
{
var visible = menuItem.Checked;
menuItem.Checked = !visible;
column.Visible = !visible;
var config = Configuration.Instance;
var dictionary = config.GridColumnsVisibilities;
dictionary[propertyName] = column.Visible;
config.GridColumnsVisibilities = dictionary;
}
}
private void gridEntryDataGridView_ColumnDisplayIndexChanged(object? sender, DataGridViewColumnEventArgs e)
{
var config = Configuration.Instance;
var dictionary = config.GridColumnsDisplayIndices;
dictionary[e.Column.DataPropertyName] = e.Column.DisplayIndex;
config.GridColumnsDisplayIndices = dictionary;
}
private void gridEntryDataGridView_CellToolTipTextNeeded(object? sender, DataGridViewCellToolTipTextNeededEventArgs e)
{
if (e.ColumnIndex == descriptionGVColumn.Index)
e.ToolTipText = "Click to see full description";
else if (e.ColumnIndex == coverGVColumn.Index)
e.ToolTipText = "Click to see full size";
}
private void gridEntryDataGridView_ColumnWidthChanged(object? sender, DataGridViewColumnEventArgs e)
{
var config = Configuration.Instance;
var dictionary = config.GridColumnsWidths;
dictionary[e.Column.DataPropertyName] = e.Column.Width;
config.GridColumnsWidths = dictionary;
}
#endregion
}
}