Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Cellm.Tests/Cellm.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="ExcelDna.Testing" Version="1.9.0-beta2" />
<PackageReference Include="ExcelDna.Testing" Version="1.9.0" />
<PackageReference Include="xunit" Version="2.9.3" />
</ItemGroup>

Expand Down
12 changes: 6 additions & 6 deletions src/Cellm.Tests/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
"net9.0-windows7.0": {
"ExcelDna.Testing": {
"type": "Direct",
"requested": "[1.9.0-beta2, )",
"resolved": "1.9.0-beta2",
"contentHash": "2D8QaJVsPbmER45pMjE+S6m+HfU05Ehgj0n/wMfnzC3GL6qT4ZY5GJcNu/BujgePeaGkNvcyl7tX0NAHJPOl0A==",
"requested": "[1.9.0, )",
"resolved": "1.9.0",
"contentHash": "6WbuQj+uGs7J0RW2TaAeG+/xPs0jfuzfAh2LZ4dHlEFJAuxQ02eU0UeQruEiPpmS2etyMI+rTB8VDDu9p0fIkA==",
"dependencies": {
"ExcelDna.Integration": "[1.9.0-beta2]",
"ExcelDna.Integration": "[1.9.0]",
"ExcelDna.Interop": "[15.0.1]",
"Microsoft.NET.Test.Sdk": "16.11.0",
"Microsoft.VisualStudio.Interop": "17.1.32210.191",
Expand All @@ -34,8 +34,8 @@
},
"ExcelDna.Integration": {
"type": "Transitive",
"resolved": "1.9.0-beta2",
"contentHash": "Zk4JICXGXJY10Bh3hFVUmvMvhwcytQ4Zr46nEAyIpUJcStAhUatju8JyXGd8OhiyponPI5cighZOM31z4wVRSw=="
"resolved": "1.9.0",
"contentHash": "A1nbeRWa+rg3RoO9NV9iqNwQxWb9eVNG+Ki3uCfo8D3U8CT11/fzTIvVT3y2LoCTzqRIPwTysqp9sj42MKYw5Q=="
},
"ExcelDna.Interop": {
"type": "Transitive",
Expand Down
231 changes: 129 additions & 102 deletions src/Cellm/AddIn/UserInterface/Ribbon/RibbonPromptGroup.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using System.Text.Json;
using ExcelDna.Integration;
using ExcelDna.Integration.CustomUI;
using Excel = Microsoft.Office.Interop.Excel;

namespace Cellm.AddIn.UserInterface.Ribbon;

public partial class RibbonMain
{
private static Excel.Application Application => (Excel.Application)ExcelDnaUtil.Application;

private enum PromptGroupControlIds
{
PromptGroupHorizontalContainer,
Expand All @@ -21,7 +24,7 @@ public string PromptGroup()
return $"""
<group id="{nameof(PromptGroup)}" label="Prompt">
<box id="{nameof(PromptGroupControlIds.PromptGroupHorizontalContainer)}" boxStyle="horizontal">
<button id="{nameof(PromptGroupControlIds.PromptToCell)}"
<button id="{nameof(PromptGroupControlIds.PromptToCell)}"
size="large"
label="Cell"
imageMso="TableSelectCell"
Expand All @@ -38,7 +41,7 @@ public string PromptGroup()
<button id="{nameof(PromptGroupControlIds.PromptToColumn)}"
size="large"
label="Column"
imageMso="TableColumnSelect"
imageMso="TableColumnSelect"
onAction="{nameof(OnPromptToColumnClicked)}"
getEnabled="{nameof(GetStructuredOutputEnabled)}"
screentip="Output response in a column"
Expand Down Expand Up @@ -100,65 +103,74 @@ private enum CellmOutputShape
ToRange,
}

private CellmFormula? GetCellmFunction()
{
var formula = (string)ExcelDnaUtil.Application.ActiveCell.Formula;
private record ParsedCellmFormula(
CellmFormula Function,
CellmOutputShape Shape,
string Arguments);

var startIndex = formula.IndexOf('=');
var endIndex = formula.IndexOf('.');
private static ParsedCellmFormula? TryParseFormula(string formula)
{
// Parse the function name (e.g., "PROMPT" or "PROMPTMODEL")
var equalsIndex = formula.IndexOf('=');
var dotIndex = formula.IndexOf('.');
var parenIndex = formula.IndexOf('(');

if (endIndex < 0)
if (equalsIndex < 0 || parenIndex < 0)
{
endIndex = formula.IndexOf('(');
return null;
}

if (startIndex < 0 || endIndex < 0 || startIndex >= endIndex)
// Function name ends at dot (if present before paren) or at paren
int functionEndIndex;
if (dotIndex >= 0 && dotIndex < parenIndex)
{
// This is fine, it means the user asked us to insert formula in a cell that does not already contain a formula
return null;
functionEndIndex = dotIndex;
}
else
{
functionEndIndex = parenIndex;
}

var cellmFormulaAsString = formula.Substring(startIndex + 1, endIndex - startIndex - 1);

if (Enum.TryParse<CellmFormula>(cellmFormulaAsString, ignoreCase: true, out var cellmFormula))
if (equalsIndex >= functionEndIndex)
{
// The cell already contains a Cellm formula
return cellmFormula;
return null;
}

// The cell does not contain a Cellm formula
return null;
}
var functionName = formula.Substring(equalsIndex + 1, functionEndIndex - equalsIndex - 1);

private CellmOutputShape? GetCellmOutputShape()
{
if (GetCellmFunction() is null)
if (!Enum.TryParse<CellmFormula>(functionName, ignoreCase: true, out var function))
{
// The cell does not contain a Cellm formula
return null;
}

var formula = (string)ExcelDnaUtil.Application.ActiveCell.Formula;

var startIndex = formula.IndexOf('.');
var endIndex = formula.IndexOf('(');
// Parse the output shape (e.g., "TOROW", "TOCOLUMN", "TORANGE")
var shape = CellmOutputShape.ToCell;

if (startIndex < 0 || endIndex < 0 || startIndex >= endIndex)
if (dotIndex >= 0 && dotIndex < parenIndex)
{
// This is fine, it means the formula uses the default output shape
return CellmOutputShape.ToCell;
var shapeName = formula.Substring(dotIndex + 1, parenIndex - dotIndex - 1);

if (Enum.TryParse<CellmOutputShape>(shapeName, ignoreCase: true, out var parsedShape))
{
shape = parsedShape;
}
}

var cellmOutputShapeAsString = formula.Substring(startIndex + 1, endIndex - startIndex + 1);
// Extract arguments including parentheses
var arguments = formula[parenIndex..];

if (Enum.TryParse<CellmOutputShape>(cellmOutputShapeAsString, ignoreCase: true, out var cellmOutputShape))
return new ParsedCellmFormula(function, shape, arguments);
}

private static string BuildFormula(CellmFormula function, CellmOutputShape shape, string arguments)
{
var shapeSuffix = shape switch
{
// The cell already contains a Cellm formula
return cellmOutputShape;
}
CellmOutputShape.ToCell => string.Empty,
_ => $".{shape.ToString().ToUpper()}"
};

// We could not parse the output shape from the formula
return null;
return $"={function.ToString().ToUpper()}{shapeSuffix}{arguments}";
}

public void OnPromptToCellClicked(IRibbonControl control)
Expand All @@ -181,105 +193,120 @@ public void OnPromptToRangeClicked(IRibbonControl control)
UpdateCell(CellmOutputShape.ToRange);
}

private void UpdateCell(CellmOutputShape targetOutputShape)
private void UpdateCell(CellmOutputShape targetShape)
{
if (ExcelDnaUtil.Application.ActiveSheet == null)
if (Application.ActiveSheet is null)
{
// No sheet open
return;
}

var currentFunction = GetCellmFunction();
var currentOutputShape = GetCellmOutputShape();
var targetOutputShapeAsString = targetOutputShape == CellmOutputShape.ToCell ? string.Empty : $".{targetOutputShape.ToString().ToUpper()}";

var selectedCells = ExcelDnaUtil.Application.Selection;

if (selectedCells.Cells.Count > 1)
if (HandleMultiCellSelection(targetShape))
{
var rowStart = selectedCells.Row;
var rowEnd = selectedCells.Rows.Count - 1;
var columnStart = selectedCells.Column;
var columnEnd = selectedCells.Columns.Count - 1;
var rangeAsString = $"{GetColumnName(columnStart)}{GetRowName(rowStart)}:{GetColumnName(columnStart + columnEnd)}{GetRowName(rowStart + rowEnd)}";
var formula = $"={nameof(CellmFunctions.Prompt).ToUpper()}({rangeAsString})";

var targetCell = ExcelDnaUtil.Application.ActiveSheet.Range[GetColumnName(columnStart + columnEnd) + GetRowName(rowStart + rowEnd + 1)];
targetCell.NumberFormat = "@"; // Do not recalculate the formula immediately

try
{
// Use array-aware Formula2 if it exists. This prevents array-aware Excel versions from prepending "@" to formula
targetCell.Formula2 = formula;
}
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException)
{
// Fallback to normal Formula property for older versions of Excel
targetCell.Formula = formula;
}

targetCell.NumberFormat = "General"; // Calculate the formula when function wizard is closed

// Select target cell before opening function wizard
targetCell.Select();

ExcelDnaUtil.Application.Dialogs[Microsoft.Office.Interop.Excel.XlBuiltInDialog.xlDialogFunctionWizard].Show();

return;
}

if (ExcelDnaUtil.Application.ActiveCell is null)
if (Application.ActiveCell is not Excel.Range activeCell)
{
// No cell selected
return;
}

if (currentFunction is null)
var formula = (string)activeCell.Formula;
var parsed = TryParseFormula(formula);

switch (parsed)
{
// The cell does not contain a Cellm formula, insert a new one
ExcelAsyncUtil.QueueAsMacro(() =>
{
ExcelDnaUtil.Application.ActiveCell.NumberFormat = "@"; // Do not recalculate the formula immediately
SetFormula($"={nameof(CellmFunctions.Prompt).ToUpper()}{targetOutputShapeAsString}()");
ExcelDnaUtil.Application.ActiveCell.NumberFormat = "General"; // Calculate the formula when function wizard is closed
ExcelDnaUtil.Application.Dialogs[Microsoft.Office.Interop.Excel.XlBuiltInDialog.xlDialogFunctionWizard].Show();
case null:
InsertNewFormula(activeCell, targetShape);
break;

});
case { Shape: var s } when s == targetShape:
TriggerRecalculation(activeCell, formula);
break;

return;
case var p:
ChangeFormulaShape(activeCell, p, targetShape);
break;
}
}

private bool HandleMultiCellSelection(CellmOutputShape targetShape)
{
var selectedCells = Application.Selection;

if (currentOutputShape == targetOutputShape)
if (selectedCells.Cells.Count <= 1)
{
// Just recalculate
ExcelDnaUtil.Application.ActiveCell.Calculate();
return;
return false;
}

// Change the output shape and recalculate
var currentFormula = (string)ExcelDnaUtil.Application.ActiveCell.Formula;
var currentFunctionAsString = currentFunction.ToString() ?? throw new NullReferenceException(nameof(currentFunction));
var arguments = currentFormula[currentFormula.IndexOf('(')..];
var rowStart = selectedCells.Row;
var rowCount = selectedCells.Rows.Count;
var columnStart = selectedCells.Column;
var columnCount = selectedCells.Columns.Count;

var rangeStart = $"{GetColumnName(columnStart)}{rowStart}";
var rangeEnd = $"{GetColumnName(columnStart + columnCount - 1)}{rowStart + rowCount - 1}";
var formula = BuildFormula(CellmFormula.Prompt, targetShape, $"({rangeStart}:{rangeEnd})");

var targetCell = Application.ActiveSheet.Range[
$"{GetColumnName(columnStart + columnCount - 1)}{rowStart + rowCount}"
];

// Prevent immediate recalculation while function wizard is open
targetCell.NumberFormat = "@";
SetFormula(targetCell, formula);
targetCell.NumberFormat = "General";

// Select target cell before opening function wizard
targetCell.Select();
Application.Dialogs[Excel.XlBuiltInDialog.xlDialogFunctionWizard].Show();

return true;
}

private void InsertNewFormula(Excel.Range cell, CellmOutputShape targetShape)
{
var formula = BuildFormula(CellmFormula.Prompt, targetShape, "()");

ExcelAsyncUtil.QueueAsMacro(() =>
{
// Prevent immediate recalculation while function wizard is open
cell.NumberFormat = "@";
SetFormula(cell, formula);
cell.NumberFormat = "General";
Application.Dialogs[Excel.XlBuiltInDialog.xlDialogFunctionWizard].Show();
});
}

private void TriggerRecalculation(Excel.Range cell, string formula)
{
// Re-setting the formula triggers Excel-DNA async function re-evaluation
// (Range.Calculate() does not work for async functions)
ExcelAsyncUtil.QueueAsMacro(() =>
{
SetFormula($"={currentFunctionAsString.ToUpper()}{targetOutputShapeAsString}{arguments}");
SetFormula(cell, formula);
});
}

void SetFormula(string formula)
private void ChangeFormulaShape(Excel.Range cell, ParsedCellmFormula current, CellmOutputShape targetShape)
{
var dynamicApp = (dynamic)ExcelDnaUtil.Application;
var newFormula = BuildFormula(current.Function, targetShape, current.Arguments);

ExcelAsyncUtil.QueueAsMacro(() =>
{
SetFormula(cell, newFormula);
});
}
private static void SetFormula(Excel.Range cell, string formula)
{
try
{
// Use array-aware Formula2 if it exists. This prevents array-aware Excel versions from prepending "@" to formula
dynamicApp.ActiveCell.Formula2 = formula;
// Formula2 only exists in Excel 2019+, use dynamic to avoid compile error
((dynamic)cell).Formula2 = formula;
}
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException)
{
// Fallback to normal Formula property for older versions of Excel
ExcelDnaUtil.Application.ActiveCell.Formula = formula;
cell.Formula = formula;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Cellm/Cellm.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
<ItemGroup>
<PackageReference Include="Anthropic.SDK" Version="5.4.3" />
<PackageReference Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.2" />
<PackageReference Include="ExcelDna.Addin" Version="1.9.0-beta2" />
<PackageReference Include="ExcelDna.Addin" Version="1.9.0" />
<PackageReference Include="ExcelDna.Interop" Version="15.0.1" />
<PackageReference Include="MediatR" Version="13.0.0" />
<PackageReference Include="Microsoft.Extensions.AI" Version="9.9.0" />
Expand Down
Loading
Loading