Add SheetView and WorkbookView for per-sheet UI state

SheetView wraps a Sheet with per-sheet UndoRedoStack and rendering
method delegation. WorkbookView manages SheetView instances per sheet
via lazy creation, enabling per-sheet undo history (like Excel) and
future multi-sheet support.

Purely additive — no existing code changes.
This commit is contained in:
Atanas Korchev
2026-03-22 12:27:16 +02:00
committed by Vladimir Enchev
parent 02aef6c59f
commit 4450a068e3
3 changed files with 457 additions and 0 deletions

View File

@@ -0,0 +1,309 @@
using System;
using Xunit;
namespace Radzen.Blazor.Spreadsheet.Tests;
public class SheetViewTests
{
// --- Construction ---
[Fact]
public void Constructor_SetsSheet()
{
var sheet = new Sheet(10, 10);
var view = new SheetView(sheet);
Assert.Same(sheet, view.Sheet);
}
[Fact]
public void Constructor_ThrowsOnNull()
{
Assert.Throws<ArgumentNullException>(() => new SheetView(null!));
}
[Fact]
public void Constructor_CreatesCommands()
{
var sheet = new Sheet(10, 10);
var view = new SheetView(sheet);
Assert.NotNull(view.Commands);
}
[Fact]
public void DefaultOffsets()
{
var sheet = new Sheet(10, 10);
var view = new SheetView(sheet);
Assert.Equal(24, view.RowHeaderOffset);
Assert.Equal(100, view.ColumnHeaderOffset);
}
// --- Rendering method delegation ---
[Fact]
public void GetRowRange_DelegatesToAxis()
{
var sheet = new Sheet(100, 10);
var view = new SheetView(sheet);
var fromAxis = sheet.Rows.GetIndexRange(0, 500);
var fromView = view.GetRowRange(0, 500);
Assert.Equal(fromAxis.Start, fromView.Start);
Assert.Equal(fromAxis.End, fromView.End);
Assert.Equal(fromAxis.Offset, fromView.Offset);
}
[Fact]
public void GetColumnRange_DelegatesToAxis()
{
var sheet = new Sheet(10, 100);
var view = new SheetView(sheet);
var fromAxis = sheet.Columns.GetIndexRange(0, 500);
var fromView = view.GetColumnRange(0, 500);
Assert.Equal(fromAxis.Start, fromView.Start);
Assert.Equal(fromAxis.End, fromView.End);
}
[Fact]
public void GetRowPixelRange_DelegatesToAxis()
{
var sheet = new Sheet(10, 10);
sheet.Rows[3] = 50;
var view = new SheetView(sheet);
var fromAxis = sheet.Rows.GetPixelRange(2, 4);
var fromView = view.GetRowPixelRange(2, 4);
Assert.Equal(fromAxis.Start, fromView.Start);
Assert.Equal(fromAxis.End, fromView.End);
}
[Fact]
public void GetRowPixelRange_SingleIndex_DelegatesToAxis()
{
var sheet = new Sheet(10, 10);
var view = new SheetView(sheet);
var fromAxis = sheet.Rows.GetPixelRange(5);
var fromView = view.GetRowPixelRange(5);
Assert.Equal(fromAxis.Start, fromView.Start);
Assert.Equal(fromAxis.End, fromView.End);
}
[Fact]
public void GetColumnPixelRange_DelegatesToAxis()
{
var sheet = new Sheet(10, 10);
sheet.Columns[2] = 200;
var view = new SheetView(sheet);
var fromAxis = sheet.Columns.GetPixelRange(1, 3);
var fromView = view.GetColumnPixelRange(1, 3);
Assert.Equal(fromAxis.Start, fromView.Start);
Assert.Equal(fromAxis.End, fromView.End);
}
[Fact]
public void GetColumnPixelRange_SingleIndex_DelegatesToAxis()
{
var sheet = new Sheet(10, 10);
var view = new SheetView(sheet);
var fromAxis = sheet.Columns.GetPixelRange(5);
var fromView = view.GetColumnPixelRange(5);
Assert.Equal(fromAxis.Start, fromView.Start);
Assert.Equal(fromAxis.End, fromView.End);
}
[Fact]
public void TotalHeight_DelegatesToAxis()
{
var sheet = new Sheet(10, 10);
var view = new SheetView(sheet);
Assert.Equal(sheet.Rows.Total, view.TotalHeight);
}
[Fact]
public void TotalWidth_DelegatesToAxis()
{
var sheet = new Sheet(10, 10);
var view = new SheetView(sheet);
Assert.Equal(sheet.Columns.Total, view.TotalWidth);
}
// --- Per-sheet Commands ---
[Fact]
public void Commands_ExecuteAndUndo()
{
var sheet = new Sheet(5, 5);
var view = new SheetView(sheet);
sheet.Cells[0, 0].Value = "A";
var cmd = new ClearContentsCommand(sheet, new RangeRef(new CellRef(0, 0), new CellRef(0, 0)));
view.Commands.Execute(cmd);
Assert.Null(sheet.Cells[0, 0].Value);
view.Commands.Undo();
Assert.Equal("A", sheet.Cells[0, 0].Value);
}
[Fact]
public void Commands_IndependentPerView()
{
var sheet1 = new Sheet(5, 5);
var sheet2 = new Sheet(5, 5);
var view1 = new SheetView(sheet1);
var view2 = new SheetView(sheet2);
sheet1.Cells[0, 0].Value = "S1";
sheet2.Cells[0, 0].Value = "S2";
var cmd1 = new ClearContentsCommand(sheet1, new RangeRef(new CellRef(0, 0), new CellRef(0, 0)));
view1.Commands.Execute(cmd1);
Assert.Null(sheet1.Cells[0, 0].Value);
Assert.Equal("S2", sheet2.Cells[0, 0].Value);
// Undo on view1 doesn't affect view2
view1.Commands.Undo();
Assert.Equal("S1", sheet1.Cells[0, 0].Value);
Assert.Equal("S2", sheet2.Cells[0, 0].Value);
Assert.False(view2.Commands.CanUndo);
}
}
public class WorkbookViewTests
{
[Fact]
public void Constructor_SetsWorkbook()
{
var wb = new Workbook();
var view = new WorkbookView(wb);
Assert.Same(wb, view.Workbook);
}
[Fact]
public void Constructor_ThrowsOnNull()
{
Assert.Throws<ArgumentNullException>(() => new WorkbookView(null!));
}
[Fact]
public void GetView_CreatesViewForSheet()
{
var wb = new Workbook();
var sheet = wb.AddSheet("Sheet1", 10, 10);
var view = new WorkbookView(wb);
var sheetView = view.GetView(sheet);
Assert.NotNull(sheetView);
Assert.Same(sheet, sheetView.Sheet);
}
[Fact]
public void GetView_ReturnsSameViewForSameSheet()
{
var wb = new Workbook();
var sheet = wb.AddSheet("Sheet1", 10, 10);
var view = new WorkbookView(wb);
var v1 = view.GetView(sheet);
var v2 = view.GetView(sheet);
Assert.Same(v1, v2);
}
[Fact]
public void GetView_DifferentViewsForDifferentSheets()
{
var wb = new Workbook();
var s1 = wb.AddSheet("Sheet1", 10, 10);
var s2 = wb.AddSheet("Sheet2", 10, 10);
var view = new WorkbookView(wb);
var v1 = view.GetView(s1);
var v2 = view.GetView(s2);
Assert.NotSame(v1, v2);
Assert.Same(s1, v1.Sheet);
Assert.Same(s2, v2.Sheet);
}
[Fact]
public void GetView_PerSheetUndoHistory()
{
var wb = new Workbook();
var s1 = wb.AddSheet("Sheet1", 5, 5);
var s2 = wb.AddSheet("Sheet2", 5, 5);
var wbView = new WorkbookView(wb);
s1.Cells[0, 0].Value = "A";
s2.Cells[0, 0].Value = "B";
var v1 = wbView.GetView(s1);
var v2 = wbView.GetView(s2);
// Execute command on sheet1's view
v1.Commands.Execute(new ClearContentsCommand(s1, new RangeRef(new CellRef(0, 0), new CellRef(0, 0))));
Assert.True(v1.Commands.CanUndo);
Assert.False(v2.Commands.CanUndo);
// Sheet2 is unaffected
Assert.Equal("B", s2.Cells[0, 0].Value);
}
[Fact]
public void Remove_DisposesView()
{
var wb = new Workbook();
var sheet = wb.AddSheet("Sheet1", 10, 10);
var view = new WorkbookView(wb);
var v1 = view.GetView(sheet);
v1.Commands.Execute(new ClearContentsCommand(sheet, new RangeRef(new CellRef(0, 0), new CellRef(0, 0))));
Assert.True(view.Remove(sheet));
// New view has empty undo stack
var v2 = view.GetView(sheet);
Assert.NotSame(v1, v2);
Assert.False(v2.Commands.CanUndo);
}
[Fact]
public void Remove_ReturnsFalseForUnknownSheet()
{
var wb = new Workbook();
var view = new WorkbookView(wb);
var sheet = new Sheet(5, 5);
Assert.False(view.Remove(sheet));
}
[Fact]
public void GetView_ThrowsOnNull()
{
var wb = new Workbook();
var view = new WorkbookView(wb);
Assert.Throws<ArgumentNullException>(() => view.GetView(null!));
}
}

