w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.52k stars 673 forks source link

[css-values] Iverson bracket functions: if(), media(), supports(), defined() #4731

Open jimmyfrasche opened 4 years ago

jimmyfrasche commented 4 years ago

I propose three mathematical functions inspired by Iverson bracket notation — if(p), media(q), supports(s) — that can only be used within calc() and always return either 0 or 1. This is useful for concisely toggling terms on or off in an expression, like width: calc(500px + 10px*media((width > 800px))).

media() is simple to define by example:

:root {
  --dur: calc(5s * media((prefers-reduced-motion)));
}

is equivalent to

:root {
  --dur: 5s;
}
@media (prefers-reduced-motion) {
  --dur: 0s;
}

and similarly for supports(). If the media or feature query match, evaluate to 1. If it fails to match or is not recognized, evaluate to 0.

if() is similar but work on expressions logical and comparative expressions involving var(), env(), and attr(), like:

if(var(--x) = 1)
if(2*var(--x) < abs(env(--y)) <= 80)

I am leaving this somewhat loosely defined. There are a lot of cases to consider I'm not sure what the best solution is for all of them. Should non logical/relation expressions be allowed? That is, should if(2*var(--x)) evaluate to 1 if it the inner expression is computed to be nonzero or does there need to be an explicit comparison? An if() that contains an undefined var/env or unset attr should evaluate to 0 but should there also be another bracket function like defined(var(--x))? Should non-numbery variables always cause evaluating to 0 or should if(var(--state) = paused) be allowed?

For defined purely numeric cases, if() can be emulated with inventive combinations of arithmetic and min, max, clamp, abs, sign, round, mod, and rem but it gets fairly nasty even for relatively simple cases and even if you follow what's going on it lacks readability.

3455 was a previous attempt at an if(). It was deemed too powerful. It was different from the current proposal in two ways:

  1. it allowed the condition to refer to properties of the element (current width, etc.)
  2. it was a ternary operator returning arbitrary values

I suspect that 1 is the entirety of the argument for it being too powerful, though I am unsure. If this is case, this proposal can be extended by dropping the requirement that these must be in calc() and giving them optional parameters so that

if(p) - returns 1 or 0
if(p, then) - returns then or 0
if(p, then, else) - returns then or else

And possibly similarly for media and supports, though that could require disallowing "," within the media query itself. Although note that if nonzero is considered true then you could leave media() and supports() unchanged and use if(media(q), then, else).

Loirooriol commented 4 years ago

Desugarings of if(cond, then, else) in terms of sign():

(note abs(A) = A * sign(A) and max(sign(A), 0) = sign(A) * (1 + sign(A)) / 2).

The problem are comparisons with NaN or infinity.

Crissov commented 4 years ago

Perhaps, CSS would be better suited with a generic compare() function that compared its first two parameters and returned one of the others accordingly, or a set of three slightly more specific functions, equal(), lesser() and greater():

if(A < B, C, D) = lesser(A, B, C, D) =
if(B > A, C, D) = greater(B, A, C, D) =
if(A >= B, D, C) = 
if(B =< A, D, C) = 
compare(A, B, C, D, D) = 
compare(B, A, D, C, D)

if(A >= B, C, D) = 
if(B =< A, C, D) = 
if(A < B, D, C) = lesser(A, B, D, C) =
if(B > A, D, C) = greater(B, A, D, C) =
compare(A, B, D, C, C) = 
compare(B, A, C, D, C)

if(A > B, C, D) = greater(A, B, C, D) =
if(B < A, C, D) = lesser(B, A, C, D) =
if(A =< B, D, C) = 
if(B >= A, D, C) = 
compare(A, B, D, C, D) = 
compare(B, A, C, D, D)

if(A =< B, C, D) = 
if(B >= A, C, D) = 
if(A > B, D, C) = greater(A, B, D, C) =
if(B < A, D, C) = lesser(B, A, D, C) =
compare(A, B, C, D, C) = 
compare(B, A, D, C, C)

if(A != B, C, D) = compare(A, B, C, D) = 
if(B != A, C, D) = compare(B, A, C, D) =
if(A == B, D, C) = equal(A, B, D, C) =
if(B == A, D, C) = equal(A, B, D, C)

if(A == B, C, D) = equal(A, B, C, D) =
if(B == A, C, D) = equal(B, A, C, D) =
if(A != B, D, C) = compare(A, B, D, C) = 
if(B != A, D, C) = compare(B, A, D, C)

