feat: add scrollbar and arrow key navigation to FileListControl

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 13:53:36 -04:00
parent afbbc9ed79
commit ae262b8a77

View File

@@ -38,6 +38,8 @@ namespace OpenNest.Controls
private readonly Pen separatorPen = new Pen(Color.FromArgb(230, 230, 230)); private readonly Pen separatorPen = new Pen(Color.FromArgb(230, 230, 230));
private readonly SolidBrush emptyStateBrush = new SolidBrush(Color.FromArgb(160, 160, 160)); private readonly SolidBrush emptyStateBrush = new SolidBrush(Color.FromArgb(160, 160, 160));
private readonly VScrollBar scrollBar = new VScrollBar();
public event EventHandler<int> SelectedIndexChanged; public event EventHandler<int> SelectedIndexChanged;
public event EventHandler<FileListItem> ItemRightClicked; public event EventHandler<FileListItem> ItemRightClicked;
@@ -47,10 +49,16 @@ namespace OpenNest.Controls
ControlStyles.AllPaintingInWmPaint | ControlStyles.AllPaintingInWmPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.OptimizedDoubleBuffer |
ControlStyles.UserPaint | ControlStyles.UserPaint |
ControlStyles.ResizeRedraw, true); ControlStyles.ResizeRedraw |
ControlStyles.Selectable, true);
BackColor = Color.White; BackColor = Color.White;
Font = new Font("Segoe UI", 9f); Font = new Font("Segoe UI", 9f);
scrollBar.Dock = DockStyle.Right;
scrollBar.Visible = false;
scrollBar.Scroll += (s, e) => { scrollOffset = e.NewValue; Invalidate(); };
Controls.Add(scrollBar);
} }
public IReadOnlyList<FileListItem> Items => items; public IReadOnlyList<FileListItem> Items => items;
@@ -77,6 +85,7 @@ namespace OpenNest.Controls
{ {
selectedIndex++; selectedIndex++;
} }
UpdateScrollBar();
Invalidate(); Invalidate();
} }
@@ -85,6 +94,7 @@ namespace OpenNest.Controls
items.RemoveAt(index); items.RemoveAt(index);
if (selectedIndex >= items.Count) if (selectedIndex >= items.Count)
selectedIndex = items.Count - 1; selectedIndex = items.Count - 1;
UpdateScrollBar();
Invalidate(); Invalidate();
SelectedIndexChanged?.Invoke(this, selectedIndex); SelectedIndexChanged?.Invoke(this, selectedIndex);
} }
@@ -94,6 +104,7 @@ namespace OpenNest.Controls
items.Clear(); items.Clear();
selectedIndex = -1; selectedIndex = -1;
scrollOffset = 0; scrollOffset = 0;
UpdateScrollBar();
Invalidate(); Invalidate();
} }
@@ -103,6 +114,74 @@ namespace OpenNest.Controls
Invalidate(); Invalidate();
} }
private void UpdateScrollBar()
{
var totalHeight = items.Count * ItemHeight;
if (totalHeight > Height)
{
scrollBar.Maximum = totalHeight;
scrollBar.LargeChange = Height;
scrollBar.SmallChange = ItemHeight;
scrollBar.Visible = true;
}
else
{
scrollBar.Visible = false;
scrollOffset = 0;
}
}
private void EnsureVisible(int index)
{
if (index < 0 || index >= items.Count) return;
var itemTop = index * ItemHeight;
var itemBottom = itemTop + ItemHeight;
if (itemTop < scrollOffset)
scrollOffset = itemTop;
else if (itemBottom > scrollOffset + Height)
scrollOffset = itemBottom - Height;
if (scrollBar.Visible)
scrollBar.Value = System.Math.Min(scrollOffset, scrollBar.Maximum - scrollBar.LargeChange + 1);
}
protected override void OnResize(EventArgs e)
{
base.OnResize(e);
UpdateScrollBar();
}
protected override bool IsInputKey(Keys keyData)
{
if (keyData == Keys.Up || keyData == Keys.Down)
return true;
return base.IsInputKey(keyData);
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (items.Count == 0) return;
var newIndex = selectedIndex;
if (e.KeyCode == Keys.Down)
newIndex = System.Math.Min(selectedIndex + 1, items.Count - 1);
else if (e.KeyCode == Keys.Up)
newIndex = System.Math.Max(selectedIndex - 1, 0);
else
return;
if (newIndex != selectedIndex)
{
selectedIndex = newIndex;
EnsureVisible(selectedIndex);
Invalidate();
SelectedIndexChanged?.Invoke(this, selectedIndex);
}
e.Handled = true;
}
protected override void OnPaint(PaintEventArgs e) protected override void OnPaint(PaintEventArgs e)
{ {
base.OnPaint(e); base.OnPaint(e);
@@ -120,6 +199,7 @@ namespace OpenNest.Controls
var smallFont = new Font("Segoe UI", 8f); var smallFont = new Font("Segoe UI", 8f);
var mutedBrush = new SolidBrush(Color.FromArgb(130, 130, 130)); var mutedBrush = new SolidBrush(Color.FromArgb(130, 130, 130));
var textBrush = new SolidBrush(ForeColor); var textBrush = new SolidBrush(ForeColor);
var contentWidth = scrollBar.Visible ? Width - scrollBar.Width : Width;
for (var i = 0; i < items.Count; i++) for (var i = 0; i < items.Count; i++)
{ {
@@ -127,7 +207,7 @@ namespace OpenNest.Controls
if (y + ItemHeight < 0 || y > Height) continue; if (y + ItemHeight < 0 || y > Height) continue;
var item = items[i]; var item = items[i];
var rect = new Rectangle(0, y, Width, ItemHeight); var rect = new Rectangle(0, y, contentWidth, ItemHeight);
// Background // Background
if (i == selectedIndex) if (i == selectedIndex)
@@ -140,7 +220,7 @@ namespace OpenNest.Controls
g.FillRectangle(accentBrush, 0, y, AccentBarWidth, ItemHeight); g.FillRectangle(accentBrush, 0, y, AccentBarWidth, ItemHeight);
// Name // Name
var nameRect = new Rectangle(AccentBarWidth + 8, y + 6, Width - 70, 20); var nameRect = new Rectangle(AccentBarWidth + 8, y + 6, contentWidth - 70, 20);
TextRenderer.DrawText(g, item.Name, boldFont, nameRect, ForeColor, TextFormatFlags.Left | TextFormatFlags.EndEllipsis); TextRenderer.DrawText(g, item.Name, boldFont, nameRect, ForeColor, TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
// Dimensions + entity count // Dimensions + entity count
@@ -148,17 +228,17 @@ namespace OpenNest.Controls
var dimText = bounds != null var dimText = bounds != null
? $"{bounds.Width:0.#} x {bounds.Length:0.#} — {item.EntityCount} entities" ? $"{bounds.Width:0.#} x {bounds.Length:0.#} — {item.EntityCount} entities"
: $"{item.EntityCount} entities"; : $"{item.EntityCount} entities";
var dimRect = new Rectangle(AccentBarWidth + 8, y + 26, Width - 70, 16); var dimRect = new Rectangle(AccentBarWidth + 8, y + 26, contentWidth - 70, 16);
TextRenderer.DrawText(g, dimText, smallFont, dimRect, Color.FromArgb(130, 130, 130), TextFormatFlags.Left); TextRenderer.DrawText(g, dimText, smallFont, dimRect, Color.FromArgb(130, 130, 130), TextFormatFlags.Left);
// Quantity badge // Quantity badge
var qtyText = $"x{item.Quantity}"; var qtyText = $"x{item.Quantity}";
var qtyRect = new Rectangle(Width - 50, y + 12, 40, 24); var qtyRect = new Rectangle(contentWidth - 50, y + 12, 40, 24);
TextRenderer.DrawText(g, qtyText, Font, qtyRect, Color.FromArgb(100, 100, 100), TextFormatFlags.Right | TextFormatFlags.VerticalCenter); TextRenderer.DrawText(g, qtyText, Font, qtyRect, Color.FromArgb(100, 100, 100), TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
// Separator // Separator
if (i < items.Count - 1) if (i < items.Count - 1)
g.DrawLine(separatorPen, AccentBarWidth + 8, y + ItemHeight - 1, Width - 8, y + ItemHeight - 1); g.DrawLine(separatorPen, AccentBarWidth + 8, y + ItemHeight - 1, contentWidth - 8, y + ItemHeight - 1);
} }
boldFont.Dispose(); boldFont.Dispose();
@@ -223,6 +303,8 @@ namespace OpenNest.Controls
base.OnMouseWheel(e); base.OnMouseWheel(e);
var maxScroll = System.Math.Max(0, items.Count * ItemHeight - Height); var maxScroll = System.Math.Max(0, items.Count * ItemHeight - Height);
scrollOffset = System.Math.Max(0, System.Math.Min(maxScroll, scrollOffset - e.Delta)); scrollOffset = System.Math.Max(0, System.Math.Min(maxScroll, scrollOffset - e.Delta));
if (scrollBar.Visible)
scrollBar.Value = System.Math.Min(scrollOffset, scrollBar.Maximum - scrollBar.LargeChange + 1);
Invalidate(); Invalidate();
} }
@@ -237,6 +319,7 @@ namespace OpenNest.Controls
accentBrush.Dispose(); accentBrush.Dispose();
separatorPen.Dispose(); separatorPen.Dispose();
emptyStateBrush.Dispose(); emptyStateBrush.Dispose();
scrollBar.Dispose();
} }
base.Dispose(disposing); base.Dispose(disposing);
} }