From 4b004f72a12215eb6e85c8ed19ce8cd02765547e Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Wed, 14 Jan 2026 17:51:13 -0800 Subject: [PATCH 1/2] feat: Preserve number padding using increment/decrement --- .../src/actions/incrementDecrement.ts | 57 +++++++++++++++---- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/packages/cursorless-engine/src/actions/incrementDecrement.ts b/packages/cursorless-engine/src/actions/incrementDecrement.ts index 17d1d3685e..78cc2e6bdf 100644 --- a/packages/cursorless-engine/src/actions/incrementDecrement.ts +++ b/packages/cursorless-engine/src/actions/incrementDecrement.ts @@ -88,23 +88,60 @@ function createDestination( return target.toDestination("to"); } +function hasLeadingZeros(text: string): boolean { + const withoutSign = text.replace(/^-/, ""); + const integerPart = withoutSign.split(".")[0]; + return integerPart.startsWith("0") && integerPart.length > 1; +} + +function formatNumber( + value: number, + text: string, + decimalPlaces?: number, +): string { + const sign = value < 0 ? "-" : ""; + const absValue = Math.abs(value); + + if (hasLeadingZeros(text)) { + const integerPartLength = text.replace(/^-/, "").split(".")[0].length; + const integerPart = Math.floor(absValue) + .toString() + .padStart(integerPartLength, "0"); + + if (decimalPlaces !== undefined) { + const fractionPart = (absValue - Math.floor(absValue)) + .toFixed(decimalPlaces) + .slice(2); + return `${sign}${integerPart}.${fractionPart}`; + } + + return `${sign}${integerPart}`; + } + + return decimalPlaces !== undefined + ? value.toFixed(decimalPlaces) + : value.toString(); +} + function updateNumber(isIncrement: boolean, text: string): string { return text.includes(".") - ? updateFloat(isIncrement, text).toString() - : updateInteger(isIncrement, text).toString(); + ? updateFloat(isIncrement, text) + : updateInteger(isIncrement, text); } -function updateInteger(isIncrement: boolean, text: string): number { - const original = parseInt(text); - const diff = 1; - return original + (isIncrement ? diff : -diff); +function updateInteger(isIncrement: boolean, text: string): string { + const value = parseInt(text) + (isIncrement ? 1 : -1); + return formatNumber(value, text); } -function updateFloat(isIncrement: boolean, text: string): number { +function updateFloat(isIncrement: boolean, text: string): string { const original = parseFloat(text); const isPercentage = Math.abs(original) <= 1.0; const diff = isPercentage ? 0.1 : 1; - const updated = original + (isIncrement ? diff : -diff); - // Remove precision problems that would add a lot of extra digits - return parseFloat(updated.toPrecision(15)) / 1; + const value = parseFloat( + (original + (isIncrement ? diff : -diff)).toPrecision(15), + ); + + const decimalPlaces = text.split(".")[1]?.length || 1; + return formatNumber(value, text, decimalPlaces); } From c9be913c90bf82cd63dd9cd2341960e9caa5d887 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Wed, 14 Jan 2026 17:56:41 -0800 Subject: [PATCH 2/2] feat: Add incrementDecrement test cases --- .../incrementDecrement/decrementFile.yml | 248 ++++++++++++++++++ .../incrementDecrement/decrementFirstSub.yml | 32 +++ .../incrementDecrement/incrementFile.yml | 248 ++++++++++++++++++ .../incrementDecrement/incrementSecondSub.yml | 32 +++ .../incrementDecrement/incrementThis.yml | 40 +++ 5 files changed, 600 insertions(+) create mode 100644 data/fixtures/recorded/actions/incrementDecrement/decrementFile.yml create mode 100644 data/fixtures/recorded/actions/incrementDecrement/decrementFirstSub.yml create mode 100644 data/fixtures/recorded/actions/incrementDecrement/incrementFile.yml create mode 100644 data/fixtures/recorded/actions/incrementDecrement/incrementSecondSub.yml create mode 100644 data/fixtures/recorded/actions/incrementDecrement/incrementThis.yml diff --git a/data/fixtures/recorded/actions/incrementDecrement/decrementFile.yml b/data/fixtures/recorded/actions/incrementDecrement/decrementFile.yml new file mode 100644 index 0000000000..46db205d87 --- /dev/null +++ b/data/fixtures/recorded/actions/incrementDecrement/decrementFile.yml @@ -0,0 +1,248 @@ +languageId: plaintext +command: + version: 7 + spokenForm: decrement file + action: + name: decrement + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: document} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + 4.0 + 11.0 + 0.1 + + 008 + 002 + 001 + 0008 + + 100 + 010 + + 00101 + 011 + + 005.60 + 002.5 + 100.99 + + 004.0 + 100.0 + 000.2 + + -006 + 000 + -002.0 + -98 + + 000.6 + 000.2 + + 002.234 + 004.00 + 008.999 + selections: + - anchor: {line: 18, character: 0} + active: {line: 18, character: 0} + marks: {} +finalState: + documentContents: |- + 3.0 + 10.0 + 0.0 + + 007 + 001 + 000 + 0007 + + 99 + 009 + + 00100 + 010 + + 004.60 + 001.5 + 99.99 + + 003.0 + 99.0 + 000.1 + + -007 + -001 + -003.0 + -99 + + 000.5 + 000.1 + + 001.234 + 003.00 + 007.999 + selections: + - anchor: {line: 18, character: 0} + active: {line: 18, character: 0} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 1, character: 0} + end: {line: 1, character: 4} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 2, character: 0} + end: {line: 2, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 4, character: 0} + end: {line: 4, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 5, character: 0} + end: {line: 5, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 6, character: 0} + end: {line: 6, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 7, character: 0} + end: {line: 7, character: 4} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 9, character: 0} + end: {line: 9, character: 2} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 10, character: 0} + end: {line: 10, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 12, character: 0} + end: {line: 12, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 13, character: 0} + end: {line: 13, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 15, character: 0} + end: {line: 15, character: 6} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 16, character: 0} + end: {line: 16, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 17, character: 0} + end: {line: 17, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 19, character: 0} + end: {line: 19, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 20, character: 0} + end: {line: 20, character: 4} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 21, character: 0} + end: {line: 21, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 23, character: 0} + end: {line: 23, character: 4} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 24, character: 0} + end: {line: 24, character: 4} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 25, character: 0} + end: {line: 25, character: 6} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 26, character: 0} + end: {line: 26, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 28, character: 0} + end: {line: 28, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 29, character: 0} + end: {line: 29, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 31, character: 0} + end: {line: 31, character: 7} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 32, character: 0} + end: {line: 32, character: 6} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 33, character: 0} + end: {line: 33, character: 7} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/actions/incrementDecrement/decrementFirstSub.yml b/data/fixtures/recorded/actions/incrementDecrement/decrementFirstSub.yml new file mode 100644 index 0000000000..f498b7c9e2 --- /dev/null +++ b/data/fixtures/recorded/actions/incrementDecrement/decrementFirstSub.yml @@ -0,0 +1,32 @@ +languageId: plaintext +command: + version: 7 + spokenForm: decrement first sub + action: + name: decrement + target: + type: primitive + modifiers: + - type: ordinalScope + scopeType: {type: word} + start: 0 + length: 1 + usePrePhraseSnapshot: true +initialState: + documentContents: "2026_015" + selections: + - anchor: {line: 0, character: 8} + active: {line: 0, character: 8} + marks: {} +finalState: + documentContents: "2025_015" + selections: + - anchor: {line: 0, character: 8} + active: {line: 0, character: 8} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 4} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/actions/incrementDecrement/incrementFile.yml b/data/fixtures/recorded/actions/incrementDecrement/incrementFile.yml new file mode 100644 index 0000000000..cdc0972b32 --- /dev/null +++ b/data/fixtures/recorded/actions/incrementDecrement/incrementFile.yml @@ -0,0 +1,248 @@ +languageId: plaintext +command: + version: 7 + spokenForm: increment file + action: + name: increment + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: document} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + 3.0 + 10.0 + 0.0 + + 007 + 001 + 000 + 0007 + + 99 + 009 + + 00100 + 010 + + 004.60 + 001.5 + 99.99 + + 003.0 + 99.0 + 000.1 + + -007 + -001 + -003.0 + -99 + + 000.5 + 000.1 + + 001.234 + 003.00 + 007.999 + selections: + - anchor: {line: 18, character: 0} + active: {line: 18, character: 0} + marks: {} +finalState: + documentContents: |- + 4.0 + 11.0 + 0.1 + + 008 + 002 + 001 + 0008 + + 100 + 010 + + 00101 + 011 + + 005.60 + 002.5 + 100.99 + + 004.0 + 100.0 + 000.2 + + -006 + 000 + -002.0 + -98 + + 000.6 + 000.2 + + 002.234 + 004.00 + 008.999 + selections: + - anchor: {line: 18, character: 0} + active: {line: 18, character: 0} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 1, character: 0} + end: {line: 1, character: 4} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 2, character: 0} + end: {line: 2, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 4, character: 0} + end: {line: 4, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 5, character: 0} + end: {line: 5, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 6, character: 0} + end: {line: 6, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 7, character: 0} + end: {line: 7, character: 4} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 9, character: 0} + end: {line: 9, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 10, character: 0} + end: {line: 10, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 12, character: 0} + end: {line: 12, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 13, character: 0} + end: {line: 13, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 15, character: 0} + end: {line: 15, character: 6} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 16, character: 0} + end: {line: 16, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 17, character: 0} + end: {line: 17, character: 6} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 19, character: 0} + end: {line: 19, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 20, character: 0} + end: {line: 20, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 21, character: 0} + end: {line: 21, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 23, character: 0} + end: {line: 23, character: 4} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 24, character: 0} + end: {line: 24, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 25, character: 0} + end: {line: 25, character: 6} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 26, character: 0} + end: {line: 26, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 28, character: 0} + end: {line: 28, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 29, character: 0} + end: {line: 29, character: 5} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 31, character: 0} + end: {line: 31, character: 7} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 32, character: 0} + end: {line: 32, character: 6} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 33, character: 0} + end: {line: 33, character: 7} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/actions/incrementDecrement/incrementSecondSub.yml b/data/fixtures/recorded/actions/incrementDecrement/incrementSecondSub.yml new file mode 100644 index 0000000000..2f9a962937 --- /dev/null +++ b/data/fixtures/recorded/actions/incrementDecrement/incrementSecondSub.yml @@ -0,0 +1,32 @@ +languageId: plaintext +command: + version: 7 + spokenForm: increment second sub + action: + name: increment + target: + type: primitive + modifiers: + - type: ordinalScope + scopeType: {type: word} + start: 1 + length: 1 + usePrePhraseSnapshot: true +initialState: + documentContents: "2026_014" + selections: + - anchor: {line: 0, character: 8} + active: {line: 0, character: 8} + marks: {} +finalState: + documentContents: "2026_015" + selections: + - anchor: {line: 0, character: 8} + active: {line: 0, character: 8} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 5} + end: {line: 0, character: 8} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/actions/incrementDecrement/incrementThis.yml b/data/fixtures/recorded/actions/incrementDecrement/incrementThis.yml new file mode 100644 index 0000000000..8d0407784f --- /dev/null +++ b/data/fixtures/recorded/actions/incrementDecrement/incrementThis.yml @@ -0,0 +1,40 @@ +languageId: plaintext +command: + version: 7 + spokenForm: increment this + action: + name: increment + target: + type: primitive + mark: {type: cursor} + usePrePhraseSnapshot: true +initialState: + documentContents: "1_01_001" + selections: + - anchor: {line: 0, character: 8} + active: {line: 0, character: 8} + marks: {} +finalState: + documentContents: "2_02_002" + selections: + - anchor: {line: 0, character: 8} + active: {line: 0, character: 8} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 1} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 0, character: 2} + end: {line: 0, character: 4} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 0, character: 5} + end: {line: 0, character: 8} + isReversed: false + hasExplicitRange: true