sliding window for vision builder image strip pannel

This commit is contained in:
meelstorm
2025-11-14 10:26:43 +01:00
parent 0ee132788d
commit e5eaa88e51
7 changed files with 1090 additions and 193 deletions

View File

@@ -0,0 +1,150 @@
using System;
using System.Drawing;
using System.IO;
using System.Windows.Forms;
using OpenCvSharp;
using OpenCvSharp.Extensions;
namespace Hawkeye.VisionBuilder.Features.ImagesStrip.Components
{
/// <summary>
/// Represents a single image thumbnail item with an image and filename label.
/// </summary>
public class ImageThumbnailControl : Panel
{
private const int ThumbnailSize = 108;
private const int BorderWidth = 3;
private readonly PictureBox _pictureBox;
private readonly Label _label;
private bool _isSelected;
private string _imagePath;
public string ImagePath
{
get => _imagePath;
set
{
_imagePath = value;
LoadImage(value);
_label.Text = Path.GetFileNameWithoutExtension(value);
}
}
public bool IsSelected
{
get => _isSelected;
set
{
if (_isSelected != value)
{
_isSelected = value;
Invalidate(); // Trigger repaint to show/hide border
}
}
}
public event EventHandler ThumbnailClicked;
public ImageThumbnailControl()
{
// Set up the container panel
Width = ThumbnailSize + (BorderWidth * 2);
Height = ThumbnailSize + 25 + (BorderWidth * 2); // Extra space for label
Margin = new Padding(5);
Cursor = Cursors.Hand;
// Create PictureBox for the image
_pictureBox = new PictureBox
{
Width = ThumbnailSize,
Height = ThumbnailSize,
SizeMode = PictureBoxSizeMode.StretchImage,
Location = new System.Drawing.Point(BorderWidth, BorderWidth),
BackColor = Color.Black
};
_pictureBox.Click += OnThumbnailClick;
// Create Label for the filename
_label = new Label
{
Width = ThumbnailSize,
Height = 20,
Location = new System.Drawing.Point(BorderWidth, ThumbnailSize + BorderWidth + 2),
TextAlign = ContentAlignment.MiddleCenter,
AutoEllipsis = true,
Font = new Font("Segoe UI", 8f)
};
_label.Click += OnThumbnailClick;
Controls.Add(_pictureBox);
Controls.Add(_label);
// Enable double buffering to reduce flicker
DoubleBuffered = true;
}
private void LoadImage(string imagePath)
{
try
{
if (File.Exists(imagePath))
{
using (var mat = new Mat(imagePath))
{
var resized = mat.Resize(new OpenCvSharp.Size(ThumbnailSize, ThumbnailSize));
var bitmap = resized.ToBitmap();
// Dispose old image if exists
var oldImage = _pictureBox.Image;
_pictureBox.Image = bitmap;
oldImage?.Dispose();
}
}
}
catch (Exception ex)
{
// If image loading fails, show error in label
_label.Text = "Error loading image";
_label.ForeColor = Color.Red;
}
}
private void OnThumbnailClick(object sender, EventArgs e)
{
ThumbnailClicked?.Invoke(this, EventArgs.Empty);
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
// Draw selection border if selected
if (_isSelected)
{
using (var pen = new Pen(Color.DodgerBlue, BorderWidth))
{
var rect = new Rectangle(
BorderWidth / 2,
BorderWidth / 2,
Width - BorderWidth,
Height - BorderWidth
);
e.Graphics.DrawRectangle(pen, rect);
}
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
// Dispose the image to free memory
_pictureBox?.Image?.Dispose();
_pictureBox?.Dispose();
_label?.Dispose();
}
base.Dispose(disposing);
}
}
}

View File

@@ -0,0 +1,47 @@
namespace Hawkeye.VisionBuilder.Features.ImagesStrip.Components
{
partial class VirtualizedImageList
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
#region Component Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
flowLayoutPanel = new FlowLayoutPanel();
SuspendLayout();
//
// flowLayoutPanel
//
flowLayoutPanel.AutoScroll = true;
flowLayoutPanel.Dock = DockStyle.Fill;
flowLayoutPanel.Location = new Point(0, 0);
flowLayoutPanel.Name = "flowLayoutPanel";
flowLayoutPanel.Size = new Size(800, 150);
flowLayoutPanel.TabIndex = 0;
flowLayoutPanel.WrapContents = false;
//
// VirtualizedImageList
//
AutoScaleDimensions = new SizeF(7F, 15F);
AutoScaleMode = AutoScaleMode.Font;
Controls.Add(flowLayoutPanel);
Name = "VirtualizedImageList";
Size = new Size(800, 150);
ResumeLayout(false);
}
#endregion
private System.Windows.Forms.FlowLayoutPanel flowLayoutPanel;
}
}

View File