View File

@@ -0,0 +1,95 @@
namespace Radzen.Blazor.Spreadsheet;
#nullable enable
/// <summary>
/// Holds per-sheet UI state (undo/redo history, rendering layout) that is not part of the document model.
/// </summary>
public class SheetView
{
/// <summary>
/// Gets the document sheet this view wraps.
/// </summary>
public Sheet Sheet { get; }
/// <summary>
/// Gets the per-sheet undo/redo stack.
/// </summary>
public UndoRedoStack Commands { get; } = new();
/// <summary>
/// Gets or sets the header offset for rows (height of column headers in pixels).
/// </summary>
public double RowHeaderOffset { get; set; } = 24;
/// <summary>
/// Gets or sets the header offset for columns (width of row headers in pixels).
/// </summary>
public double ColumnHeaderOffset { get; set; } = 100;
/// <summary>
/// Initializes a new instance of the <see cref="SheetView"/> class.
/// </summary>
public SheetView(Sheet sheet)
{
Sheet = sheet ?? throw new System.ArgumentNullException(nameof(sheet));
}
/// <summary>
/// Gets the visible row index range for the given scroll position and viewport height.
/// </summary>
public IndexRange GetRowRange(double start, double end, bool includeFrozen = false)
{
return Sheet.Rows.GetIndexRange(start, end, includeFrozen);
}
/// <summary>
/// Gets the visible column index range for the given scroll position and viewport width.
/// </summary>
public IndexRange GetColumnRange(double start, double end, bool includeFrozen = false)
{
return Sheet.Columns.GetIndexRange(start, end, includeFrozen);
}
/// <summary>
/// Gets the pixel range for the specified row indices.
/// </summary>
public PixelRange GetRowPixelRange(int startIndex, int endIndex)
{
return Sheet.Rows.GetPixelRange(startIndex, endIndex);
}
/// <summary>
/// Gets the pixel range for a single row index.
/// </summary>
public PixelRange GetRowPixelRange(int index)
{
return Sheet.Rows.GetPixelRange(index);
}
/// <summary>
/// Gets the pixel range for the specified column indices.
/// </summary>
public PixelRange GetColumnPixelRange(int startIndex, int endIndex)
{
return Sheet.Columns.GetPixelRange(startIndex, endIndex);
}
/// <summary>
/// Gets the pixel range for a single column index.
/// </summary>
public PixelRange GetColumnPixelRange(int index)
{
return Sheet.Columns.GetPixelRange(index);
}
/// <summary>
/// Gets the total scrollable height including all visible rows and the header offset.
/// </summary>
public double TotalHeight => Sheet.Rows.Total;
/// <summary>
/// Gets the total scrollable width including all visible columns and the header offset.
/// </summary>
public double TotalWidth => Sheet.Columns.Total;
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
namespace Radzen.Blazor.Spreadsheet;
#nullable enable
/// <summary>
/// Manages per-sheet UI state (SheetView instances) for a workbook.
/// Each sheet gets its own view with independent undo/redo history and rendering state.
/// </summary>
public class WorkbookView
{
private readonly Dictionary<Sheet, SheetView> views = [];
/// <summary>
/// Gets the workbook this view wraps.
/// </summary>
public Workbook Workbook { get; }
/// <summary>
/// Initializes a new instance of the <see cref="WorkbookView"/> class.
/// </summary>
public WorkbookView(Workbook workbook)
{
Workbook = workbook ?? throw new ArgumentNullException(nameof(workbook));
}
/// <summary>
/// Gets or creates a SheetView for the specified sheet.
/// </summary>
public SheetView GetView(Sheet sheet)
{
ArgumentNullException.ThrowIfNull(sheet);
if (!views.TryGetValue(sheet, out var view))
{
view = new SheetView(sheet);
views[sheet] = view;
}
return view;
}
/// <summary>
/// Removes the view for the specified sheet, freeing its undo history and rendering state.
/// </summary>
public bool Remove(Sheet sheet)
{
return views.Remove(sheet);
}
}