(The order and fallback of the comparison function parameters could probably be optimized more.)

jimmyfrasche commented 4 years ago

Considering that there are already tokens for stuff like < and or (though not one for ≠ that I could find), I don't see much of a savings by excluding those and the readability isn't great, especially if the third argument means different things depending on the total number of arguments.

With abs/sign, as demonstrated above, you can already do most everything proposed here with css as specced, so this is about making it easier to do those things (and thus harder to accidentally do it the wrong way), making it clearer when you do those things, and possibly adding in a little extra that can't be done yet—or at least polishing the edge cases so they're not as sharp.

Crissov commented 4 years ago

Iʼm not particularly fond of compare() either. I like the set of three functions better.

If only the first parameter was mandatory, the second could default to zero, the third to the first and the fourth to the second.

jimmyfrasche commented 4 years ago

So if you have X and you want to add Y when A < B && B >= C || D != E with those three functions one way to write it would be

X + Y * lesser(A, B, 1, 0) * sign(
      sign(greater(B, C, 1, 0) + equal(B, C, 1, 0))
    + calc(1 - equal(D, E, 1, 0))
)

With if() it's a bit more straightforward

X + Y * if(A < B and B >= C or D != E)

(I'm assuming that and has greater precedence than or.)

If I messed something up but that just proves my point that it's too difficult :smile:

Loirooriol commented 4 years ago

I propose three mathematical functions inspired by Iverson bracket notation — if(p), media(q), supports(s)

If we want boolean functions, I think it may be clearer if we consider a new type

<bool> = true | false

This wouldn't be a numeric value (I guess it could be a CSSKeywordValue, but I don't know much about Typed OM), so things like <bool> + <bool> or min(<bool>, <bool>) wouldn't be valid.

A media(q) or supports(s) would have type <bool>.

Operators like <, !=, && would attempt to add the types of the left and right arguments. If this returns failure, the entire calculation’s type would be failure. Otherwise, the returned type would be <bool>

Then, if(<bool>, then?, else?) would attempt to add the types of the then and else arguments. If this returns failure, the entire calculation’s type would be failure. Otherwise, the sub-expression’s type would be the returned type. If omitted, then would default to 1, and else would default to a numeric value 0 with the same type as then.

So your example would become

:root {
  --dur: if(media(prefers-reduced-motion), 5s);
}

This makes it more clear that we want to set 5s conditionally, not set some random multiple of 5s.

Probably, if(<bool>, then, else) should also accept then and else to be <bool>, and then return <bool>:

if(if(A > B, C > D, E > F), 10px, 20px);
/* same as */
if((A > B && C > D) || (!(A > B) && E > F), 10px, 20px);
jimmyfrasche commented 4 years ago

So you couldn't do width: if(some-predicate, 500px, auto)?

Loirooriol commented 4 years ago

I don't know. Opening the door to that may introduce some problems.

For example, justify-self: stretch only works if width computes to auto. But what if you have something like width: if(1% < 0px, 500px, auto)? The percentage can't be resolved at computed-value time, so you can't simplify the conditional as either 500px or auto.

IMO it would seem unexpected if stretching is allowed with auto but not with if(1% < 0px, 500px, auto), if the condition ends up being false.

So we should probably change css-align to accomodate conditionals that end up resolving to auto. But the terminology may get difficult. We can't say that the used value is auto, because the used value is a length. And there is already enough confusion with "behaves as auto".

jimmyfrasche commented 4 years ago

would that also create problems with using % in a conditional for setting width in something whose parent is intrinsically sized? I'm not sure when all that stuff gets worked out.

Loirooriol commented 4 years ago

I would expect this to be handled by https://drafts.csswg.org/css-sizing/#percentage-sizing. When calculating intrinsic contributions, a cyclic percentage may be treated as zero or the whole expression may be treated as initial.

jimmyfrasche commented 4 years ago

Thanks for the link.

Are keywords in general dangerous? Could width: if(p, auto, inherit) also cause problems?

I'm fine with it being purely numeric but it would be nice if you could write, say, visibility: if(P, visible, hidden); especially for having things happen based on aria- attributes to make it easier to keep js and css in sync

Loirooriol commented 4 years ago

Note that inherit is resolved very early in the cascade, when calculating specified values. But the condition may depend on layout information that we don't have at that time. Maybe an inherit-like feature that resolves later could be added, but it doesn't exist right now.

