Skip to content

Commit 03dd7a5

Browse files
committed
fix(edit): keep multi-digit cell typing in the same field
R1 caret after a cell write sat on exclusive end p+w (next field start), so onSelectionChange cleared Ap and the next key jumped fields. Clamp caret to [p, p+w), treat Ap ownership as [p, p+w], and mark Ap from the planned field index instead of re-deriving it from the caret.
1 parent d16196d commit 03dd7a5

5 files changed

Lines changed: 135 additions & 9 deletions

File tree

src/client/services/cardCellEditGuard.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,14 +176,24 @@ export function createCardCellEditGuard(deps: CardCellEditGuardDeps) {
176176
return;
177177
}
178178
if (nav.mode === 'Ap') {
179-
// Stay Ap while caret remains in the same field
179+
// Stay Ap while caret remains in the same logical cell.
180+
// R1 places caret at the right edge; exclusive end p+w is also the next
181+
// field start — treat [p, p+w] inclusive so multi-digit typing does not jump.
180182
const card = getCardIfCellLine(editor.document, nav.line);
181183
if (!card) {
182184
clearNav();
183185
return;
184186
}
185-
const fi = cellModel.fieldIndexAt(editor.document.lineAt(nav.line).text, card, sel.startChar);
186-
if (fi !== nav.fieldIndex) clearNav();
187+
const f = card[nav.fieldIndex];
188+
if (!f) {
189+
clearNav();
190+
return;
191+
}
192+
const col = sel.startChar;
193+
if (col >= f.p && col <= f.p + f.w) {
194+
return;
195+
}
196+
clearNav();
187197
return;
188198
}
189199
// mode A: keep if still full/keep-separator on same field
@@ -217,6 +227,7 @@ export function createCardCellEditGuard(deps: CardCellEditGuardDeps) {
217227
newText: string;
218228
selStart: number;
219229
selEnd: number;
230+
fieldIndex?: number;
220231
markAp?: boolean;
221232
markA?: { fieldIndex: number; keepSeparator: boolean };
222233
clearNav?: boolean;
@@ -297,6 +308,7 @@ export function createCardCellEditGuard(deps: CardCellEditGuardDeps) {
297308
newText: result.line,
298309
selStart: result.caretCol,
299310
selEnd: result.caretCol,
311+
fieldIndex,
300312
markAp: true,
301313
};
302314
}

src/core/edit/cardCellModel.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,18 +113,25 @@ export function writeCellR1(line: string, card: CardField[], fieldIndex: number,
113113
/**
114114
* Caret column after an R1/L1 write — after the last non-space in the cell,
115115
* or at field start if empty (ready to type).
116+
*
117+
* Always returns a column in [p, p+w) so the caret stays owned by this field.
118+
* R1 values sit against the right edge; exclusive end p+w is the next field's
119+
* start and must not be used (would make the next keystroke jump fields).
116120
*/
117121
export function caretAfterCellValue(line: string, card: CardField[], fieldIndex: number): number {
118122
if (!card || fieldIndex < 0 || fieldIndex >= card.length) return 0;
119123
const f = card[fieldIndex];
124+
if (f.w <= 0) return f.p;
120125
const padded = padLineToCard(line, card);
121126
const slice = padded.slice(f.p, f.p + f.w);
122127
const val = slice.trim();
123128
if (!val) return f.p;
124129
const idx = slice.lastIndexOf(val[val.length - 1]);
125-
// last char of value occurrence from the right: find end of trimEnd content
130+
// Exclusive end of trimEnd content relative to cell
126131
const end = slice.length - (slice.match(/\s*$/)?.[0].length || 0);
127-
return f.p + Math.max(end, idx + 1);
132+
const exclusive = f.p + Math.max(end, idx + 1);
133+
// Clamp to last column of this cell (inclusive ownership for fieldIndexAt)
134+
return Math.min(exclusive, f.p + f.w - 1);
128135
}
129136

130137
export function fieldIndexAt(_line: string, card: CardField[], col: number): number {

src/extension.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4004,10 +4004,11 @@ async function handleCardCellDelete(direction) {
40044004
} else if (plan.markA) {
40054005
guard.markA(editor.document, plan.lineNum, plan.markA.fieldIndex, plan.markA.keepSeparator);
40064006
} else if (plan.markAp) {
4007-
const card = guard.getCardIfCellLine(editor.document, plan.lineNum);
4008-
const fi = card
4009-
? cardCellModel.fieldIndexAt(plan.newText, card, plan.selStart)
4010-
: (guard.getNav()?.fieldIndex ?? 0);
4007+
// Prefer plan.fieldIndex — caret may sit on exclusive cell end (next field start)
4008+
const fi =
4009+
typeof plan.fieldIndex === 'number' && plan.fieldIndex >= 0
4010+
? plan.fieldIndex
4011+
: (guard.getNav()?.fieldIndex ?? 0);
40114012
if (fi >= 0) guard.markAp(editor.document, plan.lineNum, fi);
40124013
}
40134014
vscode.commands.executeCommand('setContext', 'lsdyna.cellEditActive', guard.shouldCellEditActive(editor));

test/client/services/cardCellEditGuard.test.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,80 @@ describe('cardCellEditGuard', () => {
106106
null,
107107
);
108108
});
109+
110+
it('multi-key type stays in same field (A then Ap append)', () => {
111+
let line = buildLine('1', '12345', '67890');
112+
let document = fakeDoc(['*NODE', line]);
113+
const guard = createCardCellEditGuard({
114+
getCardFieldsForLine: () => CARD3,
115+
isLsdynaDocument: () => true,
116+
isKeywordLineText: (t) => /^\s*\*/.test(t),
117+
isProtectEnabled: () => true,
118+
});
119+
guard.markA(document, 1, 1, true);
120+
121+
// First key: full-cell write
122+
let plan = guard.planType(
123+
document,
124+
{ start: { line: 1, character: 11 }, end: { line: 1, character: 20 }, isEmpty: false },
125+
'1',
126+
);
127+
assert.ok(plan);
128+
assert.strictEqual(readCellValue(plan.newText, CARD3, 1), '1');
129+
assert.strictEqual(plan.fieldIndex, 1);
130+
line = plan.newText;
131+
document = fakeDoc(['*NODE', line]);
132+
document.version = 2;
133+
guard.markAp(document, 1, plan.fieldIndex);
134+
135+
// Caret after value must remain owned by field 1
136+
let caret = plan.caret;
137+
assert.ok(caret >= 10 && caret < 20, `caret ${caret}`);
138+
139+
// Simulate selection change after edit (Ap must not clear on right-edge caret)
140+
guard.onSelectionChange({
141+
document,
142+
selection: {
143+
start: { line: 1, character: caret },
144+
end: { line: 1, character: caret },
145+
isEmpty: true,
146+
},
147+
selections: [{ start: { line: 1, character: caret }, end: { line: 1, character: caret }, isEmpty: true }],
148+
});
149+
assert.ok(guard.getNav(), 'Ap nav must survive selection change at value end');
150+
assert.strictEqual(guard.getNav().mode, 'Ap');
151+
assert.strictEqual(guard.getNav().fieldIndex, 1);
152+
153+
// Second / third keys: append in same field
154+
for (const ch of ['2', '3']) {
155+
plan = guard.planType(
156+
document,
157+
{
158+
start: { line: 1, character: caret },
159+
end: { line: 1, character: caret },
160+
isEmpty: true,
161+
},
162+
ch,
163+
);
164+
assert.ok(plan, `planType for '${ch}'`);
165+
assert.strictEqual(plan.fieldIndex, 1);
166+
line = plan.newText;
167+
caret = plan.caret;
168+
document = fakeDoc(['*NODE', line]);
169+
document.version = (document.version || 2) + 1;
170+
guard.markAp(document, 1, plan.fieldIndex);
171+
guard.onSelectionChange({
172+
document,
173+
selection: {
174+
start: { line: 1, character: caret },
175+
end: { line: 1, character: caret },
176+
isEmpty: true,
177+
},
178+
selections: [{ start: { line: 1, character: caret }, end: { line: 1, character: caret }, isEmpty: true }],
179+
});
180+
}
181+
assert.strictEqual(readCellValue(line, CARD3, 1), '123');
182+
assert.strictEqual(readCellValue(line, CARD3, 2), '67890');
183+
assert.strictEqual(guard.getNav().fieldIndex, 1);
184+
});
109185
});

