Skip to content

Commit c4b6caa

Browse files
pfaffeDevtools-frontend LUCI CQ
authored andcommitted
CSS value tracing for arithmetic functions
This CL adds a new UI for tracing the evaluation of CSS values. This first iteration adds the UI on hover of custom property declarations in the styles tab. Supported arithmetic functions are min, max, clamp, and calc initially, more may be added later as needed. This CL does not support substitutions of var()s yet, those will be added in a followup. Bug: 396080529 Change-Id: I16b66bbfb66dbfa01c7a9630a28992450a052e46 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6269777 Reviewed-by: Ergün Erdoğmuş <[email protected]> Commit-Queue: Philip Pfaffe <[email protected]>
1 parent 6754373 commit c4b6caa

14 files changed

+595
-107
lines changed

front_end/core/root/Runtime.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,10 @@ export interface HostConfigThirdPartyCookieControls {
426426
managedBlockThirdPartyCookies: string|boolean;
427427
}
428428

429+
interface CSSValueTracing {
430+
enabled: boolean;
431+
}
432+
429433
/**
430434
* The host configuration that we expect from the DevTools back-end.
431435
*
@@ -459,6 +463,7 @@ export type HostConfig = Platform.TypeScriptUtilities.RecursivePartial<{
459463
devToolsEnableOriginBoundCookies: HostConfigEnableOriginBoundCookies,
460464
devToolsAnimationStylesInStylesTab: HostConfigAnimationStylesInStylesTab,
461465
thirdPartyCookieControls: HostConfigThirdPartyCookieControls,
466+
devToolsCssValueTracing: CSSValueTracing,
462467
}>;
463468

464469
/**

front_end/core/sdk/CSSModel.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ export class CSSModel extends SDKModel<EventTypes> {
132132
return this.#colorScheme;
133133
}
134134

135+
async resolveValues(nodeId: Protocol.DOM.NodeId, ...values: string[]): Promise<string[]|null> {
136+
const response = await this.agent.invoke_resolveValues({values, nodeId});
137+
return response.getError() ? null : response.results;
138+
}
139+
135140
headersForSourceURL(sourceURL: Platform.DevToolsPath.UrlString): CSSStyleSheetHeader[] {
136141
const headers = [];
137142
for (const headerId of this.getStyleSheetIdsForURL(sourceURL)) {

front_end/core/sdk/CSSProperty.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ import {
2828
LightDarkColorMatcher,
2929
LinearGradientMatcher,
3030
LinkableNameMatcher,
31+
MathFunctionMatcher,
3132
PositionAnchorMatcher,
3233
PositionTryMatcher,
33-
SelectFunctionMatcher,
3434
ShadowMatcher,
3535
StringMatcher,
3636
URLMatcher,
@@ -136,7 +136,7 @@ export class CSSProperty extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
136136
new FlexGridMatcher(),
137137
new PositionTryMatcher(),
138138
new LengthMatcher(),
139-
new SelectFunctionMatcher(),
139+
new MathFunctionMatcher(),
140140
new AutoBaseMatcher(),
141141
];
142142

front_end/core/sdk/CSSPropertyParser.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,10 @@ export class BottomUpTreeMatching extends TreeWalker {
240240
this.#matchers.push(...matchers);
241241
}
242242

243+
hasMatches(...matchTypes: Array<Constructor<Match>>): boolean {
244+
return Boolean(this.#matchedNodes.values().find(match => matchTypes.some(matchType => match instanceof matchType)));
245+
}
246+
243247
getMatch(node: CodeMirror.SyntaxNode): Match|undefined {
244248
return this.#matchedNodes.get(this.#key(node));
245249
}

front_end/core/sdk/CSSPropertyParserMatchers.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -589,12 +589,11 @@ describe('Matchers for SDK.CSSPropertyParser.BottomUpTreeMatching', () => {
589589
});
590590
});
591591

592-
describe('SelectFunctionMatcher', () => {
592+
describe('MathFunctionMatcher', () => {
593593
it('matches selecting functions', () => {
594594
const success = ['clamp(1px, 2px, 3px)', 'min(1, 2)', 'max(3, 4)'];
595595
for (const value of success) {
596-
const {match, text} =
597-
matchSingleValue('width', value, new SDK.CSSPropertyParserMatchers.SelectFunctionMatcher());
596+
const {match, text} = matchSingleValue('width', value, new SDK.CSSPropertyParserMatchers.MathFunctionMatcher());
598597
assert.exists(match, text);
599598
assert.strictEqual(match.text, value);
600599
assert.strictEqual(match.func, value.substr(0, value.indexOf('(')));
@@ -603,8 +602,7 @@ describe('Matchers for SDK.CSSPropertyParser.BottomUpTreeMatching', () => {
603602

604603
const failure = ['clomp(1px, 2px, 3px)', 'min()'];
605604
for (const value of failure) {
606-
const {match, text} =
607-
matchSingleValue('width', value, new SDK.CSSPropertyParserMatchers.SelectFunctionMatcher());
605+
const {match, text} = matchSingleValue('width', value, new SDK.CSSPropertyParserMatchers.MathFunctionMatcher());
608606
assert.notExists(match, text);
609607
}
610608
});

front_end/core/sdk/CSSPropertyParserMatchers.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -660,10 +660,10 @@ export class LengthMatch implements Match {
660660
export class LengthMatcher extends matcherBase(LengthMatch) {
661661
// clang-format on
662662
static readonly LENGTH_UNITS = new Set([
663-
'em', 'ex', 'ch', 'cap', 'ic', 'lh', 'rem', 'rex', 'rch', 'rlh', 'ric', 'rcap', 'px', 'pt',
664-
'pc', 'in', 'cm', 'mm', 'Q', 'vw', 'vh', 'vi', 'vb', 'vmin', 'vmax', 'dvw', 'dvh', 'dvi',
665-
'dvb', 'dvmin', 'dvmax', 'svw', 'svh', 'svi', 'svb', 'svmin', 'svmax', 'lvw', 'lvh', 'lvi', 'lvb', 'lvmin',
666-
'lvmax', 'cqw', 'cqh', 'cqi', 'cqb', 'cqmin', 'cqmax', 'cqem', 'cqlh', 'cqex', 'cqch',
663+
'em', 'ex', 'ch', 'cap', 'ic', 'lh', 'rem', 'rex', 'rch', 'rlh', 'ric', 'rcap', 'pt',
664+
'pc', 'in', 'cm', 'mm', 'Q', 'vw', 'vh', 'vi', 'vb', 'vmin', 'vmax', 'dvw', 'dvh',
665+
'dvi', 'dvb', 'dvmin', 'dvmax', 'svw', 'svh', 'svi', 'svb', 'svmin', 'svmax', 'lvw', 'lvh', 'lvi',
666+
'lvb', 'lvmin', 'lvmax', 'cqw', 'cqh', 'cqi', 'cqb', 'cqmin', 'cqmax', 'cqem', 'cqlh', 'cqex', 'cqch',
667667
]);
668668
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): LengthMatch|null {
669669
if (node.name !== 'NumberLiteral') {
@@ -678,30 +678,30 @@ export class LengthMatcher extends matcherBase(LengthMatch) {
678678
}
679679
}
680680

681-
export class SelectFunctionMatch implements Match {
681+
export class MathFunctionMatch implements Match {
682682
constructor(
683683
readonly text: string, readonly node: CodeMirror.SyntaxNode, readonly func: string,
684684
readonly args: CodeMirror.SyntaxNode[][]) {
685685
}
686686
}
687687

688688
// clang-format off
689-
export class SelectFunctionMatcher extends matcherBase(SelectFunctionMatch) {
689+
export class MathFunctionMatcher extends matcherBase(MathFunctionMatch) {
690690
// clang-format on
691-
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): SelectFunctionMatch|null {
691+
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): MathFunctionMatch|null {
692692
if (node.name !== 'CallExpression') {
693693
return null;
694694
}
695695
const callee = matching.ast.text(node.getChild('Callee'));
696-
if (!['min', 'max', 'clamp'].includes(callee)) {
696+
if (!['min', 'max', 'clamp', 'calc'].includes(callee)) {
697697
return null;
698698
}
699699
const args = ASTUtils.callArgs(node);
700700
if (args.some(arg => arg.length === 0 || matching.hasUnresolvedVarsRange(arg[0], arg[arg.length - 1]))) {
701701
return null;
702702
}
703703
const text = matching.ast.text(node);
704-
return new SelectFunctionMatch(text, node, callee, args);
704+
return new MathFunctionMatch(text, node, callee, args);
705705
}
706706
}
707707

front_end/panels/elements/CSSValueTraceView.test.ts

Lines changed: 142 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,150 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import * as SDK from '../../core/sdk/sdk.js';
6+
import type * as Protocol from '../../generated/protocol.js';
7+
import {createTarget, stubNoopSettings} from '../../testing/EnvironmentHelpers.js';
8+
import {describeWithMockConnection, setMockConnectionResponseHandler} from '../../testing/MockConnection.js';
9+
import {getMatchedStylesWithBlankRule} from '../../testing/StyleHelpers.js';
10+
import * as UI from '../../ui/legacy/legacy.js';
11+
512
import * as Elements from './elements.js';
613

7-
describe('CSSValueTraceView', () => {
8-
it('works', async () => {
9-
const view = new Elements.CSSValueTraceView.CSSValueTraceView();
10-
view.showTrace(
11-
[[document.createTextNode('sub 1')], [document.createTextNode('sub 2')]],
12-
[[document.createTextNode('eval 1')], [document.createTextNode('eval 2')]], [document.createTextNode('final')]);
13-
const {performUpdate} = view;
14-
const performUpdatePromise = Promise.withResolvers<void>();
15-
sinon.stub(view, 'performUpdate').callsFake(function(this: unknown) {
16-
performUpdate.call(this);
17-
performUpdatePromise.resolve();
14+
async function setUpStyles() {
15+
stubNoopSettings();
16+
setMockConnectionResponseHandler('CSS.enable', () => ({}));
17+
const computedStyleModel = new Elements.ComputedStyleModel.ComputedStyleModel();
18+
const cssModel = new SDK.CSSModel.CSSModel(createTarget());
19+
await cssModel.resumeModel();
20+
const domModel = cssModel.domModel();
21+
const node = new SDK.DOMModel.DOMNode(domModel);
22+
node.id = 0 as Protocol.DOM.NodeId;
23+
UI.Context.Context.instance().setFlavor(SDK.DOMModel.DOMNode, node);
24+
const matchedStyles = await getMatchedStylesWithBlankRule(cssModel);
25+
const stylesPane = new Elements.StylesSidebarPane.StylesSidebarPane(computedStyleModel);
26+
27+
return {matchedStyles, stylesPane};
28+
}
29+
30+
async function getTreeElement(
31+
matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, stylesPane: Elements.StylesSidebarPane.StylesSidebarPane,
32+
name: string, value: string, variables?: Record<string, {value: string, computedValue?: string}>) {
33+
const property = new SDK.CSSProperty.CSSProperty(
34+
matchedStyles.nodeStyles()[0], matchedStyles.nodeStyles()[0].pastLastSourcePropertyIndex(), name, value, true,
35+
false, true, false, '', undefined, []);
36+
const treeElement = new Elements.StylePropertyTreeElement.StylePropertyTreeElement({
37+
stylesPane,
38+
section: sinon.createStubInstance(Elements.StylePropertiesSection.StylePropertiesSection),
39+
matchedStyles,
40+
property,
41+
isShorthand: false,
42+
inherited: false,
43+
overloaded: false,
44+
newProperty: true,
45+
});
46+
47+
if (variables) {
48+
const varMap =
49+
new Map(Object.getOwnPropertyNames(variables)
50+
.map(
51+
name => new SDK.CSSProperty.CSSProperty(
52+
matchedStyles.nodeStyles()[0], matchedStyles.nodeStyles()[0].pastLastSourcePropertyIndex(),
53+
name, variables[name].value, true, false, true, false, '', undefined, []))
54+
.map(property => [property.name, {
55+
value: variables[property.name].computedValue ?? variables[property.name].value,
56+
declaration: new SDK.CSSMatchedStyles.CSSValueSource(property),
57+
}]));
58+
sinon.stub(matchedStyles, 'computeCSSVariable').callsFake((_, name) => varMap.get(name) ?? null);
59+
}
60+
61+
return {matchedStyles, property, treeElement};
62+
}
63+
64+
async function showTrace(
65+
property: SDK.CSSProperty.CSSProperty, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
66+
treeElement: Elements.StylePropertyTreeElement.StylePropertyTreeElement):
67+
Promise<Elements.CSSValueTraceView.ViewInput> {
68+
let renderPromise = Promise.withResolvers<Elements.CSSValueTraceView.ViewInput>();
69+
const view = new Elements.CSSValueTraceView.CSSValueTraceView(
70+
sinon.stub<Parameters<Elements.CSSValueTraceView.View>>().callsFake(input => {
71+
renderPromise.resolve(input);
72+
}));
73+
await renderPromise.promise;
74+
renderPromise = Promise.withResolvers<Elements.CSSValueTraceView.ViewInput>();
75+
view.showTrace(property, matchedStyles, new Map(), treeElement.getPropertyRenderers());
76+
return await renderPromise.promise;
77+
}
78+
79+
describeWithMockConnection('CSSValueTraceView', () => {
80+
beforeEach(() => {
81+
setMockConnectionResponseHandler('CSS.resolveValues', ({values}) => {
82+
const results = values.map((v: string) => {
83+
if (v.endsWith('em')) {
84+
return `${Number(v.substring(0, v.length - 2)) * 16}px`;
85+
}
86+
if (v.endsWith('vw')) {
87+
return `${Number(v.substring(0, v.length - 2)) * 980 / 100}px`;
88+
}
89+
switch (v) {
90+
case 'calc(clamp(16px, calc(1vw + 1em), 24px) + 3.2px)':
91+
return '27.7px';
92+
case 'clamp(16px, calc(1vw + 1em), 24px)':
93+
return '24px';
94+
case 'calc(1vw + 1em)':
95+
return '24.53px';
96+
}
97+
return v;
98+
});
99+
return {results};
18100
});
19-
await performUpdatePromise.promise;
20-
assert.deepEqual(
21-
view.contentElement.textContent?.split('\n').map(l => l.trim()).filter(l => l),
22-
['\u21B3sub 1\u21B3sub 2', '=eval 1', '=eval 2', '=final']);
101+
});
102+
103+
it('shows simple values', async () => {
104+
const {matchedStyles, stylesPane} = await setUpStyles();
105+
for (const value of ['40', '40px', 'red']) {
106+
const {property, treeElement} = await getTreeElement(matchedStyles, stylesPane, 'property', value);
107+
108+
const input = await showTrace(property, matchedStyles, treeElement);
109+
110+
const substitutions = input.substitutions.map(nodes => nodes.map(node => node.textContent ?? '').join());
111+
const evaluations = input.evaluations.map(nodes => nodes.map(node => node.textContent ?? '').join());
112+
const result = input.finalResult?.map(node => node.textContent ?? '').join();
113+
assert.deepEqual(substitutions, []);
114+
assert.deepEqual(evaluations, []);
115+
assert.deepEqual(result, value);
116+
}
117+
});
118+
119+
it('does not have substitutions yet', async () => {
120+
const {matchedStyles, stylesPane} = await setUpStyles();
121+
const {property, treeElement} =
122+
await getTreeElement(matchedStyles, stylesPane, 'width', 'var(--w)', {'--w': {value: '40em'}});
123+
const input = await showTrace(property, matchedStyles, treeElement);
124+
const substitutions = input.substitutions.map(nodes => nodes.map(node => node.textContent ?? '').join());
125+
const evaluations = input.evaluations.map(nodes => nodes.map(node => node.textContent ?? '').join());
126+
const result = input.finalResult?.map(node => node.textContent ?? '').join();
127+
// TODO(pfaffe) once vars actually substitute this needs to show the first line
128+
assert.deepEqual(substitutions, ['var(--w)']);
129+
assert.deepEqual(evaluations, []);
130+
assert.deepEqual(result, 'var(--w)');
131+
});
132+
133+
it('shows intermediate evaluation steps', async () => {
134+
const {matchedStyles, stylesPane} = await setUpStyles();
135+
const {property, treeElement} = await getTreeElement(
136+
matchedStyles, stylesPane, 'fond-size', 'calc(clamp(16px, calc(1vw + 1em), 24px) + 3.2px)');
137+
const resolveValuesSpy = sinon.spy(treeElement.parentPane().cssModel()!.resolveValues);
138+
const input = await showTrace(property, matchedStyles, treeElement);
139+
const substitutions = input.substitutions.map(nodes => nodes.map(node => node.textContent ?? '').join(''));
140+
const evaluations = input.evaluations.map(nodes => nodes.map(node => node.textContent ?? '').join(''));
141+
const result = input.finalResult?.map(node => node.textContent ?? '').join('');
142+
await Promise.all(resolveValuesSpy.returnValues);
143+
assert.deepEqual(substitutions, []);
144+
assert.deepEqual(evaluations, [
145+
'calc(clamp(16px, calc(9.8px + 16px), 24px) + 3.2px)',
146+
'calc(clamp(16px, 24.53px, 24px) + 3.2px)',
147+
'calc(24px + 3.2px)',
148+
]);
149+
assert.deepEqual(result, '27.7px');
23150
});
24151
});

front_end/panels/elements/CSSValueTraceView.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,18 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import type * as SDK from '../../core/sdk/sdk.js';
56
import * as Lit from '../../third_party/lit/lit.js';
67
import * as UI from '../../ui/legacy/legacy.js';
78

89
import cssValueTraceViewStyles from './cssValueTraceView.css.js';
10+
import {
11+
type MatchRenderer,
12+
Renderer,
13+
RenderingContext,
14+
TracingContext,
15+
} from './PropertyRenderer.js';
16+
import stylePropertiesTreeOutlineStyles from './stylePropertiesTreeOutline.css.js';
917

1018
const {html, render} = Lit;
1119

@@ -63,19 +71,61 @@ export class CSSValueTraceView extends UI.Widget.VBox {
6371
},
6472
) {
6573
super(true);
66-
this.registerRequiredCSS(cssValueTraceViewStyles);
74+
this.registerRequiredCSS(cssValueTraceViewStyles, stylePropertiesTreeOutlineStyles);
6775
this.#view = view;
6876
this.requestUpdate();
6977
}
7078

7179
showTrace(
72-
substitutions: Node[][],
73-
evaluations: Node[][],
74-
finalResult: Node[]|undefined,
80+
property: SDK.CSSProperty.CSSProperty,
81+
matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
82+
computedStyles: Map<string, string>|null,
83+
renderers: Array<MatchRenderer<SDK.CSSPropertyParser.Match>>,
7584
): void {
85+
const matchedResult = property.parseValue(matchedStyles, computedStyles);
86+
if (!matchedResult) {
87+
return undefined;
88+
}
89+
90+
const rendererMap = new Map(
91+
renderers.map(r => [r.matcher().matchType, r]),
92+
);
93+
94+
// Compute all trace lines
95+
// 1st: Apply substitutions for var() functions
96+
const substitutions = [];
97+
const evaluations = [];
98+
const tracing = new TracingContext(matchedResult);
99+
while (tracing.nextSubstitution()) {
100+
const context = new RenderingContext(
101+
matchedResult.ast,
102+
rendererMap,
103+
matchedResult,
104+
/* cssControls */ undefined,
105+
/* options */ {},
106+
tracing,
107+
);
108+
substitutions.push(
109+
Renderer.render(matchedResult.ast.tree, context).nodes,
110+
);
111+
}
112+
113+
// 2nd: Apply evaluations for calc, min, max, etc.
114+
while (tracing.nextEvaluation()) {
115+
const context = new RenderingContext(
116+
matchedResult.ast,
117+
rendererMap,
118+
matchedResult,
119+
/* cssControls */ undefined,
120+
/* options */ {},
121+
tracing,
122+
);
123+
evaluations.push(Renderer.render(matchedResult.ast.tree, context).nodes);
124+
}
125+
76126
this.#substitutions = substitutions;
127+
this.#finalResult = evaluations.pop();
77128
this.#evaluations = evaluations;
78-
this.#finalResult = finalResult;
79129
this.requestUpdate();
80130
}
81131

0 commit comments

Comments
 (0)