And yes, keywords seem more problematic, e.g. display: if(1% > 1px, none, block). Here it's not just a cyclic percentage during sizing, it directly affects the box tree. It's not as easy as saying that the percentage is treated in some way during intrinsic sizing, and then it's honored or not during actual layout. Because the existence or non-existence of boxes can have wider implications.

Possibly we could add some constraints, like restricting the properties that accept if() (would be invalid in display anyways if keywords are not accepted). There have been some ideas in this direction for container queries. But going this way makes if() more fundamentally different than math functions, and thus less likely to become a reality soon.

Crissov commented 4 years ago

So if you have X and you want to add Y when A < B && B >= C || D != E with those three functions one way to write it would be

calc(X + lesser(A, B, greater(C, B, equal(D, E, 0, Y), Y), 0)) 

Is that an example from a realistic use case or just a strawman?

Actually, another possibility would be some kind of ternary operators inside calc():

calc(X + (A < B && B >= C || D != E) ? Y : 0) 

calc(X + (A < B and B >= C or D != E) ? Y : 0) 

calc(X + if A < B and B >= C or D != E then Y else 0) 

calc(X + if A lt B and B ge C or D ne E then Y else 0) 

calc(X + (and(A < B, or(B >= C, D != E))) ? Y : 0)

calc(X + Y when(A < B and B >= C or D != E) 0 otherwise) 

calc(X + 0 unless A < B and B >= C or D != E then Y) 

Some of these are of course very unfamiliar from outside CSS (especially from JS), but they could be appropriate nevertheless.

jimmyfrasche commented 4 years ago

I don't expect much day-to-day code to have as complicated a condition, no.

Logical conditions would be useful in real code for stuff like if(media(something) or var(--force)). I expect them to be relatively common uses of if().

I expect they'd get the most use in css frameworks that define a lot of stuff with custom properties that act as knobs.

At some point, I'd hope that we could define reusable custom calc functions directly in css and I imagine libraries of such functions could involve somewhat complicated conditionals. Cf. my comment: https://github.com/w3c/css-houdini-drafts/issues/857#issuecomment-579084136

jimmyfrasche commented 4 years ago

Does if(if(A > B, C > D, E > F), 10px, 20px) mean that the logical and comparative operators need to be added to calc expressions and that if() is essentially a handy way of converting boolean-typed calc expressions into non-bools? Otherwise, it would need to be written if(if(A > B, if(C > D, true, false), if(E > F, true, false)), 10px, 20px) (or if(E > F, true) if false is the zero value of bool).

Does if(p) then only accept bool for p? Do you need to write if(var(--v) != 0)?

If bool is added, there are a lot of attributes that are either "true"/"false" or have bool-ish definitions and it would be handy to be able to do things like if(attr(aria-pressed bool)).

Loirooriol commented 4 years ago

the logical and comparative operators need to be added to calc expressions

Kind of, but they would return a boolean, which probably most math functions (like calc()) shouldn't accept as an argument. So "calc expressions" may not be the right term.

if() is essentially a handy way of converting boolean-typed calc expressions into non-bools? Does if(p) then only accept bool for p?

Yes, that's what I was thinking.

If bool is added, there are a lot of attributes

I wonder whether some string operations could be useful too, like checking if an attribute value starts, ends or contains a substring.

jimmyfrasche commented 4 years ago

If bools are an ordinary type (not special syntax used in if()), I don't see why calc(A < B) shouldn't return a bool. It may not be an exceptionally useful thing to do on its own, but it can be used to parenthesize expressions within if(), and it could later be used like --custom-houdini-func(A < B). There are certainly cases where using a bool wouldn't make sense like in width: but string doesn't make sense there and they can justly fail.

For strings, to keep things consistent, I guess ^= and $= and friends from the [] selector could be used, though that might be a bit hairy fitting it in the grammar with *= and the i/s modifiers. Additional string functions could be added later to make it and other things more convenient.

tabatkins commented 4 years ago

I do suspect we might add a bool type at some point for these kinds of purposes, yeah.

I'm inclined to currently put this on the side of "make sure that custom functions can handle this case so --if() is possible.

jimmyfrasche commented 4 years ago

If everything is in place except if() wouldn't the custom func just be something like registerCustomFunc("--if", (p, t, f) => p ? t : f);? (Ignoring the specifics of how funcs are registered and what additional metadata needs to be provided, etc.).

tabatkins commented 4 years ago

More or less, yeah.

jimmyfrasche commented 4 years ago

Is there much use for the bool type if you can't do conditionals (without js)?