@@ -0,0 +1,272 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
namespace Hawkeye.VisionBuilder.Features.ImagesStrip.Components
{
/// <summary>
/// A virtualized image list control that efficiently displays image thumbnails
/// by loading only the images in the current window (managed by ImagesViewModel).
/// </summary>
public partial class VirtualizedImageList : UserControl
{
private ImagesViewModel _viewModel;
private Dictionary<int, ImageThumbnailControl> _thumbnailControls = new Dictionary<int, ImageThumbnailControl>();
private bool _isUpdating = false;
/// <summary>
/// Gets or sets the ImagesViewModel that manages the image data and windowing logic.
/// </summary>
public ImagesViewModel ViewModel
{
get => _viewModel;
set
{
if (_viewModel != null)
{
// Unsubscribe from old view model
_viewModel.ImagesAdded -= OnImagesAdded;
_viewModel.ImagesRemoved -= OnImagesRemoved;
_viewModel.CurrentImageChanged -= OnCurrentImageChanged;
_viewModel.ImagesCleared -= OnImagesCleared;
}
_viewModel = value;
if (_viewModel != null)
{
// Subscribe to new view model
_viewModel.ImagesAdded += OnImagesAdded;
_viewModel.ImagesRemoved += OnImagesRemoved;
_viewModel.CurrentImageChanged += OnCurrentImageChanged;
_viewModel.ImagesCleared += OnImagesCleared;
// Load initial images
RefreshImages();
}
}
}
/// <summary>
/// Event raised when a thumbnail is clicked by the user.
/// </summary>
public event EventHandler<string> ImageSelected;
public VirtualizedImageList()
{
InitializeComponent();
}
private void OnImagesAdded(object sender, ImageAddedEventArgs[] e)
{
if (_isUpdating) return;
_isUpdating = true;
flowLayoutPanel.SuspendLayout();
try
{
foreach (var args in e.OrderBy(x => x.Index))
{
AddThumbnail(args.Index, args.FilePath);
}
}
finally
{
flowLayoutPanel.ResumeLayout();
_isUpdating = false;
}
UpdateCurrentSelection();
}
private void OnImagesRemoved(object sender, ImageRemovedEventArgs[] e)
{
if (_isUpdating) return;
_isUpdating = true;
flowLayoutPanel.SuspendLayout();
try
{
foreach (var args in e.OrderByDescending(x => x.Index))
{
RemoveThumbnail(args.Index);
}
}
finally
{
flowLayoutPanel.ResumeLayout();
_isUpdating = false;
}
}
private void OnCurrentImageChanged(object sender, CurrentImageChangedEventArgs e)
{
UpdateCurrentSelection();
ScrollToCurrentImage();
}
private void OnImagesCleared(object sender, EventArgs e)
{
ClearAllThumbnails();
}
private void AddThumbnail(int index, string filePath)
{
// Create new thumbnail control
var thumbnail = new ImageThumbnailControl
{
ImagePath = filePath,
Tag = index // Store the index in Tag for later reference
};
thumbnail.ThumbnailClicked += OnThumbnailClicked;
// Find the correct insertion position to maintain order
int insertPosition = 0;
foreach (Control control in flowLayoutPanel.Controls)
{
if (control.Tag is int controlIndex && controlIndex < index)
{
insertPosition++;
}
else
{
break;
}
}
flowLayoutPanel.Controls.Add(thumbnail);
flowLayoutPanel.Controls.SetChildIndex(thumbnail, insertPosition);
_thumbnailControls[index] = thumbnail;
}
private void RemoveThumbnail(int index)
{
if (_thumbnailControls.TryGetValue(index, out var thumbnail))
{
thumbnail.ThumbnailClicked -= OnThumbnailClicked;
flowLayoutPanel.Controls.Remove(thumbnail);
thumbnail.Dispose();
_thumbnailControls.Remove(index);
}
}
private void ClearAllThumbnails()
{
_isUpdating = true;
flowLayoutPanel.SuspendLayout();
try
{
foreach (var thumbnail in _thumbnailControls.Values)
{
thumbnail.ThumbnailClicked -= OnThumbnailClicked;
thumbnail.Dispose();
}
_thumbnailControls.Clear();
flowLayoutPanel.Controls.Clear();
}
finally
{
flowLayoutPanel.ResumeLayout();
_isUpdating = false;
}
}
private void UpdateCurrentSelection()
{
if (_viewModel == null) return;
int currentIndex = _viewModel.CurrentIndex;
// Update IsSelected property for all thumbnails
foreach (var kvp in _thumbnailControls)
{
kvp.Value.IsSelected = (kvp.Key == currentIndex);
}
// Raise event for external listeners
if (_thumbnailControls.TryGetValue(currentIndex, out var currentThumbnail))
{
ImageSelected?.Invoke(this, currentThumbnail.ImagePath);
}
}
private void ScrollToCurrentImage()
{
if (_viewModel == null) return;
int currentIndex = _viewModel.CurrentIndex;
if (_thumbnailControls.TryGetValue(currentIndex, out var thumbnail))
{
// Scroll the thumbnail into view
flowLayoutPanel.ScrollControlIntoView(thumbnail);
}
}
private void OnThumbnailClicked(object sender, EventArgs e)
{
if (sender is ImageThumbnailControl thumbnail && _viewModel != null)
{
string imagePath = thumbnail.ImagePath;
// Notify view model of selection
_viewModel.SelectByPath(imagePath);
// Raise event for external listeners
ImageSelected?.Invoke(this, imagePath);
}
}
private void RefreshImages()
{
if (_viewModel == null) return;
ClearAllThumbnails();
_isUpdating = true;
flowLayoutPanel.SuspendLayout();
try
{
// Add all currently loaded images
foreach (var imageItem in _viewModel.LoadedImages.OrderBy(x => x.Index))
{
AddThumbnail(imageItem.Index, imageItem.FilePath);
}
}
finally
{
flowLayoutPanel.ResumeLayout();
_isUpdating = false;
}
UpdateCurrentSelection();
ScrollToCurrentImage();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
if (_viewModel != null)
{
_viewModel.ImagesAdded -= OnImagesAdded;
_viewModel.ImagesRemoved -= OnImagesRemoved;
_viewModel.CurrentImageChanged -= OnCurrentImageChanged;
_viewModel.ImagesCleared -= OnImagesCleared;
}
ClearAllThumbnails();
}
base.Dispose(disposing);
}
}
}

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>