Skip to content

Commit f34bcdb

Browse files
authored
Merge pull request #60 from ShortArrow/fix/select-bounce
Carry pending previews in state messages (fixes the segment bounce)
2 parents 72bce06 + 085e71d commit f34bcdb

6 files changed

Lines changed: 193 additions & 26 deletions

File tree

src/panel.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -268,20 +268,22 @@ export async function toggleSettingsPanel(): Promise<void> {
268268
}
269269

270270
/**
271-
* The compiled conversions, as text to paste into the webview script.
271+
* The compiled conversions and the pending merge, as text to paste into the
272+
* webview script.
272273
*
273274
* A panel whose sliders cannot convert is still worth showing, so an unreadable
274275
* file costs the sliders and nothing else: the picker keeps working.
275276
*/
276277
function readInlineLib(extensionPath: string): string {
277-
try {
278-
return fs.readFileSync(
279-
path.join(extensionPath, "out", "colorConvert.js"),
280-
"utf8"
281-
);
282-
} catch {
283-
return "";
284-
}
278+
return ["colorConvert.js", "panelState.js"]
279+
.map((file) => {
280+
try {
281+
return fs.readFileSync(path.join(extensionPath, "out", file), "utf8");
282+
} catch {
283+
return "";
284+
}
285+
})
286+
.join("\n");
285287
}
286288

287289
class ColorPanelProvider implements vscode.WebviewViewProvider {
@@ -323,12 +325,22 @@ class ColorPanelProvider implements vscode.WebviewViewProvider {
323325
});
324326
}
325327

328+
/**
329+
* Tell the webview what is saved and what is still only proposed.
330+
*
331+
* The pending map travels with the saved values because the script displays
332+
* one over the other: a state message posted while a row is staged used to
333+
* carry the saved value alone, and the row snapped back to it.
334+
*/
326335
postState() {
327336
this.view?.webview.postMessage({
328337
type: "state",
329338
toggles: currentToggles(),
330339
selects: currentSelects(),
331340
rows: currentRows(),
341+
pending: Object.fromEntries(
342+
getPendingPreviews().map((entry) => [entry.key, entry.value])
343+
),
332344
});
333345
}
334346