test/core/edit/cardCellModel.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,23 @@ describe('cardCellModel', () => {
124124
});
125125
});
126126

127+
describe('caretAfterCellValue', () => {
128+
it('stays inside the field (never exclusive end = next field start)', () => {
129+
const line = writeCellR1(buildLine('1', 'x', '9'), CARD3, 1, '12');
130+
const caret = caretAfterCellValue(line, CARD3, 1);
131+
// Field 1 is [10, 20); next field starts at 20
132+
assert.ok(caret >= 10 && caret < 20, `caret ${caret} must be in [10, 20)`);
133+
assert.strictEqual(fieldIndexAt(line, CARD3, caret), 1);
134+
});
135+
136+
it('full-width value still owns last column of cell', () => {
137+
const line = writeCellR1(buildLine('1', 'x', '9'), CARD3, 1, '1234567890');
138+
const caret = caretAfterCellValue(line, CARD3, 1);
139+
assert.strictEqual(caret, 19);
140+
assert.strictEqual(fieldIndexAt(line, CARD3, caret), 1);
141+
});
142+
});
143+
127144
describe('in-cell edit', () => {
128145
it('append insert builds value with R1 (S2 path)', () => {
129146
let line = buildLine('1', '12345', '67890');
@@ -136,6 +153,19 @@ describe('cardCellModel', () => {
136153
assert.strictEqual(readCellValue(r.line, CARD3, 2), '67890');
137154
});
138155

156+
it('multi-digit append stays in same field (no jump to next)', () => {
157+
let line = writeCellR1(buildLine('1', '12345', '67890'), CARD3, 1, '1');
158+
for (const ch of ['2', '3', '4']) {
159+
const caret = caretAfterCellValue(line, CARD3, 1);
160+
assert.strictEqual(fieldIndexAt(line, CARD3, caret), 1);
161+
const r = applyInCellInsert(line, CARD3, 1, caret, ch);
162+
assert.ok(r);
163+
line = r.line;
164+
}
165+
assert.strictEqual(readCellValue(line, CARD3, 1), '1234');
166+
assert.strictEqual(readCellValue(line, CARD3, 2), '67890');
167+
});
168+
139169
it('backspace on Ap shortens value without eating next field', () => {
140170
let line = writeCellR1(buildLine('1', 'x', '9'), CARD3, 1, '12');
141171
const r = applyInCellDelete(

0 commit comments

Comments
 (0)