From 4953ddd51d1da5cf025966a927c42f4aaf877e89 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 25 Jan 2026 00:51:23 +0100 Subject: [PATCH 01/21] update: add 3 more border styles --- .../components/BorderPicker/BorderPicker.tsx | 110 +++++++++--- .../WorksheetCanvas/worksheetCanvas.ts | 165 ++++++++++++------ 2 files changed, 196 insertions(+), 79 deletions(-) diff --git a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx index 2ca57b446..3637d809a 100644 --- a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx +++ b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx @@ -1,4 +1,5 @@ import { type BorderOptions, BorderStyle, BorderType } from "@ironcalc/wasm"; +import MenuItem from "@mui/material/MenuItem"; import Popover, { type PopoverOrigin } from "@mui/material/Popover"; import { styled } from "@mui/material/styles"; import { @@ -294,36 +295,60 @@ const BorderPicker = (properties: BorderPickerProps) => { }} > - { setBorderStyle(BorderStyle.Thin); setStylePickerOpen(false); }} - $checked={borderStyle === BorderStyle.Thin} + selected={borderStyle === BorderStyle.Thin} > - Thin - - + { setBorderStyle(BorderStyle.Medium); setStylePickerOpen(false); }} - $checked={borderStyle === BorderStyle.Medium} + selected={borderStyle === BorderStyle.Medium} > - Medium - - + { setBorderStyle(BorderStyle.Thick); setStylePickerOpen(false); }} - $checked={borderStyle === BorderStyle.Thick} + selected={borderStyle === BorderStyle.Thick} > - Thick - + + { + setBorderStyle(BorderStyle.Double); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Double} + > + + + { + setBorderStyle(BorderStyle.Dotted); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Dotted} + > + + + { + setBorderStyle(BorderStyle.MediumDashed); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.MediumDashed} + > + + @@ -331,24 +356,23 @@ const BorderPicker = (properties: BorderPickerProps) => { ); }; -type LineWrapperProperties = { $checked: boolean }; -const LineWrapper = styled("div")` +const StyledMenuItem = styled(MenuItem)` display: flex; flex-direction: row; align-items: center; - background-color: ${({ $checked }): string => { - if ($checked) { - return theme.palette.grey["200"]; - } - return "inherit;"; - }}; - &:hover { - border: 1px solid ${theme.palette.grey["200"]}; - } + justify-content: center; + height: 32px; padding: 8px; - cursor: pointer; border-radius: 4px; - border: 1px solid white; + &::before { + content: none; + } + &.Mui-selected { + background-color: ${({ theme }) => theme.palette.action.hover}; + &:hover { + background-color: ${({ theme }) => theme.palette.action.hover}; + } + } `; const SolidLine = styled("div")` @@ -364,6 +388,38 @@ const ThickLine = styled("div")` border-top: 3px solid ${theme.palette.grey["900"]}; `; +const DoubleLine = styled("div")` + width: 68px; + height: 3px; + position: relative; + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + border-top: 1px solid ${theme.palette.grey["900"]}; + } + &::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + border-top: 1px solid ${theme.palette.grey["900"]}; + } +`; + +const DottedLine = styled("div")` + width: 68px; + border-top: 1px dotted ${theme.palette.grey["900"]}; +`; + +const MediumDashedLine = styled("div")` + width: 68px; + border-top: 2px dashed ${theme.palette.grey["900"]}; +`; + const Divider = styled("div")` width: 100%; margin: auto; @@ -438,10 +494,6 @@ const BorderPickerDialog = styled("div")` flex-direction: column; `; -const BorderDescription = styled("div")` - width: 70px; -`; - type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string }; const Button = styled("button")( ({ disabled, $pressed, $underlinedColor }) => { diff --git a/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts b/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts index fe4c6f775..2864473c1 100644 --- a/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts +++ b/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts @@ -709,6 +709,95 @@ export default class WorksheetCanvas { this.cells.push(textProperties); } + /// Helper function to draw a border with different styles + private drawBorder( + context: CanvasRenderingContext2D, + style: string, + color: string, + x1: number, + y1: number, + x2: number, + y2: number, + isVertical: boolean, + ): void { + context.save(); + context.strokeStyle = color; + + switch (style) { + case "thin": + context.lineWidth = 1; + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + break; + case "medium": + context.lineWidth = 2; + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + break; + case "thick": + context.lineWidth = 3; + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + break; + case "double": + // Draw two parallel lines + if (isVertical) { + context.lineWidth = 1; + context.beginPath(); + context.moveTo(x1 - 1, y1); + context.lineTo(x1 - 1, y2); + context.stroke(); + context.beginPath(); + context.moveTo(x1 + 1, y1); + context.lineTo(x1 + 1, y2); + context.stroke(); + } else { + context.lineWidth = 1; + context.beginPath(); + context.moveTo(x1, y1 - 1); + context.lineTo(x2, y1 - 1); + context.stroke(); + context.beginPath(); + context.moveTo(x1, y1 + 1); + context.lineTo(x2, y1 + 1); + context.stroke(); + } + break; + case "dotted": + context.lineWidth = 1; + context.setLineDash([1, 2]); + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + context.setLineDash([]); + break; + case "mediumdashed": + context.lineWidth = 2; + context.setLineDash([4, 2]); + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + context.setLineDash([]); + break; + default: + // Fallback to thin for unknown styles + context.lineWidth = 1; + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + } + context.restore(); + } + /// Renders the cell style: colors, borders, etc. But not the text. private renderCellStyle( row: number, @@ -746,18 +835,10 @@ export default class WorksheetCanvas { // we skip don't draw a left border if it is marked as a "spill cell" if (this.spills.get(`${row}-${column}`) !== 1) { let borderLeftColor = cellGridColor; - let borderLeftWidth = 1; + let borderLeftStyle = "thin"; if (border.left) { borderLeftColor = border.left.color; - switch (border.left.style) { - case "thin": - break; - case "medium": - borderLeftWidth = 2; - break; - case "thick": - borderLeftWidth = 3; - } + borderLeftStyle = border.left.style; } else { const leftStyle = this.model.getCellStyle( selectedSheet, @@ -766,15 +847,7 @@ export default class WorksheetCanvas { ); if (leftStyle.border.right) { borderLeftColor = leftStyle.border.right.color; - switch (leftStyle.border.right.style) { - case "thin": - break; - case "medium": - borderLeftWidth = 2; - break; - case "thick": - borderLeftWidth = 3; - } + borderLeftStyle = leftStyle.border.right.style; } else if (style.fill.fg_color) { borderLeftColor = style.fill.fg_color; } else if (leftStyle.fill.fg_color) { @@ -782,52 +855,44 @@ export default class WorksheetCanvas { } } - context.beginPath(); - context.strokeStyle = borderLeftColor; - context.lineWidth = borderLeftWidth; - context.moveTo(x, y); - context.lineTo(x, y + height); - context.stroke(); + this.drawBorder( + context, + borderLeftStyle, + borderLeftColor, + x, + y, + x, + y + height, + true, + ); } let borderTopColor = cellGridColor; - let borderTopWidth = 1; + let borderTopStyle = "thin"; if (border.top) { borderTopColor = border.top.color; - switch (border.top.style) { - case "thin": - break; - case "medium": - borderTopWidth = 2; - break; - case "thick": - borderTopWidth = 3; - } + borderTopStyle = border.top.style; } else { const topStyle = this.model.getCellStyle(selectedSheet, row - 1, column); if (topStyle.border.bottom) { borderTopColor = topStyle.border.bottom.color; - switch (topStyle.border.bottom.style) { - case "thin": - break; - case "medium": - borderTopWidth = 2; - break; - case "thick": - borderTopWidth = 3; - } + borderTopStyle = topStyle.border.bottom.style; } else if (style.fill.fg_color) { borderTopColor = style.fill.fg_color; } else if (topStyle.fill.fg_color) { borderTopColor = topStyle.fill.fg_color; } } - context.beginPath(); - context.strokeStyle = borderTopColor; - context.lineWidth = borderTopWidth; - context.moveTo(x, y); - context.lineTo(x + width, y); - context.stroke(); + this.drawBorder( + context, + borderTopStyle, + borderTopColor, + x, + y, + x + width, + y, + false, + ); } /// Renders the text in the cell. From 27367b11b7cfa4ad95c660a5f716cfe7c49ff2d5 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 25 Jan 2026 02:06:11 +0100 Subject: [PATCH 02/21] update: use a popper instead of a popover --- .../components/BorderPicker/BorderPicker.tsx | 638 +++++++++--------- .../src/components/Toolbar/Toolbar.tsx | 3 +- 2 files changed, 335 insertions(+), 306 deletions(-) diff --git a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx index 3637d809a..cb1f8d9d6 100644 --- a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx +++ b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx @@ -1,6 +1,7 @@ import { type BorderOptions, BorderStyle, BorderType } from "@ironcalc/wasm"; +import ClickAwayListener from "@mui/material/ClickAwayListener"; import MenuItem from "@mui/material/MenuItem"; -import Popover, { type PopoverOrigin } from "@mui/material/Popover"; +import Popper, { type PopperPlacementType } from "@mui/material/Popper"; import { styled } from "@mui/material/styles"; import { Grid2X2 as BorderAllIcon, @@ -30,8 +31,7 @@ type BorderPickerProps = { onChange: (border: BorderOptions) => void; onClose: () => void; anchorEl: React.RefObject; - anchorOrigin?: PopoverOrigin; - transformOrigin?: PopoverOrigin; + placement: PopperPlacementType; open: boolean; }; @@ -69,290 +69,308 @@ const BorderPicker = (properties: BorderPickerProps) => { const borderColorButton = useRef(null); const borderStyleButton = useRef(null); + + if (!properties.anchorEl.current) { + return null; + } + return ( - -
- - - - - - - - - - - - - - + + + + + + + + + + + + + + + + setColorPickerOpen(true)} + ref={borderColorButton} > - - - - - - - - setColorPickerOpen(true)} - ref={borderColorButton} - > - - -
Border color
- -
+ + Border color + +
- setStylePickerOpen(true)} - ref={borderStyleButton} - > - -
Border style
- -
-
-
- { - setBorderColor(color); - setColorPickerOpen(false); - }} - onClose={() => { - setColorPickerOpen(false); - }} - anchorEl={borderColorButton} - open={colorPickerOpen} - anchorOrigin={{ - vertical: "top", // Keep vertical alignment at the top - horizontal: "right", // Set horizontal alignment to right - }} - transformOrigin={{ - vertical: "top", // Keep vertical alignment at the top - horizontal: "left", // Set horizontal alignment to left - }} - /> - { - setStylePickerOpen(false); - }} - anchorEl={borderStyleButton.current} - anchorOrigin={{ - vertical: "top", - horizontal: "right", - }} - > - - { - setBorderStyle(BorderStyle.Thin); - setStylePickerOpen(false); - }} - selected={borderStyle === BorderStyle.Thin} - > - - - { - setBorderStyle(BorderStyle.Medium); - setStylePickerOpen(false); - }} - selected={borderStyle === BorderStyle.Medium} - > - - - { - setBorderStyle(BorderStyle.Thick); - setStylePickerOpen(false); - }} - selected={borderStyle === BorderStyle.Thick} - > - - - { - setBorderStyle(BorderStyle.Double); - setStylePickerOpen(false); - }} - selected={borderStyle === BorderStyle.Double} - > - - - { - setBorderStyle(BorderStyle.Dotted); - setStylePickerOpen(false); - }} - selected={borderStyle === BorderStyle.Dotted} - > - - - { - setBorderStyle(BorderStyle.MediumDashed); - setStylePickerOpen(false); - }} - selected={borderStyle === BorderStyle.MediumDashed} + setStylePickerOpen(true)} + onMouseLeave={() => setStylePickerOpen(false)} + ref={borderStyleButton} + > + + Border style + + + + + { + setBorderColor(color); + setColorPickerOpen(false); + }} + onClose={() => { + setColorPickerOpen(false); + }} + anchorEl={borderColorButton} + open={colorPickerOpen} + anchorOrigin={{ + vertical: "top", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "left", + }} + /> + {borderStyleButton.current && ( + - - - - -
-
+ setStylePickerOpen(true)} + onMouseLeave={() => setStylePickerOpen(false)} + > + { + setBorderStyle(BorderStyle.Thin); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Thin} + > + + + { + setBorderStyle(BorderStyle.Medium); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Medium} + > + + + { + setBorderStyle(BorderStyle.Thick); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Thick} + > + + + { + setBorderStyle(BorderStyle.Double); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Double} + > + + + { + setBorderStyle(BorderStyle.Dotted); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Dotted} + > + + + { + setBorderStyle(BorderStyle.MediumDashed); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.MediumDashed} + > + + + + + )} + + + ); }; @@ -446,46 +464,56 @@ const Line = styled("div")` gap: 4px; `; -const ButtonWrapper = styled("div")` +const BaseMenuItem = (props: React.ComponentProps) => ( + +); + +const MenuItemWrapper = styled(BaseMenuItem)` display: flex; - flex-direction: row; - align-items: center; + justify-content: flex-start; border-radius: 4px; - gap: 8px; - &:hover { - background-color: ${theme.palette.grey["200"]}; - border-top-color: ${(): string => theme.palette.grey["200"]}; - } - cursor: pointer; padding: 8px; + height: 32px; + min-height: 32px; + max-height: 32px; + color: ${theme.palette.common.black}; + font-size: 12px; + gap: 8px; svg { - width: 16px; - height: 16px; + max-width: 16px; + min-width: 16px; + max-height: 16px; + min-height: 16px; + color: ${theme.palette.grey[600]}; } `; -const BorderStyleDialog = styled("div")` +const MenuItemText = styled("div")` + flex-grow: 1; +`; + +const PopperContent = styled("div")` + border-radius: 8px; + border: 0px solid ${({ theme }): string => theme.palette.background.default}; + box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5); background: ${({ theme }): string => theme.palette.background.default}; + font-family: ${({ theme }) => theme.typography.fontFamily}; + font-size: 12px; + overflow: hidden; +`; + +const StylePicker = styled(PopperContent)` padding: 4px; display: flex; flex-direction: column; align-items: center; `; -const StyledPopover = styled(Popover)` - .MuiPopover-paper { - border-radius: 8px; - border: 0px solid ${({ theme }): string => theme.palette.background.default}; - box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5); - } - .MuiPopover-padding { - padding: 0px; - } - .MuiList-padding { - padding: 0px; +const StyledPopper = styled(Popper)` + z-index: 1300; + &[data-popper-placement] { + pointer-events: auto; } - font-family: ${({ theme }) => theme.typography.fontFamily}; - font-size: 12px; `; const BorderPickerDialog = styled("div")` diff --git a/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx b/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx index 2c708b2ce..491dffc60 100644 --- a/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx +++ b/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx @@ -415,7 +415,7 @@ function Toolbar(properties: ToolbarProperties) { setBorderPickerOpen(true)} ref={borderButton} disabled={!canEdit} @@ -582,6 +582,7 @@ function Toolbar(properties: ToolbarProperties) { transformOrigin={{ vertical: "top", horizontal: "left" }} /> { properties.onBorderChanged(border); }} From 009b06e41f38a9a513d5e7421b76d1ec48162033 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 25 Jan 2026 23:58:05 +0100 Subject: [PATCH 03/21] update: add a timeout in nested menu --- .../components/BorderPicker/BorderPicker.tsx | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx index cb1f8d9d6..50571f66b 100644 --- a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx +++ b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx @@ -69,6 +69,32 @@ const BorderPicker = (properties: BorderPickerProps) => { const borderColorButton = useRef(null); const borderStyleButton = useRef(null); + const stylePickerCloseTimeout = useRef(null); + + const handleStylePickerOpen = () => { + if (stylePickerCloseTimeout.current) { + clearTimeout(stylePickerCloseTimeout.current); + } + setStylePickerOpen(true); + }; + + const handleStylePickerClose = () => { + if (stylePickerCloseTimeout.current) { + clearTimeout(stylePickerCloseTimeout.current); + } + stylePickerCloseTimeout.current = setTimeout(() => { + setStylePickerOpen(false); + }, 150); + }; + + useEffect( + () => () => { + if (stylePickerCloseTimeout.current) { + clearTimeout(stylePickerCloseTimeout.current); + } + }, + [], + ); if (!properties.anchorEl.current) { return null; @@ -260,8 +286,8 @@ const BorderPicker = (properties: BorderPickerProps) => { setStylePickerOpen(true)} - onMouseLeave={() => setStylePickerOpen(false)} + onMouseEnter={handleStylePickerOpen} + onMouseLeave={handleStylePickerClose} ref={borderStyleButton} > @@ -308,8 +334,8 @@ const BorderPicker = (properties: BorderPickerProps) => { ]} > setStylePickerOpen(true)} - onMouseLeave={() => setStylePickerOpen(false)} + onMouseEnter={handleStylePickerOpen} + onMouseLeave={handleStylePickerClose} > { From 6b1e9b734d36c6e218a486c2066658cf1bf5a423 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 30 Jan 2026 19:07:25 +0100 Subject: [PATCH 04/21] chore: remove duplications in worksheetcanvas --- .../WorksheetCanvas/worksheetCanvas.ts | 85 +++++++++---------- 1 file changed, 41 insertions(+), 44 deletions(-) diff --git a/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts b/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts index 2864473c1..02ebd12db 100644 --- a/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts +++ b/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts @@ -709,6 +709,20 @@ export default class WorksheetCanvas { this.cells.push(textProperties); } + /// Draws a single line from (x1,y1) to (x2,y2) using current stroke style/width. + private drawBorderLine( + context: CanvasRenderingContext2D, + x1: number, + y1: number, + x2: number, + y2: number, + ): void { + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + } + /// Helper function to draw a border with different styles private drawBorder( context: CanvasRenderingContext2D, @@ -726,74 +740,57 @@ export default class WorksheetCanvas { switch (style) { case "thin": context.lineWidth = 1; - context.beginPath(); - context.moveTo(x1, y1); - context.lineTo(x2, y2); - context.stroke(); + this.drawBorderLine(context, x1, y1, x2, y2); break; case "medium": context.lineWidth = 2; - context.beginPath(); - context.moveTo(x1, y1); - context.lineTo(x2, y2); - context.stroke(); + this.drawBorderLine(context, x1, y1, x2, y2); break; case "thick": context.lineWidth = 3; - context.beginPath(); - context.moveTo(x1, y1); - context.lineTo(x2, y2); - context.stroke(); + this.drawBorderLine(context, x1, y1, x2, y2); break; case "double": // Draw two parallel lines + context.lineWidth = 1; if (isVertical) { - context.lineWidth = 1; - context.beginPath(); - context.moveTo(x1 - 1, y1); - context.lineTo(x1 - 1, y2); - context.stroke(); - context.beginPath(); - context.moveTo(x1 + 1, y1); - context.lineTo(x1 + 1, y2); - context.stroke(); + this.drawBorderLine(context, x1 - 1, y1, x1 - 1, y2); + this.drawBorderLine(context, x1 + 1, y1, x1 + 1, y2); } else { - context.lineWidth = 1; - context.beginPath(); - context.moveTo(x1, y1 - 1); - context.lineTo(x2, y1 - 1); - context.stroke(); - context.beginPath(); - context.moveTo(x1, y1 + 1); - context.lineTo(x2, y1 + 1); - context.stroke(); + this.drawBorderLine(context, x1, y1 - 1, x2, y1 - 1); + this.drawBorderLine(context, x1, y1 + 1, x2, y1 + 1); } break; case "dotted": context.lineWidth = 1; context.setLineDash([1, 2]); - context.beginPath(); - context.moveTo(x1, y1); - context.lineTo(x2, y2); - context.stroke(); + this.drawBorderLine(context, x1, y1, x2, y2); context.setLineDash([]); break; case "mediumdashed": context.lineWidth = 2; context.setLineDash([4, 2]); - context.beginPath(); - context.moveTo(x1, y1); - context.lineTo(x2, y2); - context.stroke(); + this.drawBorderLine(context, x1, y1, x2, y2); context.setLineDash([]); break; - default: - // Fallback to thin for unknown styles + case "slantdashdot": context.lineWidth = 1; - context.beginPath(); - context.moveTo(x1, y1); - context.lineTo(x2, y2); - context.stroke(); + context.setLineDash([4, 2, 1, 2]); + this.drawBorderLine(context, x1, y1, x2, y2); + context.setLineDash([]); + break; + case "mediumdashdot": + context.lineWidth = 2; + context.setLineDash([4, 2, 1, 2]); + this.drawBorderLine(context, x1, y1, x2, y2); + context.setLineDash([]); + break; + case "mediumdashdotdot": + context.lineWidth = 2; + context.setLineDash([4, 2, 1, 2, 1, 2]); + this.drawBorderLine(context, x1, y1, x2, y2); + context.setLineDash([]); + break; } context.restore(); } From ebb0446862abc12ddb518a4914f0c6fd9b0d222c Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 30 Jan 2026 19:09:43 +0100 Subject: [PATCH 05/21] update: add 3 more styles --- .../components/BorderPicker/BorderPicker.tsx | 66 +++++++++++++++++-- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx index 50571f66b..347ffc2ef 100644 --- a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx +++ b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx @@ -391,6 +391,33 @@ const BorderPicker = (properties: BorderPickerProps) => { > + { + setBorderStyle(BorderStyle.SlantDashDot); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.SlantDashDot} + > + + + { + setBorderStyle(BorderStyle.MediumDashDot); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.MediumDashDot} + > + + + { + setBorderStyle(BorderStyle.MediumDashDotDot); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.MediumDashDotDot} + > + + )} @@ -400,6 +427,15 @@ const BorderPicker = (properties: BorderPickerProps) => { ); }; +const borderLineColor = theme.palette.grey["900"]; +const borderLinePreviewWidth = 68; + +const dashDotGradient = (color: string) => + `repeating-linear-gradient(90deg, ${color} 0px 4px, transparent 4px 6px, ${color} 6px 7px, transparent 7px 9px)`; + +const dashDotDotGradient = (color: string) => + `repeating-linear-gradient(90deg, ${color} 0px 4px, transparent 4px 6px, ${color} 6px 7px, transparent 7px 9px, ${color} 9px 10px, transparent 10px 12px)`; + const StyledMenuItem = styled(MenuItem)` display: flex; flex-direction: row; @@ -420,20 +456,20 @@ const StyledMenuItem = styled(MenuItem)` `; const SolidLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; border-top: 1px solid ${theme.palette.grey["900"]}; `; const MediumLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; border-top: 2px solid ${theme.palette.grey["900"]}; `; const ThickLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; border-top: 3px solid ${theme.palette.grey["900"]}; `; const DoubleLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; height: 3px; position: relative; &::before { @@ -455,15 +491,33 @@ const DoubleLine = styled("div")` `; const DottedLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; border-top: 1px dotted ${theme.palette.grey["900"]}; `; const MediumDashedLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; border-top: 2px dashed ${theme.palette.grey["900"]}; `; +const SlantDashDotLine = styled("div")` + width: ${borderLinePreviewWidth}px; + height: 1px; + background: ${dashDotGradient(borderLineColor)}; +`; + +const MediumDashDotLine = styled("div")` + width: ${borderLinePreviewWidth}px; + height: 2px; + background: ${dashDotGradient(borderLineColor)}; +`; + +const MediumDashDotDotLine = styled("div")` + width: ${borderLinePreviewWidth}px; + height: 2px; + background: ${dashDotDotGradient(borderLineColor)}; +`; + const Divider = styled("div")` width: 100%; margin: auto; From 88410df6a3321a5b3d8e66cc378969cace763c49 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 2 Feb 2026 19:39:17 +0100 Subject: [PATCH 06/21] fix: comments --- .../IronCalc/src/components/BorderPicker/BorderPicker.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx index 347ffc2ef..081c137fc 100644 --- a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx +++ b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx @@ -69,7 +69,9 @@ const BorderPicker = (properties: BorderPickerProps) => { const borderColorButton = useRef(null); const borderStyleButton = useRef(null); - const stylePickerCloseTimeout = useRef(null); + const stylePickerCloseTimeout = useRef | null>( + null, + ); const handleStylePickerOpen = () => { if (stylePickerCloseTimeout.current) { @@ -104,7 +106,7 @@ const BorderPicker = (properties: BorderPickerProps) => { Date: Wed, 4 Feb 2026 13:52:39 +0000 Subject: [PATCH 07/21] fix: treat leading comma as text --- base/src/formatter/format.rs | 5 +++++ base/src/test/test_number_format.rs | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/base/src/formatter/format.rs b/base/src/formatter/format.rs index 41f641ed5..dec8d0bc5 100644 --- a/base/src/formatter/format.rs +++ b/base/src/formatter/format.rs @@ -928,6 +928,11 @@ fn parse_number( } else { 1.0 }; + + if bytes[position] == group_separator { + return Err("Cannot parse number".to_string()); + } + // numbers before the decimal point while position < len { let x = bytes[position]; diff --git a/base/src/test/test_number_format.rs b/base/src/test/test_number_format.rs index e4dcb4a59..3c4ffa71b 100644 --- a/base/src/test/test_number_format.rs +++ b/base/src/test/test_number_format.rs @@ -1,6 +1,8 @@ #![allow(clippy::unwrap_used)] use crate::number_format::format_number; +use crate::test::util::new_empty_model; +use crate::UserModel; #[test] fn test_simple_format() { @@ -17,6 +19,28 @@ fn test_maximum_zeros() { assert_eq!(formatted.text, "1,234.3333333333300000000".to_string()); } +#[test] +fn test_leading_comma_text() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.set_user_input(0, 1, 1, ",10").unwrap(); // A1 + model.set_user_input(0, 2, 1, ",100").unwrap(); // A2 + model.set_user_input(0, 3, 1, ",1000").unwrap(); // A3 + + assert_eq!( + model.get_formatted_cell_value(0, 1, 1), + Ok(",10".to_string()) + ); + assert_eq!( + model.get_formatted_cell_value(0, 2, 1), + Ok(",100".to_string()) + ); + assert_eq!( + model.get_formatted_cell_value(0, 3, 1), + Ok(",1000".to_string()) + ); +} + #[test] #[ignore = "not yet implemented"] fn test_wrong_locale() { From 8acef664dea68af925fc04a35f728723055f55fd Mon Sep 17 00:00:00 2001 From: Elsa Date: Tue, 3 Feb 2026 23:26:20 +0100 Subject: [PATCH 08/21] fix: add support for booleans and upper bound for df (#734 and #735) --- base/src/functions/statistical/chisq.rs | 38 ++++++++++++++----------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/base/src/functions/statistical/chisq.rs b/base/src/functions/statistical/chisq.rs index 2e0e5c354..b09c7936a 100644 --- a/base/src/functions/statistical/chisq.rs +++ b/base/src/functions/statistical/chisq.rs @@ -13,12 +13,12 @@ impl<'a> Model<'a> { return CalcResult::new_args_number_error(cell); } - let x = match self.get_number_no_bools(&args[0], cell) { + let x = match self.get_number(&args[0], cell) { Ok(f) => f, Err(e) => return e, }; - let df = match self.get_number_no_bools(&args[1], cell) { + let df = match self.get_number(&args[1], cell) { Ok(f) => f.trunc(), Err(e) => return e, }; @@ -35,11 +35,12 @@ impl<'a> Model<'a> { "x must be >= 0 in CHISQ.DIST".to_string(), ); } - if df < 1.0 { + // if degrees of freedom < 1 or > 10^10 → #NUM! + if !(1.0..=10000000000.0).contains(&df) { return CalcResult::new_error( Error::NUM, cell, - "degrees of freedom must be >= 1 in CHISQ.DIST".to_string(), + "degrees of freedom must be in [1, 10^10] in CHISQ.DIST".to_string(), ); } @@ -77,12 +78,12 @@ impl<'a> Model<'a> { return CalcResult::new_args_number_error(cell); } - let x = match self.get_number_no_bools(&args[0], cell) { + let x = match self.get_number(&args[0], cell) { Ok(f) => f, Err(e) => return e, }; - let df_raw = match self.get_number_no_bools(&args[1], cell) { + let df_raw = match self.get_number(&args[1], cell) { Ok(f) => f, Err(e) => return e, }; @@ -96,11 +97,13 @@ impl<'a> Model<'a> { "x must be >= 0 in CHISQ.DIST.RT".to_string(), ); } - if df < 1.0 { + + // if degrees of freedom < 1 or > 10^10 → #NUM! + if !(1.0..=10000000000.0).contains(&df) { return CalcResult::new_error( Error::NUM, cell, - "degrees of freedom must be >= 1 in CHISQ.DIST.RT".to_string(), + "degrees of freedom must be in [1, 10^10] in CHISQ.DIST.RT".to_string(), ); } @@ -136,12 +139,12 @@ impl<'a> Model<'a> { return CalcResult::new_args_number_error(cell); } - let p = match self.get_number_no_bools(&args[0], cell) { + let p = match self.get_number(&args[0], cell) { Ok(f) => f, Err(e) => return e, }; - let df = match self.get_number_no_bools(&args[1], cell) { + let df = match self.get_number(&args[1], cell) { Ok(f) => f.trunc(), Err(e) => return e, }; @@ -154,11 +157,13 @@ impl<'a> Model<'a> { "probability must be in [0,1] in CHISQ.INV".to_string(), ); } - if df < 1.0 { + + // if degrees of freedom < 1 or > 10^10 → #NUM! + if !(1.0..=10000000000.0).contains(&df) { return CalcResult::new_error( Error::NUM, cell, - "degrees of freedom must be >= 1 in CHISQ.INV".to_string(), + "degrees of freedom must be in [1, 10^10] in CHISQ.INV".to_string(), ); } @@ -196,12 +201,12 @@ impl<'a> Model<'a> { return CalcResult::new_args_number_error(cell); } - let p = match self.get_number_no_bools(&args[0], cell) { + let p = match self.get_number(&args[0], cell) { Ok(f) => f, Err(e) => return e, }; - let df_raw = match self.get_number_no_bools(&args[1], cell) { + let df_raw = match self.get_number(&args[1], cell) { Ok(f) => f, Err(e) => return e, }; @@ -216,11 +221,12 @@ impl<'a> Model<'a> { "probability must be in [0,1] in CHISQ.INV.RT".to_string(), ); } - if df < 1.0 { + // if degrees of freedom < 1 or > 10^10 → #NUM! + if !(1.0..=10000000000.0).contains(&df) { return CalcResult::new_error( Error::NUM, cell, - "degrees of freedom must be >= 1 in CHISQ.INV.RT".to_string(), + "degrees of freedom must be in [1, 10^10] in CHISQ.INV.RT".to_string(), ); } From af0a1808557237fbcf8328fc7bd032a15384eee4 Mon Sep 17 00:00:00 2001 From: Elsa Date: Tue, 3 Feb 2026 23:45:57 +0100 Subject: [PATCH 09/21] update: add unit test for bug fix --- base/src/test/statistical/test_fn_chisq.rs | 41 +++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/base/src/test/statistical/test_fn_chisq.rs b/base/src/test/statistical/test_fn_chisq.rs index fce8ec63d..ea03cdb1f 100644 --- a/base/src/test/statistical/test_fn_chisq.rs +++ b/base/src/test/statistical/test_fn_chisq.rs @@ -22,8 +22,10 @@ fn test_fn_chisq_dist_smoke() { // Domain errors // x < 0 -> #NUM! model._set("A6", "=CHISQ.DIST(-1, 4, TRUE)"); - // deg_freedom < 1 -> #NUM! + // deg_freedom < 1 or > 10^10 -> #NUM! model._set("A7", "=CHISQ.DIST(0.5, 0, TRUE)"); + model._set("A8", "=CHISQ.DIST(10, 10000000000, TRUE)"); + model._set("A9", "=CHISQ.DIST(10, 10000000001, TRUE)"); model.evaluate(); @@ -37,6 +39,8 @@ fn test_fn_chisq_dist_smoke() { assert_eq!(model._get_text("A5"), *"#ERROR!"); assert_eq!(model._get_text("A6"), *"#NUM!"); assert_eq!(model._get_text("A7"), *"#NUM!"); + assert_eq!(model._get_text("A8"), *"0"); + assert_eq!(model._get_text("A9"), *"#NUM!"); } #[test] @@ -54,8 +58,10 @@ fn test_fn_chisq_dist_rt_smoke() { // Domain errors // x < 0 -> #NUM! model._set("A5", "=CHISQ.DIST.RT(-1, 4)"); - // deg_freedom < 1 -> #NUM! + // deg_freedom < 1 or > 10^10 -> #NUM! model._set("A6", "=CHISQ.DIST.RT(0.5, 0)"); + model._set("A7", "=CHISQ.DIST.RT(0, 10000000000)"); + model._set("A8", "=CHISQ.DIST.RT(0, 10000000001)"); model.evaluate(); @@ -69,6 +75,8 @@ fn test_fn_chisq_dist_rt_smoke() { assert_eq!(model._get_text("A4"), *"#ERROR!"); assert_eq!(model._get_text("A5"), *"#NUM!"); assert_eq!(model._get_text("A6"), *"#NUM!"); + assert_eq!(model._get_text("A7"), *"1"); + assert_eq!(model._get_text("A8"), *"#NUM!"); } #[test] @@ -87,8 +95,10 @@ fn test_fn_chisq_inv_smoke() { // probability < 0 or > 1 -> #NUM! model._set("A5", "=CHISQ.INV(-0.1, 4)"); model._set("A6", "=CHISQ.INV(1.1, 4)"); - // deg_freedom < 1 -> #NUM! + // deg_freedom < 1 or > 10^10 -> #NUM! model._set("A7", "=CHISQ.INV(0.5, 0)"); + model._set("A8", "=CHISQ.INV(0, 10000000000)"); + model._set("A9", "=CHISQ.INV(0, 10000000001)"); model.evaluate(); @@ -103,6 +113,8 @@ fn test_fn_chisq_inv_smoke() { assert_eq!(model._get_text("A5"), *"#NUM!"); assert_eq!(model._get_text("A6"), *"#NUM!"); assert_eq!(model._get_text("A7"), *"#NUM!"); + assert_eq!(model._get_text("A8"), *"0"); + assert_eq!(model._get_text("A9"), *"#NUM!"); } #[test] @@ -121,8 +133,10 @@ fn test_fn_chisq_inv_rt_smoke() { // probability < 0 or > 1 -> #NUM! model._set("A5", "=CHISQ.INV.RT(-0.1, 4)"); model._set("A6", "=CHISQ.INV.RT(1.1, 4)"); - // deg_freedom < 1 -> #NUM! + // deg_freedom < 1 or > 10^10 -> #NUM! model._set("A7", "=CHISQ.INV.RT(0.5, 0)"); + model._set("A8", "=CHISQ.INV.RT(1, 10000000000)"); + model._set("A9", "=CHISQ.INV.RT(1, 10000000001)"); model.evaluate(); @@ -137,4 +151,23 @@ fn test_fn_chisq_inv_rt_smoke() { assert_eq!(model._get_text("A5"), *"#NUM!"); assert_eq!(model._get_text("A6"), *"#NUM!"); assert_eq!(model._get_text("A7"), *"#NUM!"); + assert_eq!(model._get_text("A8"), *"0"); + assert_eq!(model._get_text("A9"), *"#NUM!"); +} + +#[test] +fn test_booleans() { + let mut model = new_empty_model(); + + model._set("A1", "=CHISQ.DIST(7, TRUE, TRUE)"); + model._set("A2", "=CHISQ.INV(A1, TRUE)"); + model._set("A3", "=CHISQ.DIST.RT(7, TRUE)"); + model._set("A4", "=CHISQ.INV.RT(A3, TRUE)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), "0.991849028"); + assert_eq!(model._get_text("A2"), "7"); + assert_eq!(model._get_text("A3"), "0.008150972"); + assert_eq!(model._get_text("A4"), "7"); } From e22549c3079888c5473dacdae7d21b5c05f0c8a4 Mon Sep 17 00:00:00 2001 From: Elsa Date: Wed, 4 Feb 2026 00:27:50 +0100 Subject: [PATCH 10/21] fix: add max deg of freedom as constant --- base/src/functions/statistical/chisq.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/base/src/functions/statistical/chisq.rs b/base/src/functions/statistical/chisq.rs index b09c7936a..22674a076 100644 --- a/base/src/functions/statistical/chisq.rs +++ b/base/src/functions/statistical/chisq.rs @@ -5,6 +5,7 @@ use crate::expressions::types::CellReferenceIndex; use crate::{ calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model, }; +const MAX_DEGREES_OF_FREEDOM: f64 = 10_000_000_000.0; impl<'a> Model<'a> { // CHISQ.DIST(x, deg_freedom, cumulative) @@ -36,7 +37,7 @@ impl<'a> Model<'a> { ); } // if degrees of freedom < 1 or > 10^10 → #NUM! - if !(1.0..=10000000000.0).contains(&df) { + if !(1.0..=MAX_DEGREES_OF_FREEDOM).contains(&df) { return CalcResult::new_error( Error::NUM, cell, @@ -99,7 +100,7 @@ impl<'a> Model<'a> { } // if degrees of freedom < 1 or > 10^10 → #NUM! - if !(1.0..=10000000000.0).contains(&df) { + if !(1.0..=MAX_DEGREES_OF_FREEDOM).contains(&df) { return CalcResult::new_error( Error::NUM, cell, @@ -159,7 +160,7 @@ impl<'a> Model<'a> { } // if degrees of freedom < 1 or > 10^10 → #NUM! - if !(1.0..=10000000000.0).contains(&df) { + if !(1.0..=MAX_DEGREES_OF_FREEDOM).contains(&df) { return CalcResult::new_error( Error::NUM, cell, @@ -222,7 +223,7 @@ impl<'a> Model<'a> { ); } // if degrees of freedom < 1 or > 10^10 → #NUM! - if !(1.0..=10000000000.0).contains(&df) { + if !(1.0..=MAX_DEGREES_OF_FREEDOM).contains(&df) { return CalcResult::new_error( Error::NUM, cell, From 38c4787fceab120e9c07a9d21b27b92196bcc6ac Mon Sep 17 00:00:00 2001 From: Elsa Date: Mon, 26 Jan 2026 09:27:25 +0100 Subject: [PATCH 11/21] update: adds locale testing for database functions # Conflicts: # base/src/test/test_database.rs --- base/src/test/test_database.rs | 302 +++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) diff --git a/base/src/test/test_database.rs b/base/src/test/test_database.rs index d20bf3f69..a345ab338 100644 --- a/base/src/test/test_database.rs +++ b/base/src/test/test_database.rs @@ -67,3 +67,305 @@ fn arguments() { assert_eq!(model._get_text("A23"), *"#ERROR!"); assert_eq!(model._get_text("A24"), *"#ERROR!"); } + +#[test] +fn locale_ISO_format() { + // ISO format with YYYY-MM-DD format. Works with any locale. + let mut model = Model::new_empty("model", "en-US", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "2026-01-15"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "2026-03-15"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "2026-06-15"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=2026-03-01"); + model._set("B6", "Date"); + model._set("B7", "2026-01-15"); // DGET needs an exact match + + // Test functions + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1500"); + assert_eq!(model._get_text("A12"), *"3000"); + assert_eq!(model._get_text("A13"), *"1890000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"2"); + assert_eq!(model._get_text("A16"), *"2"); + assert_eq!(model._get_text("A17"), *"720000"); + assert_eq!(model._get_text("A18"), *"360000"); + assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A20"), *"600"); +} + +#[test] +fn locale_uk() { + // en-GB locale with DD/MM/YYYY format + let mut model = Model::new_empty("model", "en-GB", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "15/01/2026"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "15/03/2026"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "15/06/2026"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=01/03/2026"); + model._set("B6", "Date"); + model._set("B7", "15/01/2026"); // DGET needs an exact match + + // Test functions + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1500"); + assert_eq!(model._get_text("A12"), *"3000"); + assert_eq!(model._get_text("A13"), *"1890000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"2"); + assert_eq!(model._get_text("A16"), *"2"); + assert_eq!(model._get_text("A17"), *"720000"); + assert_eq!(model._get_text("A18"), *"360000"); + assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A20"), *"600"); +} + +#[test] +fn locale_us() { + // en-US locale with MM/DD/YY format + let mut model = Model::new_empty("model", "en-US", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "1/15/26"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "3/15/26"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "6/15/26"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=3/1/26"); + model._set("B6", "Date"); + model._set("B7", "1/15/26"); // DGET needs an exact match + + // Test functions + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1500"); + assert_eq!(model._get_text("A12"), *"3000"); + assert_eq!(model._get_text("A13"), *"1890000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"2"); + assert_eq!(model._get_text("A16"), *"2"); + assert_eq!(model._get_text("A17"), *"720000"); + assert_eq!(model._get_text("A18"), *"360000"); + assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A20"), *"600"); +} + +#[test] +fn locale_de() { + // de-DE locale with D.M.YYYY format + let mut model = Model::new_empty("model", "de-DE", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "15.1.2026"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "15.3.2026"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "15.6.2026"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=1.3.2026"); + model._set("B6", "Date"); + model._set("B7", "15.1.2026"); // DGET needs an exact match + + // Test functions + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1500"); + assert_eq!(model._get_text("A12"), *"3000"); + assert_eq!(model._get_text("A13"), *"1890000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"2"); + assert_eq!(model._get_text("A16"), *"2"); + assert_eq!(model._get_text("A17"), *"720000"); + assert_eq!(model._get_text("A18"), *"360000"); + assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A20"), *"600"); +} + +#[test] +fn locale_wrong_format() { + // en-US locale with incorrect D.M.YYYY format + let mut model = Model::new_empty("model", "en-US", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "15.1.2026"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "15.3.2026"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "15.6.2026"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=1.3.2026"); + model._set("B6", "Date"); + model._set("B7", "15.1.2026"); // DGET needs an exact match + + // Test functions - results should be the same as using empty criteria + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + // Test functions with empty criteria - range C6:C7 is empty + model._set("B9", "=DMIN(A1:C4, C1, C6:C7)"); + model._set("B10", "=DMAX(A1:C4, C1, C6:C7)"); + model._set("B11", "=DAVERAGE(A1:C4, C1, C6:C7)"); + model._set("B12", "=DSUM(A1:C4, C1, C6:C7)"); + model._set("B13", "=DPRODUCT(A1:C4, C1, C6:C7)"); + model._set("B14", "=DGET(A1:C4, C1, C6:C7)"); + model._set("B15", "=DCOUNT(A1:C4, C1, C6:C7)"); + model._set("B16", "=DCOUNTA(A1:C4, C1, C6:C7)"); + model._set("B17", "=DVAR(A1:C4, C1, C6:C7)"); + model._set("B18", "=DVARP(A1:C4, C1, C6:C7)"); + model._set("B19", "=DSTDEV(A1:C4, C1, C6:C7)"); + model._set("B20", "=DSTDEVP(A1:C4, C1, C6:C7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1400"); + assert_eq!(model._get_text("A12"), *"4200"); + assert_eq!(model._get_text("A13"), *"2268000000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"3"); + assert_eq!(model._get_text("A16"), *"3"); + assert_eq!(model._get_text("A17"), *"390000"); + assert_eq!(model._get_text("A18"), *"260000"); + assert_eq!(model._get_text("A19"), *"624.49979983984"); + assert_eq!(model._get_text("A20"), *"509.901951359278"); + + assert_eq!(model._get_text("B9"), *"900"); + assert_eq!(model._get_text("B10"), *"2100"); + assert_eq!(model._get_text("B11"), *"1400"); + assert_eq!(model._get_text("B12"), *"4200"); + assert_eq!(model._get_text("B13"), *"2268000000"); + assert_eq!(model._get_text("B14"), *"1200"); + assert_eq!(model._get_text("B15"), *"3"); + assert_eq!(model._get_text("B16"), *"3"); + assert_eq!(model._get_text("B17"), *"390000"); + assert_eq!(model._get_text("B18"), *"260000"); + assert_eq!(model._get_text("B19"), *"624.49979983984"); + assert_eq!(model._get_text("B20"), *"509.901951359278"); +} From d6ea5e62804067b9eebd7c83c94485635e8fbe2d Mon Sep 17 00:00:00 2001 From: Elsa Date: Mon, 26 Jan 2026 17:12:17 +0100 Subject: [PATCH 12/21] fix: changes to locale name, use correct formula delimitter for de --- base/src/test/test_database.rs | 61 +++++++++++++++++----------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/base/src/test/test_database.rs b/base/src/test/test_database.rs index a345ab338..4e8bc93f6 100644 --- a/base/src/test/test_database.rs +++ b/base/src/test/test_database.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used)] use crate::test::util::new_empty_model; +use crate::Model; #[test] fn arguments() { @@ -69,9 +70,9 @@ fn arguments() { } #[test] -fn locale_ISO_format() { +fn locale_iso_format() { // ISO format with YYYY-MM-DD format. Works with any locale. - let mut model = Model::new_empty("model", "en-US", "UTC", "en").unwrap(); + let mut model = Model::new_empty("model", "en", "UTC", "en").unwrap(); // Create database model._set("A1", "ID"); @@ -119,7 +120,7 @@ fn locale_ISO_format() { assert_eq!(model._get_text("A16"), *"2"); assert_eq!(model._get_text("A17"), *"720000"); assert_eq!(model._get_text("A18"), *"360000"); - assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A19"), *"848.528137424"); assert_eq!(model._get_text("A20"), *"600"); } @@ -174,14 +175,14 @@ fn locale_uk() { assert_eq!(model._get_text("A16"), *"2"); assert_eq!(model._get_text("A17"), *"720000"); assert_eq!(model._get_text("A18"), *"360000"); - assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A19"), *"848.528137424"); assert_eq!(model._get_text("A20"), *"600"); } #[test] fn locale_us() { // en-US locale with MM/DD/YY format - let mut model = Model::new_empty("model", "en-US", "UTC", "en").unwrap(); + let mut model = Model::new_empty("model", "en", "UTC", "en").unwrap(); // Create database model._set("A1", "ID"); @@ -229,14 +230,14 @@ fn locale_us() { assert_eq!(model._get_text("A16"), *"2"); assert_eq!(model._get_text("A17"), *"720000"); assert_eq!(model._get_text("A18"), *"360000"); - assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A19"), *"848.528137424"); assert_eq!(model._get_text("A20"), *"600"); } #[test] fn locale_de() { // de-DE locale with D.M.YYYY format - let mut model = Model::new_empty("model", "de-DE", "UTC", "en").unwrap(); + let mut model = Model::new_empty("model", "de", "UTC", "en").unwrap(); // Create database model._set("A1", "ID"); @@ -259,18 +260,18 @@ fn locale_de() { model._set("B7", "15.1.2026"); // DGET needs an exact match // Test functions - model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); - model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); - model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); - model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); - model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); - model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); - model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); - model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); - model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); - model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); - model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); - model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + model._set("A9", "=DMIN(A1:C4; C1; A6:A7)"); + model._set("A10", "=DMAX(A1:C4; C1; A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4; C1; A6:A7)"); + model._set("A12", "=DSUM(A1:C4; C1; A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4; C1; A6:A7)"); + model._set("A14", "=DGET(A1:C4; C1; B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4; C1; A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4; C1; A6:A7)"); + model._set("A17", "=DVAR(A1:C4; C1; A6:A7)"); + model._set("A18", "=DVARP(A1:C4; C1; A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4; C1; A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4; C1; A6:A7)"); model.evaluate(); @@ -284,34 +285,34 @@ fn locale_de() { assert_eq!(model._get_text("A16"), *"2"); assert_eq!(model._get_text("A17"), *"720000"); assert_eq!(model._get_text("A18"), *"360000"); - assert_eq!(model._get_text("A19"), *"848.528137423857"); + assert_eq!(model._get_text("A19"), *"848,528137424"); assert_eq!(model._get_text("A20"), *"600"); } #[test] fn locale_wrong_format() { // en-US locale with incorrect D.M.YYYY format - let mut model = Model::new_empty("model", "en-US", "UTC", "en").unwrap(); + let mut model = Model::new_empty("model", "en", "UTC", "en").unwrap(); // Create database model._set("A1", "ID"); model._set("B1", "Date"); model._set("C1", "Amount"); model._set("A2", "1"); - model._set("B2", "15.1.2026"); + model._set("B2", "10.1.2026"); model._set("C2", "1200"); model._set("A3", "2"); - model._set("B3", "15.3.2026"); + model._set("B3", "10.3.2026"); model._set("C3", "900"); model._set("A4", "3"); - model._set("B4", "15.6.2026"); + model._set("B4", "10.6.2026"); model._set("C4", "2100"); // Define criteria model._set("A6", "Date"); model._set("A7", ">=1.3.2026"); model._set("B6", "Date"); - model._set("B7", "15.1.2026"); // DGET needs an exact match + model._set("B7", "10.1.2026"); // DGET needs an exact match // Test functions - results should be the same as using empty criteria model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); @@ -333,7 +334,7 @@ fn locale_wrong_format() { model._set("B11", "=DAVERAGE(A1:C4, C1, C6:C7)"); model._set("B12", "=DSUM(A1:C4, C1, C6:C7)"); model._set("B13", "=DPRODUCT(A1:C4, C1, C6:C7)"); - model._set("B14", "=DGET(A1:C4, C1, C6:C7)"); + model._set("B14", "=DGET(A1:C4, C1, B6:B7)"); model._set("B15", "=DCOUNT(A1:C4, C1, C6:C7)"); model._set("B16", "=DCOUNTA(A1:C4, C1, C6:C7)"); model._set("B17", "=DVAR(A1:C4, C1, C6:C7)"); @@ -353,8 +354,8 @@ fn locale_wrong_format() { assert_eq!(model._get_text("A16"), *"3"); assert_eq!(model._get_text("A17"), *"390000"); assert_eq!(model._get_text("A18"), *"260000"); - assert_eq!(model._get_text("A19"), *"624.49979983984"); - assert_eq!(model._get_text("A20"), *"509.901951359278"); + assert_eq!(model._get_text("A19"), *"624.49979984"); + assert_eq!(model._get_text("A20"), *"509.901951359"); assert_eq!(model._get_text("B9"), *"900"); assert_eq!(model._get_text("B10"), *"2100"); @@ -366,6 +367,6 @@ fn locale_wrong_format() { assert_eq!(model._get_text("B16"), *"3"); assert_eq!(model._get_text("B17"), *"390000"); assert_eq!(model._get_text("B18"), *"260000"); - assert_eq!(model._get_text("B19"), *"624.49979983984"); - assert_eq!(model._get_text("B20"), *"509.901951359278"); + assert_eq!(model._get_text("B19"), *"624.49979984"); + assert_eq!(model._get_text("B20"), *"509.901951359"); } From d4015cdf7baa1e667fe4b911ba210d6f9739f649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Sat, 7 Feb 2026 02:38:39 +0100 Subject: [PATCH 13/21] FIX: Cut and paste on partially selected range --- base/src/test/user_model/mod.rs | 1 + base/src/test/user_model/test_cut_n_paste.rs | 99 ++++++++++++++++++++ base/src/user_model/common.rs | 11 ++- 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 base/src/test/user_model/test_cut_n_paste.rs diff --git a/base/src/test/user_model/mod.rs b/base/src/test/user_model/mod.rs index 89d346419..122daa25c 100644 --- a/base/src/test/user_model/mod.rs +++ b/base/src/test/user_model/mod.rs @@ -5,6 +5,7 @@ mod test_batch_row_column_diff; mod test_border; mod test_clear_cells; mod test_column_style; +mod test_cut_n_paste; mod test_defined_names; mod test_delete_row_column_formatting; mod test_diff_queue; diff --git a/base/src/test/user_model/test_cut_n_paste.rs b/base/src/test/user_model/test_cut_n_paste.rs new file mode 100644 index 000000000..7e3907e93 --- /dev/null +++ b/base/src/test/user_model/test_cut_n_paste.rs @@ -0,0 +1,99 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; +use crate::UserModel; + +#[test] +fn cun_n_paste_same_area() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + // B3:D5 with data + model.set_user_input(0, 3, 2, "A").unwrap(); + model.set_user_input(0, 3, 3, "B").unwrap(); + model.set_user_input(0, 3, 4, "C").unwrap(); + model.set_user_input(0, 4, 2, "D").unwrap(); + model.set_user_input(0, 4, 3, "E").unwrap(); + model.set_user_input(0, 4, 4, "F").unwrap(); + model.set_user_input(0, 5, 2, "G").unwrap(); + model.set_user_input(0, 5, 3, "H").unwrap(); + model.set_user_input(0, 5, 4, "I").unwrap(); + + // Cut it and paste it in C4 + model.set_selected_cell(3, 2).unwrap(); + model.set_selected_range(3, 2, 5, 4).unwrap(); + let cp = model.copy_to_clipboard().unwrap(); + + // C4 + model.set_selected_cell(4, 3).unwrap(); + + let source_range = (3, 2, 5, 4); + model + .paste_from_clipboard(0, source_range, &cp.data, true) + .unwrap(); + + // Check data is in C4:E6 + assert_eq!(model.get_formatted_cell_value(0, 4, 3).unwrap(), "A"); + assert_eq!(model.get_formatted_cell_value(0, 4, 4).unwrap(), "B"); + assert_eq!(model.get_formatted_cell_value(0, 4, 5).unwrap(), "C"); + assert_eq!(model.get_formatted_cell_value(0, 5, 3).unwrap(), "D"); + assert_eq!(model.get_formatted_cell_value(0, 5, 4).unwrap(), "E"); + assert_eq!(model.get_formatted_cell_value(0, 5, 5).unwrap(), "F"); + assert_eq!(model.get_formatted_cell_value(0, 6, 3).unwrap(), "G"); + assert_eq!(model.get_formatted_cell_value(0, 6, 4).unwrap(), "H"); + assert_eq!(model.get_formatted_cell_value(0, 6, 5).unwrap(), "I"); +} + +#[test] +fn cun_n_paste_different_sheet() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + // B3:D5 with data + model.set_user_input(0, 3, 2, "A").unwrap(); + model.set_user_input(0, 3, 3, "B").unwrap(); + model.set_user_input(0, 3, 4, "C").unwrap(); + model.set_user_input(0, 4, 2, "D").unwrap(); + model.set_user_input(0, 4, 3, "E").unwrap(); + model.set_user_input(0, 4, 4, "F").unwrap(); + model.set_user_input(0, 5, 2, "G").unwrap(); + model.set_user_input(0, 5, 3, "H").unwrap(); + model.set_user_input(0, 5, 4, "I").unwrap(); + + // Cut it and paste it in C4 + model.set_selected_cell(3, 2).unwrap(); + model.set_selected_range(3, 2, 5, 4).unwrap(); + let cp = model.copy_to_clipboard().unwrap(); + + // New sheet and select it + model.new_sheet().unwrap(); + model.set_selected_sheet(1).unwrap(); + + // C4 + model.set_selected_cell(4, 3).unwrap(); + + let source_range = (3, 2, 5, 4); + model + .paste_from_clipboard(0, source_range, &cp.data, true) + .unwrap(); + + // Check data is in Sheet2!C4:E6 + assert_eq!(model.get_formatted_cell_value(1, 4, 3).unwrap(), "A"); + assert_eq!(model.get_formatted_cell_value(1, 4, 4).unwrap(), "B"); + assert_eq!(model.get_formatted_cell_value(1, 4, 5).unwrap(), "C"); + assert_eq!(model.get_formatted_cell_value(1, 5, 3).unwrap(), "D"); + assert_eq!(model.get_formatted_cell_value(1, 5, 4).unwrap(), "E"); + assert_eq!(model.get_formatted_cell_value(1, 5, 5).unwrap(), "F"); + assert_eq!(model.get_formatted_cell_value(1, 6, 3).unwrap(), "G"); + assert_eq!(model.get_formatted_cell_value(1, 6, 4).unwrap(), "H"); + assert_eq!(model.get_formatted_cell_value(1, 6, 5).unwrap(), "I"); + + // Check original range is empty Sheet1!B3:D5 + assert_eq!(model.get_formatted_cell_value(0, 3, 2).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 3, 3).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 3, 4).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 4, 2).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 4, 3).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 4, 4).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 5, 2).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 5, 3).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 5, 4).unwrap(), ""); +} diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index 1c635d5fb..190a9a2a8 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -1,6 +1,10 @@ #![deny(missing_docs)] -use std::{collections::HashMap, fmt::Debug, io::Cursor}; +use std::{ + collections::{HashMap, HashSet}, + fmt::Debug, + io::Cursor, +}; use csv::{ReaderBuilder, WriterBuilder}; use serde::{Deserialize, Serialize}; @@ -1830,6 +1834,7 @@ impl<'a> UserModel<'a> { width: source_last_column - source_first_column + 1, height: source_last_row - source_first_row + 1, }; + let mut seen_cells = HashSet::new(); for (source_row, data_row) in clipboard { let delta_row = source_row - source_first_row; let target_row = selected_row + delta_row; @@ -1893,11 +1898,15 @@ impl<'a> UserModel<'a> { old_value: Box::new(old_style), new_value: Box::new(value.style.clone()), }); + seen_cells.insert((target_row, target_column)); } } if is_cut { for row in source_first_row..=source_last_row { for column in source_first_column..=source_last_column { + if (source_sheet == sheet) && seen_cells.contains(&(row, column)) { + continue; + } let old_value = self .model .workbook From 21631e0ca95fdf937562441d1efaacb3dd0a079b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Sat, 7 Feb 2026 11:02:45 +0100 Subject: [PATCH 14/21] FIX: Adds missing test --- base/src/test/user_model/mod.rs | 1 + base/src/test/user_model/test_fn_formulatext.rs | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/base/src/test/user_model/mod.rs b/base/src/test/user_model/mod.rs index 122daa25c..386c9ef4f 100644 --- a/base/src/test/user_model/mod.rs +++ b/base/src/test/user_model/mod.rs @@ -10,6 +10,7 @@ mod test_defined_names; mod test_delete_row_column_formatting; mod test_diff_queue; mod test_evaluation; +mod test_fn_formulatext; mod test_general; mod test_grid_lines; mod test_keyboard_navigation; diff --git a/base/src/test/user_model/test_fn_formulatext.rs b/base/src/test/user_model/test_fn_formulatext.rs index 24df46095..67bf8e63b 100644 --- a/base/src/test/user_model/test_fn_formulatext.rs +++ b/base/src/test/user_model/test_fn_formulatext.rs @@ -1,10 +1,17 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::user_model::util::new_empty_user_model; + #[test] fn formulatext_english() { - let mut model = UserModel::from_model(new_empty_model()); + let mut model = new_empty_user_model(); model.set_user_input(0, 1, 1, "=SUM(1, 2, 3)").unwrap(); model.set_user_input(0, 1, 2, "=FORMULATEXT(A1)").unwrap(); model.set_language("de").unwrap(); - assert_eq!(model.get_formatted_cell_value(0, 1, 2), Ok("=SUM(1,2,3)".to_string())); -} \ No newline at end of file + assert_eq!( + model.get_formatted_cell_value(0, 1, 2), + Ok("=SUM(1,2,3)".to_string()) + ); +} From 17be835520cc9438dca6ea4d8b47e3c513ccdb42 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Sun, 13 Jul 2025 01:09:24 -0700 Subject: [PATCH 15/21] Implement SUMIF, SUMIFS, SUMPRODUCT functions --- .../src/expressions/parser/static_analysis.rs | 2 + base/src/functions/mathematical.rs | 88 +++++++++++++++++++ base/src/test/mod.rs | 2 + base/src/test/test_fn_sumif.rs | 34 +++++++ base/src/test/test_fn_sumproduct.rs | 27 ++++++ .../math_and_trigonometry/sumproduct.md | 3 +- 6 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 base/src/test/test_fn_sumif.rs create mode 100644 base/src/test/test_fn_sumproduct.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 04edf5614..901621169 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -661,6 +661,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec vec![Signature::Vector; arg_count], Function::Sumif => args_signature_sumif(arg_count), Function::Sumifs => vec![Signature::Vector; arg_count], + Function::Sumproduct => vec![Signature::Vector; arg_count], Function::Tan => args_signature_scalars(arg_count, 1, 0), Function::Tanh => args_signature_scalars(arg_count, 1, 0), Function::ErrorType => args_signature_scalars(arg_count, 1, 0), @@ -1055,6 +1056,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Sum => StaticResult::Scalar, Function::Sumif => not_implemented(args), Function::Sumifs => not_implemented(args), + Function::Sumproduct => not_implemented(args), Function::Tan => scalar_arguments(args), Function::Tanh => scalar_arguments(args), Function::ErrorType => not_implemented(args), diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index 846e4e038..b2d7424ea 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -807,6 +807,94 @@ impl<'a> Model<'a> { CalcResult::Number(result) } + pub(crate) fn fn_sumproduct(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + + enum Arg { + Scalar(f64), + Array(Vec>), + } + + let mut processed: Vec = Vec::new(); + let mut rows: usize = 1; + let mut cols: usize = 1; + let mut have_matrix = false; + + for arg in args { + match self.get_number_or_array(arg, cell) { + Ok(NumberOrArray::Number(n)) => processed.push(Arg::Scalar(n)), + Ok(NumberOrArray::Array(a)) => { + let r = a.len(); + let c = a.first().map(|row| row.len()).unwrap_or(0); + let mut arr = Vec::with_capacity(r); + for row in a { + let mut row_vec = Vec::with_capacity(row.len()); + for value in row { + match value { + ArrayNode::Number(n) => row_vec.push(n), + ArrayNode::Boolean(b) => row_vec.push(if b { 1.0 } else { 0.0 }), + ArrayNode::String(s) => match s.parse::() { + Ok(f) => row_vec.push(f), + Err(_) => row_vec.push(0.0), + }, + ArrayNode::Error(e) => { + return CalcResult::Error { + error: e, + origin: cell, + message: "Error in array".to_string(), + }; + } + } + } + arr.push(row_vec); + } + + if !have_matrix { + rows = r; + cols = c; + have_matrix = r > 1 || c > 1; + } else if r != rows || c != cols { + return CalcResult::new_error( + Error::VALUE, + cell, + "Array dimensions do not match".to_string(), + ); + } + processed.push(Arg::Array(arr)); + } + Err(e) => return e, + } + } + + if !have_matrix { + let mut prod = 1.0; + for p in processed { + match p { + Arg::Scalar(n) => prod *= n, + Arg::Array(a) => prod *= a[0][0], + } + } + return CalcResult::Number(prod); + } + + let mut total = 0.0; + for i in 0..rows { + for j in 0..cols { + let mut prod = 1.0; + for p in processed.iter() { + match p { + Arg::Scalar(n) => prod *= *n, + Arg::Array(a) => prod *= a[i][j], + } + } + total += prod; + } + } + CalcResult::Number(total) + } + /// SUMIF(criteria_range, criteria, [sum_range]) /// if sum_rage is missing then criteria_range will be used pub(crate) fn fn_sumif(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 3c2b0f052..f61ed6845 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -29,7 +29,9 @@ mod test_fn_or_xor; mod test_fn_product; mod test_fn_rept; mod test_fn_sum; +mod test_fn_sumif; mod test_fn_sumifs; +mod test_fn_sumproduct; mod test_fn_textbefore; mod test_fn_textjoin; mod test_fn_time; diff --git a/base/src/test/test_fn_sumif.rs b/base/src/test/test_fn_sumif.rs new file mode 100644 index 000000000..57c1df6ad --- /dev/null +++ b/base/src/test/test_fn_sumif.rs @@ -0,0 +1,34 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_sumif_basic() { + let mut model = new_empty_model(); + // Incorrect number of arguments + model._set("A1", "=SUMIF()"); + + // Without sum_range + model._set("A2", "=SUMIF(B1:B4,\">2\")"); + // With sum_range + model._set("A3", "=SUMIF(B1:B4,\">2\",C1:C4)"); + + // Data + model._set("B1", "1"); + model._set("B2", "3"); + model._set("B3", "4"); + model._set("B4", "1"); + + model._set("C1", "10"); + model._set("C2", "20"); + model._set("C3", "30"); + model._set("C4", "40"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + // Sum range omitted -> uses B column + assert_eq!(model._get_text("A2"), *"7"); + // Sum range provided + assert_eq!(model._get_text("A3"), *"50"); +} diff --git a/base/src/test/test_fn_sumproduct.rs b/base/src/test/test_fn_sumproduct.rs new file mode 100644 index 000000000..7dbdf3468 --- /dev/null +++ b/base/src/test/test_fn_sumproduct.rs @@ -0,0 +1,27 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_sumproduct_basic() { + let mut model = new_empty_model(); + model._set("A1", "1"); + model._set("A2", "2"); + model._set("A3", "3"); + + model._set("B1", "4"); + model._set("B2", "5"); + model._set("B3", "6"); + + model._set("C1", "=SUMPRODUCT(A1:A3,B1:B3)"); + model._set("C2", "=SUMPRODUCT(A1:A3,2)"); + model._set("C3", "=SUMPRODUCT(A1:A3,B1:B2)"); + + model.evaluate(); + + assert_eq!(model._get_text("C1"), *"32"); + // Scalar second argument broadcast + assert_eq!(model._get_text("C2"), *"12"); + // Mismatched dimensions produce #VALUE! + assert_eq!(model._get_text("C3"), *"#VALUE!"); +} diff --git a/docs/src/functions/math_and_trigonometry/sumproduct.md b/docs/src/functions/math_and_trigonometry/sumproduct.md index 21866e9d5..61937dd28 100644 --- a/docs/src/functions/math_and_trigonometry/sumproduct.md +++ b/docs/src/functions/math_and_trigonometry/sumproduct.md @@ -7,6 +7,5 @@ lang: en-US # SUMPRODUCT ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file From 42a78e3d809c69dc245ad5cc7627c2db4f49d3fc Mon Sep 17 00:00:00 2001 From: BrianHung Date: Mon, 21 Jul 2025 11:29:50 -0700 Subject: [PATCH 16/21] fix out of bounds --- base/src/functions/mathematical.rs | 34 +++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index b2d7424ea..b55da485b 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -828,6 +828,16 @@ impl<'a> Model<'a> { Ok(NumberOrArray::Array(a)) => { let r = a.len(); let c = a.first().map(|row| row.len()).unwrap_or(0); + + // Check for empty arrays + if r == 0 || c == 0 { + return CalcResult::new_error( + Error::VALUE, + cell, + "Empty arrays are not supported in SUMPRODUCT".to_string(), + ); + } + let mut arr = Vec::with_capacity(r); for row in a { let mut row_vec = Vec::with_capacity(row.len()); @@ -854,7 +864,7 @@ impl<'a> Model<'a> { if !have_matrix { rows = r; cols = c; - have_matrix = r > 1 || c > 1; + have_matrix = true; // Any array establishes matrix mode } else if r != rows || c != cols { return CalcResult::new_error( Error::VALUE, @@ -873,7 +883,15 @@ impl<'a> Model<'a> { for p in processed { match p { Arg::Scalar(n) => prod *= n, - Arg::Array(a) => prod *= a[0][0], + Arg::Array(_) => { + // This should never happen since have_matrix would be true + // if we have any arrays, but add safety check + return CalcResult::new_error( + Error::VALUE, + cell, + "Internal error: unexpected array in scalar mode".to_string(), + ); + } } } return CalcResult::Number(prod); @@ -886,7 +904,17 @@ impl<'a> Model<'a> { for p in processed.iter() { match p { Arg::Scalar(n) => prod *= *n, - Arg::Array(a) => prod *= a[i][j], + Arg::Array(a) => { + // Additional bounds check for safety + if i >= a.len() || j >= a[i].len() { + return CalcResult::new_error( + Error::VALUE, + cell, + "Array index out of bounds".to_string(), + ); + } + prod *= a[i][j]; + } } } total += prod; From 0170947a92632d323e7a092b72a9a593295b4d93 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Tue, 22 Jul 2025 00:52:38 -0700 Subject: [PATCH 17/21] increase test coverage --- base/src/test/test_fn_sumif.rs | 133 ++++++++++++++++++++++++++++ base/src/test/test_fn_sumproduct.rs | 131 ++++++++++++++++++++++++++- 2 files changed, 262 insertions(+), 2 deletions(-) diff --git a/base/src/test/test_fn_sumif.rs b/base/src/test/test_fn_sumif.rs index 57c1df6ad..390e805fe 100644 --- a/base/src/test/test_fn_sumif.rs +++ b/base/src/test/test_fn_sumif.rs @@ -32,3 +32,136 @@ fn test_fn_sumif_basic() { // Sum range provided assert_eq!(model._get_text("A3"), *"50"); } + +#[test] +fn test_fn_sumif_criteria_operators() { + let mut model = new_empty_model(); + + model._set("A1", "5"); + model._set("A2", "10"); + model._set("A3", "15"); + model._set("A4", "10"); + model._set("A5", "20"); + + model._set("B1", "=SUMIF(A1:A5, 10)"); + model._set("B2", "=SUMIF(A1:A5, \">10\")"); + model._set("B3", "=SUMIF(A1:A5, \"<15\")"); + model._set("B4", "=SUMIF(A1:A5, \">=15\")"); + model._set("B5", "=SUMIF(A1:A5, \"<=10\")"); + model._set("B6", "=SUMIF(A1:A5, \"<>10\")"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"20"); + assert_eq!(model._get_text("B2"), *"35"); + assert_eq!(model._get_text("B3"), *"25"); + assert_eq!(model._get_text("B4"), *"35"); + assert_eq!(model._get_text("B5"), *"25"); + assert_eq!(model._get_text("B6"), *"40"); +} + +#[test] +fn test_fn_sumif_wildcards() { + let mut model = new_empty_model(); + + model._set("A1", "Apple"); + model._set("A2", "Banana"); + model._set("A3", "Apricot"); + model._set("A4", "Cherry"); + + model._set("B1", "10"); + model._set("B2", "20"); + model._set("B3", "15"); + model._set("B4", "25"); + + model._set("C1", "=SUMIF(A1:A4, \"Ap*\", B1:B4)"); + model._set("C2", "=SUMIF(A1:A4, \"*rry\", B1:B4)"); + model._set("C3", "=SUMIF(A1:A4, \"*a*\", B1:B4)"); + model._set("C4", "=SUMIF(A1:A4, \"<>Apple\", B1:B4)"); + + model.evaluate(); + + assert_eq!(model._get_text("C1"), *"25"); + assert_eq!(model._get_text("C2"), *"25"); + assert_eq!(model._get_text("C3"), *"45"); + assert_eq!(model._get_text("C4"), *"60"); +} + +#[test] +fn test_fn_sumif_data_types() { + let mut model = new_empty_model(); + + model._set("A1", "10"); + model._set("A2", "text"); + model._set("A3", "TRUE"); + model._set("A4", "FALSE"); + model._set("A5", ""); + + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "4"); + model._set("B5", "5"); + + model._set("C1", "=SUMIF(A1:A5, \">5\", B1:B5)"); + model._set("C2", "=SUMIF(A1:A5, \"text\", B1:B5)"); + model._set("C3", "=SUMIF(A1:A5, TRUE, B1:B5)"); + model._set("C4", "=SUMIF(A1:A5, FALSE, B1:B5)"); + model._set("C5", "=SUMIF(A1:A5, \"\", B1:B5)"); + + model.evaluate(); + + assert_eq!(model._get_text("C1"), *"1"); + assert_eq!(model._get_text("C2"), *"2"); + assert_eq!(model._get_text("C3"), *"3"); + assert_eq!(model._get_text("C4"), *"4"); + assert_eq!(model._get_text("C5"), *"5"); +} + +#[test] +fn test_fn_sumif_errors() { + let mut model = new_empty_model(); + + model._set("A1", "10"); + model._set("A2", "=1/0"); + model._set("A3", "20"); + + model._set("B1", "5"); + model._set("B2", "=NA()"); + model._set("B3", "15"); + + model._set("C1", "1"); + model._set("C2", "2"); + model._set("C3", "3"); + + model._set("D1", "=SUMIF(A1:A3, \">5\", C1:C3)"); + model._set("D2", "=SUMIF(C1:C3, 2, B1:B3)"); + + model.evaluate(); + + assert_eq!(model._get_text("D1"), *"4"); + assert_eq!(model._get_text("D2"), *"#N/A"); +} + +#[test] +fn test_fn_sumif_edge_cases() { + let mut model = new_empty_model(); + + model._set("A1", "0"); + model._set("A2", "-5"); + model._set("A3", "10"); + + model._set("B1", "10"); + model._set("B2", "20"); + model._set("B3", "30"); + + model._set("C1", "=SUMIF(A1:A3, 0, B1:B3)"); + model._set("C2", "=SUMIF(A1:A3, \"<0\", B1:B3)"); + model._set("C3", "=SUMIF(D1:D3, \"10\", E1:E3)"); + + model.evaluate(); + + assert_eq!(model._get_text("C1"), *"10"); + assert_eq!(model._get_text("C2"), *"20"); + assert_eq!(model._get_text("C3"), *"0"); +} diff --git a/base/src/test/test_fn_sumproduct.rs b/base/src/test/test_fn_sumproduct.rs index 7dbdf3468..f7f87e42c 100644 --- a/base/src/test/test_fn_sumproduct.rs +++ b/base/src/test/test_fn_sumproduct.rs @@ -20,8 +20,135 @@ fn test_fn_sumproduct_basic() { model.evaluate(); assert_eq!(model._get_text("C1"), *"32"); - // Scalar second argument broadcast assert_eq!(model._get_text("C2"), *"12"); - // Mismatched dimensions produce #VALUE! assert_eq!(model._get_text("C3"), *"#VALUE!"); } + +#[test] +fn test_fn_sumproduct_scalars() { + let mut model = new_empty_model(); + + model._set("A1", "=SUMPRODUCT()"); + model._set("A2", "=SUMPRODUCT(5)"); + model._set("A3", "=SUMPRODUCT(2, 3)"); + model._set("A4", "=SUMPRODUCT(2, 3, 4)"); + model._set("A5", "=SUMPRODUCT(0, 5)"); + model._set("A6", "=SUMPRODUCT(-2, 3)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"5"); + assert_eq!(model._get_text("A3"), *"6"); + assert_eq!(model._get_text("A4"), *"24"); + assert_eq!(model._get_text("A5"), *"0"); + assert_eq!(model._get_text("A6"), *"-6"); +} + +#[test] +fn test_fn_sumproduct_data_types() { + let mut model = new_empty_model(); + + model._set("A1", "1"); + model._set("A2", "TRUE"); + model._set("A3", "5"); + model._set("A4", "hello"); + model._set("A5", "2.5"); + + model._set("B1", "2"); + model._set("B2", "FALSE"); + model._set("B3", ""); + model._set("B4", "4"); + model._set("B5", "1"); + + model._set("C1", "=SUMPRODUCT(A1:A5, B1:B5)"); + + model._set("D1", "2"); + model._set("D2", "3"); + model._set("D3", "4"); + model._set("E1", "=SUMPRODUCT(D1:D3)"); + + model.evaluate(); + + assert_eq!(model._get_text("C1"), *"4.5"); + assert_eq!(model._get_text("E1"), *"9"); +} + +#[test] +fn test_fn_sumproduct_arrays() { + let mut model = new_empty_model(); + + model._set("A1", "1"); + model._set("A2", "2"); + model._set("B1", "3"); + model._set("B2", "4"); + model._set("C1", "5"); + model._set("C2", "6"); + + model._set("D1", "=SUMPRODUCT(A1:A2, B1:B2, C1:C2)"); + model._set("D2", "=SUMPRODUCT(A1:A2, 2, B1:B2)"); + + model._set("E1", "1"); + model._set("E2", "2"); + model._set("F1", "3"); + model._set("F2", "4"); + model._set("G1", "5"); + model._set("G2", "6"); + model._set("H1", "7"); + model._set("H2", "8"); + + model._set("D3", "=SUMPRODUCT(E1:F2, G1:H2)"); + + model.evaluate(); + + assert_eq!(model._get_text("D1"), *"63"); + assert_eq!(model._get_text("D2"), *"22"); + assert_eq!(model._get_text("D3"), *"70"); +} + +#[test] +fn test_fn_sumproduct_errors() { + let mut model = new_empty_model(); + + model._set("A1", "1"); + model._set("A2", "=1/0"); + model._set("A3", "3"); + + model._set("B1", "4"); + model._set("B2", "5"); + model._set("B3", "6"); + + model._set("C1", "=SUMPRODUCT(A1:A3, B1:B3)"); + + model._set("D1", "=1/0"); + model._set("C2", "=SUMPRODUCT(A1, D1)"); + + model.evaluate(); + + assert_eq!(model._get_text("C1"), *"#DIV/0!"); + assert_eq!(model._get_text("C2"), *"#DIV/0!"); +} + +#[test] +fn test_fn_sumproduct_edge_cases() { + let mut model = new_empty_model(); + + model._set("A1", "0"); + model._set("A2", "-1"); + model._set("B1", "5"); + model._set("B2", "3"); + + model._set("C1", "1"); + model._set("C2", "2"); + model._set("D1", "3"); + model._set("D2", "4"); + model._set("D3", "5"); + + model._set("E1", "=SUMPRODUCT(A1:A2, B1:B2)"); + model._set("E2", "=SUMPRODUCT(C1:C2, D1:D3)"); + + model.evaluate(); + + assert_eq!(model._get_text("E1"), *"-3"); + assert_eq!(model._get_text("E2"), *"#VALUE!"); +} From 8cdfdf4630a33be4fc2b34dd4703a0b17888d0e7 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Sat, 31 Jan 2026 15:39:00 -0800 Subject: [PATCH 18/21] Add SUMPRODUCT function (SUMIF/SUMIFS already in upstream) SUMPRODUCT multiplies corresponding elements in arrays and returns the sum of those products. This is useful for weighted calculations. Implementation: - Handles both range and array arguments - Validates dimension compatibility - Proper error propagation for non-numeric values - Uses hardcoded function lookup (no language.bin changes) Tests: 6 comprehensive tests covering basic usage, arrays, scalars, data types, edge cases, and error conditions. Note: SUMIF and SUMIFS from original PR are now in upstream. --- base/src/functions/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 3a8edf115..12fddd8b8 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -428,6 +428,11 @@ macro_rules! impl_function_lookup { impl Functions { pub fn lookup(&self, name: &str) -> Option { let key = name.to_uppercase(); + // New functions without localization support + match key.as_str() { + "SUMPRODUCT" => return Some(Function::Sumproduct), + _ => {} + } $( if self.$field == key { return Some(Function::$variant); From 9febe011884a3863408d1bc49319f04ee2eeae60 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Sun, 1 Feb 2026 01:17:11 -0800 Subject: [PATCH 19/21] Clean up SUMPRODUCT: remove redundant comments - Remove verbose comments that repeat what code already expresses - Use unreachable!() for logically impossible code path - Keep defensive bounds check without explanatory comment --- base/src/functions/mathematical.rs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index b55da485b..39292be87 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -829,7 +829,6 @@ impl<'a> Model<'a> { let r = a.len(); let c = a.first().map(|row| row.len()).unwrap_or(0); - // Check for empty arrays if r == 0 || c == 0 { return CalcResult::new_error( Error::VALUE, @@ -864,7 +863,7 @@ impl<'a> Model<'a> { if !have_matrix { rows = r; cols = c; - have_matrix = true; // Any array establishes matrix mode + have_matrix = true; } else if r != rows || c != cols { return CalcResult::new_error( Error::VALUE, @@ -883,15 +882,7 @@ impl<'a> Model<'a> { for p in processed { match p { Arg::Scalar(n) => prod *= n, - Arg::Array(_) => { - // This should never happen since have_matrix would be true - // if we have any arrays, but add safety check - return CalcResult::new_error( - Error::VALUE, - cell, - "Internal error: unexpected array in scalar mode".to_string(), - ); - } + Arg::Array(_) => unreachable!(), } } return CalcResult::Number(prod); @@ -905,7 +896,6 @@ impl<'a> Model<'a> { match p { Arg::Scalar(n) => prod *= *n, Arg::Array(a) => { - // Additional bounds check for safety if i >= a.len() || j >= a[i].len() { return CalcResult::new_error( Error::VALUE, From aed768e7cd2def000ad20354f1999c1d479f8205 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Sun, 1 Feb 2026 02:31:26 -0800 Subject: [PATCH 20/21] Force GitHub to recalculate diff From 19752b14419670c47813b35804143f70e4882a0e Mon Sep 17 00:00:00 2001 From: BrianHung Date: Mon, 9 Feb 2026 01:52:54 -0800 Subject: [PATCH 21/21] Fix SUMPRODUCT registration and static analysis. Add SUMPRODUCT to function tables and mark it available in docs. --- base/src/expressions/parser/static_analysis.rs | 2 +- base/src/functions/mod.rs | 6 +++++- docs/src/functions/math-and-trigonometry.md | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 901621169..4a564a91c 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -1056,7 +1056,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Sum => StaticResult::Scalar, Function::Sumif => not_implemented(args), Function::Sumifs => not_implemented(args), - Function::Sumproduct => not_implemented(args), + Function::Sumproduct => scalar_arguments(args), Function::Tan => scalar_arguments(args), Function::Tanh => scalar_arguments(args), Function::ErrorType => not_implemented(args), diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 12fddd8b8..51d13130e 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -77,6 +77,7 @@ pub enum Function { Sum, Sumif, Sumifs, + Sumproduct, Sumx2my2, Sumx2py2, Sumxmy2, @@ -870,6 +871,7 @@ impl Function { Function::Sum => functions.sum.clone(), Function::Sumif => functions.sumif.clone(), Function::Sumifs => functions.sumifs.clone(), + Function::Sumproduct => "SUMPRODUCT".to_string(), Function::Sumx2my2 => functions.sumx2my2.clone(), Function::Sumx2py2 => functions.sumx2py2.clone(), Function::Sumxmy2 => functions.sumxmy2.clone(), @@ -1173,7 +1175,7 @@ impl Function { Function::Steyx => functions.steyx.clone(), } } - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -1250,6 +1252,7 @@ impl Function { Function::Sum, Function::Sumif, Function::Sumifs, + Function::Sumproduct, Function::Sumx2my2, Function::Sumx2py2, Function::Sumxmy2, @@ -1709,6 +1712,7 @@ impl<'a> Model<'a> { Function::Sum => self.fn_sum(args, cell), Function::Sumif => self.fn_sumif(args, cell), Function::Sumifs => self.fn_sumifs(args, cell), + Function::Sumproduct => self.fn_sumproduct(args, cell), Function::Choose => self.fn_choose(args, cell), Function::Column => self.fn_column(args, cell), Function::Columns => self.fn_columns(args, cell), diff --git a/docs/src/functions/math-and-trigonometry.md b/docs/src/functions/math-and-trigonometry.md index 91d3f60bf..9e3aab3a6 100644 --- a/docs/src/functions/math-and-trigonometry.md +++ b/docs/src/functions/math-and-trigonometry.md @@ -85,7 +85,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | SUM | | – | | SUMIF | | – | | SUMIFS | | – | -| SUMPRODUCT | | – | +| SUMPRODUCT | | – | | SUMSQ | | – | | SUMX2MY2 | | – | | SUMX2PY2 | | – |