From 4f7b4f8f9a053aa4f7c179ad472ff9eb9d969474 Mon Sep 17 00:00:00 2001 From: Tim Jacks <53003551+jimmytacks@users.noreply.github.com> Date: Fri, 29 May 2026 15:47:16 +0100 Subject: [PATCH] Editable defaults for optional args in LET to LAMBDA (#215) When an input is marked Optional in the LET to LAMBDA dialog, the generated LAMBDA previously always defaulted it to the binding's original RHS. Authors can now edit that default inline: the read-only RHS preview becomes an editable text box (pre-filled with the original RHS) when Optional is ticked. - InputChoice gains an optional DefaultExpression; the builder uses it for the IF(ISOMITTED(...)) wrapper, falling back to the original RHS when blank so existing behaviour and tests are unchanged. - User-typed defaults get the same ApplyRenames + AbsolutizeCellRefs treatment as the original RHS (a typed A1 becomes $A$1). - Save is disabled while an optional row has a blank default. Closes #215 Co-Authored-By: Claude Opus 4.8 --- .../LetToLambdaBuilderTests.cs | 82 +++++++++++++++++++ addin/lambda-boss/LetToLambdaBuilder.cs | 36 +++++--- addin/lambda-boss/UI/LetToLambdaWindow.xaml | 21 ++++- .../lambda-boss/UI/LetToLambdaWindow.xaml.cs | 45 ++++++++-- 4 files changed, 162 insertions(+), 22 deletions(-) diff --git a/addin/lambda-boss.Tests/LetToLambdaBuilderTests.cs b/addin/lambda-boss.Tests/LetToLambdaBuilderTests.cs index 07410b4..1caaf8e 100644 --- a/addin/lambda-boss.Tests/LetToLambdaBuilderTests.cs +++ b/addin/lambda-boss.Tests/LetToLambdaBuilderTests.cs @@ -22,6 +22,16 @@ private static string BuildWithOptional(string formula, string lambdaName, return LetToLambdaBuilder.Build(new LambdaGenerationRequest(lambdaName, parsed, inputs)); } + private static string BuildWithDefault(string formula, string lambdaName, + params (string name, string paramName, bool keep, bool isOptional, string? defaultExpr)[] choices) + { + var parsed = LetParser.Parse(formula); + var inputs = choices + .Select(c => new InputChoice(c.name, c.paramName, c.keep, c.isOptional, c.defaultExpr)) + .ToList(); + return LetToLambdaBuilder.Build(new LambdaGenerationRequest(lambdaName, parsed, inputs)); + } + private static string Lines(params string[] lines) => string.Join("\n", lines); [Fact] @@ -369,6 +379,78 @@ public void OptionalWithReorderAndRename_WorksTogether() ")"), result); } + [Fact] + public void OptionalWithCustomDefault_UsesTypedDefault() + { + // The original RHS is A1, but the user overrides the default to 0. + var result = BuildWithDefault("=LET(x, 10, y, A1, x + y)", "Adder", + ("x", "x", true, false, null), ("y", "offset", true, true, "0")); + + Assert.Equal(Lines( + "=LAMBDA(", + " x,", + " [offset],", + " LET(", + " offset, IF(ISOMITTED(offset), 0, offset),", + " x + offset", + " )", + ")"), result); + } + + [Fact] + public void OptionalWithCustomDefault_CellRefAbsolutized() + { + // A cell ref typed as a custom default is absolutized like any other. + var result = BuildWithDefault("=LET(x, 10, y, A1, x + y)", "Adder", + ("x", "x", true, false, null), ("y", "offset", true, true, "B2")); + + Assert.Equal(Lines( + "=LAMBDA(", + " x,", + " [offset],", + " LET(", + " offset, IF(ISOMITTED(offset), $B$2, offset),", + " x + offset", + " )", + ")"), result); + } + + [Fact] + public void OptionalWithBlankCustomDefault_FallsBackToOriginalRhs() + { + // A null/blank custom default preserves the pre-edit behaviour. + var result = BuildWithDefault("=LET(x, 10, y, A1, x + y)", "Adder", + ("x", "x", true, false, null), ("y", "offset", true, true, " ")); + + Assert.Equal(Lines( + "=LAMBDA(", + " x,", + " [offset],", + " LET(", + " offset, IF(ISOMITTED(offset), $A$1, offset),", + " x + offset", + " )", + ")"), result); + } + + [Fact] + public void OptionalWithCustomDefault_AppliesRenames() + { + // The typed default references binding x, which is renamed to base. + var result = BuildWithDefault("=LET(x, 5, y, 0, x + y)", "Calc", + ("x", "base", true, false, null), ("y", "offset", true, true, "x + 1")); + + Assert.Equal(Lines( + "=LAMBDA(", + " base,", + " [offset],", + " LET(", + " offset, IF(ISOMITTED(offset), base + 1, offset),", + " base + offset", + " )", + ")"), result); + } + [Fact] public void OptionalOnUncheckedRow_Throws() { diff --git a/addin/lambda-boss/LetToLambdaBuilder.cs b/addin/lambda-boss/LetToLambdaBuilder.cs index 9f02dcc..5bd8abe 100644 --- a/addin/lambda-boss/LetToLambdaBuilder.cs +++ b/addin/lambda-boss/LetToLambdaBuilder.cs @@ -6,11 +6,18 @@ namespace LambdaBoss; /// /// User's decision for a single LET binding whose RHS is a value. /// +/// +/// Custom default for an optional param, as typed by the user in the +/// dialog. When null or blank the builder falls back to the binding's +/// original RHS, preserving the pre-edit behaviour. Only consulted when +/// is true. +/// public record InputChoice( string OriginalBindingName, string ParamName, bool Keep, - bool IsOptional = false); + bool IsOptional = false, + string? DefaultExpression = null); public record LambdaGenerationRequest( string LambdaName, @@ -99,13 +106,10 @@ public static string Build(LambdaGenerationRequest request) .ToList(); // Optional bindings wrap each optional kept param with an - // IF(ISOMITTED(...)) defaulting to the original RHS (with renames). - // They appear before internal bindings so internal bindings can - // reference the defaulted value. - // Optional bindings wrap each optional kept param with an - // IF(ISOMITTED(...)) defaulting to the original RHS (with renames). - // They appear before internal bindings so internal bindings can - // reference the defaulted value. Cell references in the default are + // IF(ISOMITTED(...)) defaulting to the user's custom default (or the + // original RHS when none was supplied), with renames applied. They + // appear before internal bindings so internal bindings can reference + // the defaulted value. Cell references in the default are // forced absolute: when Excel stores a LAMBDA as a workbook Name, // relative refs shift by the offset between the active cell at // registration time and the calling cell, which in practice baked @@ -113,10 +117,18 @@ public static string Build(LambdaGenerationRequest request) // regardless of where the LAMBDA is invoked. var optionalBindings = kept .Where(k => k.Choice.IsOptional) - .Select(k => new LetBinding( - k.Choice.ParamName, - $"IF(ISOMITTED({k.Choice.ParamName}), {AbsolutizeCellRefs(ApplyRenames(k.Binding.RhsText, renames))}, {k.Choice.ParamName})", - IsCalculation: false)) + .Select(k => + { + // A blank custom default falls back to the original RHS so the + // builder behaves exactly as before when no edit was made. + var defaultExpr = string.IsNullOrWhiteSpace(k.Choice.DefaultExpression) + ? k.Binding.RhsText + : k.Choice.DefaultExpression!; + return new LetBinding( + k.Choice.ParamName, + $"IF(ISOMITTED({k.Choice.ParamName}), {AbsolutizeCellRefs(ApplyRenames(defaultExpr, renames))}, {k.Choice.ParamName})", + IsCalculation: false); + }) .ToList(); var body = ApplyRenames(parsed.Body, renames); diff --git a/addin/lambda-boss/UI/LetToLambdaWindow.xaml b/addin/lambda-boss/UI/LetToLambdaWindow.xaml index dc28aa7..e4a3d7b 100644 --- a/addin/lambda-boss/UI/LetToLambdaWindow.xaml +++ b/addin/lambda-boss/UI/LetToLambdaWindow.xaml @@ -103,9 +103,10 @@ Foreground="#cccccc" VerticalAlignment="Center" Margin="0,0,8,0" - ToolTip="Caller may omit this argument; defaults to the original RHS expression" /> + ToolTip="Caller may omit this argument; edit the default it falls back to" /> + - /// Display form of used by the row template. - /// When the row is marked optional, the RHS becomes the default - /// expression in the generated LAMBDA, so we prefix "default:" to - /// make that role explicit. + /// The default expression for an optional param, editable in the row + /// template when is set. Seeded with the + /// original RHS so the dialog opens showing the existing behaviour. /// - public string RhsPreviewDisplay => IsOptional ? $"default: {RhsPreview}" : RhsPreview; + public string DefaultExpression + { + get => _defaultExpression; + set + { + _defaultExpression = value; + OnChanged(); + } + } + + /// + /// The read-only RHS preview is shown only while the row is not + /// optional; when optional, the editable default box takes its place. + /// + public bool ShowRhsPreview => !IsOptional; /// /// Zero-based position in the original LET source order. Used to keep @@ -54,7 +68,7 @@ public bool Keep { _isOptional = false; OnChanged(nameof(IsOptional)); - OnChanged(nameof(RhsPreviewDisplay)); + OnChanged(nameof(ShowRhsPreview)); } OnChanged(); } @@ -68,7 +82,7 @@ public bool IsOptional if (_isOptional == value) return; _isOptional = value; OnChanged(); - OnChanged(nameof(RhsPreviewDisplay)); + OnChanged(nameof(ShowRhsPreview)); } } @@ -114,6 +128,7 @@ public LetToLambdaWindow(ParsedLet parsed, Func resolveExisting BindingName = b.Name, ParamName = b.Name, RhsPreview = b.RhsText, + DefaultExpression = b.RhsText, Keep = true, SourceIndex = i })); @@ -314,6 +329,15 @@ private void UpdateSaveEnabled() return; } + var blankDefault = keptRows + .FirstOrDefault(r => r.IsOptional && string.IsNullOrWhiteSpace(r.DefaultExpression)); + if (blankDefault != null) + { + StatusText.Text = $"Optional input '{blankDefault.ParamName}' needs a default value."; + SaveButton.IsEnabled = false; + return; + } + var retainedBindingNames = _parsed.Bindings .Where(b => b.IsCalculation || _rows.Any(r => r.BindingName == b.Name && !r.Keep)) .Select(b => b.Name) @@ -355,7 +379,12 @@ private void HideNameError() private void SaveButton_Click(object sender, RoutedEventArgs e) { var inputs = _rows - .Select(r => new InputChoice(r.BindingName, r.ParamName.Trim(), r.Keep, r.IsOptional)) + .Select(r => new InputChoice( + r.BindingName, + r.ParamName.Trim(), + r.Keep, + r.IsOptional, + r.IsOptional ? r.DefaultExpression.Trim() : null)) .ToList(); Result = new LambdaGenerationRequest(LambdaNameBox.Text.Trim(), _parsed, inputs);