src/panelHtml.ts

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,25 @@ const script = ` const vscode = acquireVsCodeApi();
200200
function convert(from, to) {
201201
return exports[from + 'To' + to.charAt(0).toUpperCase() + to.slice(1)];
202202
}
203+
/**
204+
* One inlined merge of a saved value with whatever is staged, by the kind
205+
* of control asking, or undefined when the library could not be read.
206+
*
207+
* Assembled like {@link convert} and for the same reason: the panel
208+
* script never spells the name, so finding it in the document is proof
209+
* the library was really inlined.
210+
*/
211+
function display(kind) {
212+
return exports['display' + kind.charAt(0).toUpperCase() + kind.slice(1)];
213+
}
214+
/**
215+
* What one row should show now, falling back to the saved value alone
216+
* when the merge could not be inlined.
217+
*/
218+
function shown(kind, saved, key, pending) {
219+
const merge = display(kind);
220+
return merge ? merge(saved, key, pending || {}) : { value: saved, pending: false };
221+
}
203222
function sliders(key) {
204223
return document.querySelectorAll('input[data-slider-for="' + key + '"]');
205224
}
@@ -457,34 +476,39 @@ const script = ` const vscode = acquireVsCodeApi();
457476
if (!message || message.type !== 'state') {
458477
return;
459478
}
479+
const pending = message.pending || {};
460480
(message.toggles || []).forEach((toggle) => {
481+
const entry = shown('toggle', toggle.value, toggle.key, pending);
461482
const box = switchOf(toggle.key);
462483
if (box) {
463-
box.checked = toggle.value;
484+
box.checked = entry.value;
464485
}
465-
markPending(toggle.key, false);
486+
markPending(toggle.key, entry.pending);
466487
});
467488
(message.selects || []).forEach((select) => {
468-
markSelected(select.key, select.value);
469-
markPending(select.key, false);
489+
const entry = shown('value', select.value, select.key, pending);
490+
markSelected(select.key, entry.value);
491+
markPending(select.key, entry.pending);
470492
});
471493
message.rows.forEach((row) => {
472-
markPending(row.key, false);
494+
const entry = shown('value', row.savedColor, row.key, pending);
495+
const color = entry.value;
496+
markPending(row.key, entry.pending);
473497
const swatch = document.querySelector('[data-swatch="' + row.key + '"]');
474498
if (swatch) {
475-
swatch.style.background = row.savedColor;
476-
swatch.title = row.savedColor;
499+
swatch.style.background = color;
500+
swatch.title = color;
477501
}
478502
const input = document.querySelector('input[type="color"][data-key="' + row.key + '"]');
479-
if (input && /^#[0-9a-fA-F]{6}$/.test(row.savedColor)) {
480-
input.value = row.savedColor;
503+
if (input && /^#[0-9a-fA-F]{6}$/.test(color)) {
504+
input.value = color;
481505
}
482-
showOnSliders(row.key, /^#[0-9a-fA-F]{6}$/.test(row.savedColor) ? row.savedColor : '#000000');
483-
// The saved value wins over whatever is being typed: a reset has to
484-
// reach a field the reader still has the caret in.
506+
showOnSliders(row.key, /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#000000');
507+
// The displayed value wins over whatever is being typed: a reset has
508+
// to reach a field the reader still has the caret in.
485509
const field = document.querySelector('[data-hex-for="' + row.key + '"]');
486510
if (field) {
487-
field.value = row.savedColor;
511+
field.value = color;
488512
field.classList.remove('invalid');
489513
}
490514
});
@@ -558,15 +582,18 @@ const style = ` body { font-family: var(--vscode-font-family); font-size: v
558582
*
559583
* Flipping a switch or dragging a picker only previews; a row marks itself
560584
* pending until a state message says its value was saved. The footer button
561-
* commits every pending row at once.
585+
* commits every pending row at once. A state message names what is saved and
586+
* what is staged separately, and every control shows the staged value over the
587+
* saved one, so a resync arriving mid-edit cannot undo a choice.
562588
*
563589
* The select rows sit between the two: they are settings of the editor rather
564590
* than of this extension, and choosing one stages it without any local
565591
* rendering, because VS Code draws those numbers itself.
566592
*
567-
* @param inlineLib compiled color conversions, pasted verbatim into the nonced
568-
* script behind an exports shim so the sliders convert with the very code the
569-
* unit tests cover; an empty string leaves the row usable through its picker
593+
* @param inlineLib the compiled color conversions and pending merge, pasted
594+
* verbatim into the nonced script behind an exports shim so the panel runs
595+
* the very code the unit tests cover; an empty string leaves the row usable
596+
* through its picker
570597
*/
571598
export function renderPanelHtml(
572599
toggles: PanelToggle[],

src/panelState.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* What a control shows, once the staged value is laid over the saved one.
3+
*
4+
* A state message names what is saved, and separately what the panel is still
5+
* proposing; the two are merged here so the webview and the unit tests reach
6+
* the same answer instead of two implementations that agree until they drift.
7+
* Like the color conversions, the module imports nothing and stays inside
8+
* ES2020: its compiled CommonJS is inlined into the webview script, where
9+
* there is no module loader and no node standard library.
10+
*/
11+
12+
/** Settings the panel is proposing, keyed by configuration name. */
13+
export interface PendingMap {
14+
[key: string]: string | boolean;
15+
}
16+
17+
/** One value to display, and whether it is staged rather than saved. */
18+
export interface DisplayEntry {
19+
value: string;
20+
pending: boolean;
21+
}
22+
23+
/**
24+
* The text one row shows: the staged value when there is one, else the saved.
25+
*
26+
* An entry of the wrong type is not this row's: previews of switches and of
27+
* enumerated settings share one store, so a boolean parked under this key is
28+
* ignored rather than rendered.
29+
*
30+
* @param saved the value the configuration currently holds
31+
* @param key the configuration name this row edits
32+
* @param pending everything the panel is proposing
33+
*/
34+
export function displayValue(
35+
saved: string,
36+
key: string,
37+
pending: PendingMap
38+
): DisplayEntry {
39+
const staged = pending[key];
40+
if (typeof staged === "string") {
41+
return { value: staged, pending: true };
42+
}
43+
return { value: saved, pending: false };
44+
}
45+
46+
/** The state one switch shows, by the same rule as {@link displayValue}. */
47+
export function displayToggle(
48+
saved: boolean,
49+
key: string,
50+
pending: PendingMap
51+
): { value: boolean; pending: boolean } {
52+
const staged = pending[key];
53+
if (typeof staged === "boolean") {
54+
return { value: staged, pending: true };
55+
}
56+
return { value: saved, pending: false };
57+
}

src/test/panelMessages.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import * as assert from 'assert';
22
import { beforeEach, describe, it } from 'mocha';
33
import { PanelMessageDeps, handlePanelMessage } from '../panel';
44
import {
5+
PendingPreview,
56
clearAllPreviews,
7+
getPendingPreviews,
68
getPreviewColor,
79
getPreviewToggle,
810
setPreviewColor,
@@ -163,6 +165,18 @@ describe('Test panel message handling', () => {
163165
assert.strictEqual(counts.postState, 1);
164166
});
165167

168+
it('Must have the staged select pending while that state is posted', async () => {
169+
const staged: PendingPreview[][] = [];
170+
const { deps } = recordingDeps();
171+
await handlePanelMessage(
172+
{ type: 'preview', key: 'editor.lineNumbers', value: 'relative' },
173+
{ ...deps, postState: () => staged.push(getPendingPreviews()) }
174+
);
175+
assert.deepStrictEqual(staged, [
176+
[{ key: 'editor.lineNumbers', value: 'relative' }],
177+
]);
178+
});
179+
166180
it('Must ignore a select value the setting does not offer', async () => {
167181
const { deps, saves, counts } = recordingDeps();
168182
await handlePanelMessage(

src/test/panelState.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import * as assert from 'assert';
2+
import { describe, it } from 'mocha';
3+
import { displayToggle, displayValue } from '../panelState';
4+
5+
describe('Test panel display values', () => {
6+
it('Must show the saved value while nothing is pending', () => {
7+
assert.deepStrictEqual(displayValue('on', 'editor.lineNumbers', {}), {
8+
value: 'on',
9+
pending: false,
10+
});
11+
});
12+
13+
it('Must show the pending value over the saved one', () => {
14+
assert.deepStrictEqual(
15+
displayValue('on', 'editor.lineNumbers', {
16+
'editor.lineNumbers': 'relative',
17+
}),
18+
{ value: 'relative', pending: true }
19+
);
20+
});
21+
22+
it('Must ignore a pending entry that is not a string', () => {
23+
assert.deepStrictEqual(displayValue('on', 'k', { k: true }), {
24+
value: 'on',
25+
pending: false,
26+
});
27+
});
28+
29+
it('Must show a pending switch over its saved state', () => {
30+
assert.deepStrictEqual(displayToggle(false, 'enableRainbow', { enableRainbow: true }), {
31+
value: true,
32+
pending: true,
33+
});
34+
assert.deepStrictEqual(displayToggle(false, 'enableRainbow', {}), {
35+
value: false,
36+
pending: false,
37+
});
38+
});
39+
40+
it('Must ignore a pending entry that is not a boolean', () => {
41+
assert.deepStrictEqual(displayToggle(true, 'k', { k: '#fff' }), {
42+
value: true,
43+
pending: false,
44+
});
45+
});
46+
47+
it('Must leave a row alone while another key is pending', () => {
48+
assert.deepStrictEqual(displayValue('on', 'a', { b: 'off' }), {
49+
value: 'on',
50+
pending: false,
51+
});
52+
});
53+
});

src/test/panelView.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ describe('Test color panel view', () => {
5151
(html as string).includes('hexToHsl'),
5252
'resolved panel html does not inline the color conversions'
5353
);
54+
assert.ok(
55+
(html as string).includes('displayValue'),
56+
'resolved panel html does not inline the pending merge'
57+
);
5458
assert.ok(
5559
(html as string).includes('data-plane-for="foreground"'),
5660
'resolved panel html has no picking plane for foreground'

0 commit comments

Comments
 (0)