Skip to content

feat: implement unified IFormattable on Color, HslColor, and HsvColor#20919

Open
NathanDrake2406 wants to merge 3 commits intoAvaloniaUI:masterfrom
NathanDrake2406:feat/color-tostring-formats
Open

feat: implement unified IFormattable on Color, HslColor, and HsvColor#20919
NathanDrake2406 wants to merge 3 commits intoAvaloniaUI:masterfrom
NathanDrake2406:feat/color-tostring-formats

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Mar 17, 2026

Summary

Implements IFormattable on all three color types (Color, HslColor, HsvColor) with a unified set of format specifiers — any type can output any format via auto-conversion, following the DateTime analogy discussed in review.

Also fixes a pre-existing bug: HslColor.ToString() was outputting hsva( instead of hsla(.

Closes #18725

Design

Convention: uppercase = include alpha (rgba/hsla/hsva prefix), lowercase = exclude alpha (rgb/hsl/hsv prefix). % suffix = percent mode.

Format Output Example
null/"" Type-specific default Red, hsla(230, 1, 0.5, 1)
"X" XAML hex with alpha #FFFF0000
"x" Hex without alpha #FF0000
"H" HTML hex with alpha #FF0000FF
"R" CSS rgba absolute rgba(255, 0, 0, 1.00)
"r" CSS rgb absolute rgb(255, 0, 0)
"R%" CSS rgba percent rgba(100%, 0%, 0%, 100%)
"r%" CSS rgb percent rgb(100%, 0%, 0%)
"L" CSS hsla hsla(0, 100%, 50%, 1.00)
"l" CSS hsl hsl(0, 100%, 50%)
"L%" HSL all percent hsla(0%, 100%, 50%, 100%)
"l%" HSL all percent hsl(0%, 100%, 50%)
"V" CSS hsva hsva(0, 100%, 100%, 1.00)
"v" CSS hsv hsv(0, 100%, 100%)
"V%" HSV all percent hsva(0%, 100%, 100%, 100%)
"v%" HSV all percent hsv(0%, 100%, 100%)

"C" and "A" are reserved for future complex format strings (hsv:C1,C2,C3,A).

Architecture

Each type only implements its native format natively and delegates cross-model formats via conversion:

  • Color handles hex + RGB natively; delegates L/VToHsl()/ToHsv()
  • HslColor handles HSL natively; delegates hex/RGB → ToRgb(), HSV → ToHsv()
  • HsvColor handles HSV natively; delegates hex/RGB → ToRgb(), HSL → ToHsl()

Zero duplicated formatting logic. Every delegation terminates in one hop.

Breaking changes from prior iteration

  • "h" dropped (identical to "x")
  • "C"/"c" removed, reserved for future use
  • "P"/"p" replaced by "R%"/"r%"
  • Uppercase specifiers now use rgba()/hsla()/hsva() prefix (CSS convention)

Test plan

  • 116 unit tests covering all specifiers on all types
  • Cross-type delegation verified (e.g. HslColor.ToString("X") → hex via RGB)
  • Reserved specifiers (C, c, A, a) throw FormatException
  • Removed specifiers (h, P, p) throw FormatException
  • IFormatProvider ignored (culture-invariant verified with fr-FR)
  • Default ToString() unchanged for backwards compatibility
  • hsla() bug fix verified (was hsva())

@MrJul MrJul added the feature label Mar 17, 2026
@avaloniaui-bot
Copy link
Copy Markdown

You can test this PR using the following package version. 12.0.999-cibuild0063532-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@robloo
Copy link
Copy Markdown
Contributor

robloo commented Mar 18, 2026

Nice! Couple questions:

  1. Where did these format codes come from? They do make sense but if there is any precident in the ecosystem we should try to take advantage of it.
  2. We need to support HslColor and HsvColor as well. This is where there could be issues with the format strings but we could establish "L/l" and "V/v" as the other formatting codes. Then again we can generalize all this using the "component" terminology. So having "C/c" for ALL THREE colors may make sense too.
  3. There are people who need to be able to add/remove the alpha channel. These formatting codes don't directly support that. (you do support both XAML and HTML formatting which is awesome though!)
    • Related you seem to arbitrarily include/exclude the alpha channel based on if it is opaque. That is a bad policy in general as it could break parsing downstream if devs are expecting stable/consistent formatting strings.
  4. Have you considered more advanced formatting strings like date/time where users can have full control? In light of the above maybe the simple format strings aren't best here and we should have complex strings for maximum control.
    • This means the ability to fully control which components are included and what the format of that component is (percent/absolute).

@NathanDrake2406 NathanDrake2406 force-pushed the feat/color-tostring-formats branch from bfef8c5 to 7adf64c Compare March 18, 2026 02:36
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Nice! Couple questions:

1. Where did these format codes come from? They do make sense but if there is any precident in the ecosystem we should try to take advantage of it.

2. We need to support `HslColor` and `HsvColor` as well. This is where there could be issues with the format strings but we could establish "L/l" and "V/v" as the other formatting codes. Then again we can generalize all this using the "component" terminology. So having "C/c" for ALL THREE colors may make sense too.

3. There are people who need to be able to add/remove the alpha channel. These formatting codes don't directly support that. (you do support both XAML and HTML formatting which is awesome though!)
   
   * Related you seem to arbitrarily include/exclude the alpha channel based on if it is opaque. That is a bad policy in general as it could break parsing downstream if devs are expecting stable/consistent formatting strings.

4. Have you considered more advanced formatting strings like date/time where users can have full control? In light of the above maybe the simple format strings aren't best here and we should have complex strings for maximum control.
   
   * This means the ability to fully control which components are included and what the format of that component is (percent/absolute).
  1. Unprecedented :)
  2. I'll put up a new issue for this since this PR is getting quite big
  3. Addressed! Thanks for the feedback, please review :)
  4. Will put up a new issue if you feel like it's needed

@avaloniaui-bot
Copy link
Copy Markdown

You can test this PR using the following package version. 12.0.999-cibuild0063581-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot
Copy link
Copy Markdown

You can test this PR using the following package version. 12.0.999-cibuild0063601-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@robloo
Copy link
Copy Markdown
Contributor

robloo commented Mar 18, 2026

I'll put up a new issue for this since this PR is getting quite big

This is a small PR in code and it's all a related discussion. Sometimes its useful to keep all discussions in one place ;)

Addressed! Thanks for the feedback, please review :)

Awesome! This should help developers and maintain consistency. More below about specifier codes.


There is another deeper conversation here. In .NET itself DateOnly, DateTimeOffset, DateTime, and 3rd party library types all represent a position on a calendar system. The calendar system is actually arbitrary and if you want you can output it as persian, gregorian, japanese etc. The fundamental information all these types represent is the same. When formatting you can specify the system as well as the format on EACH type. All types essentially support the same formatting.

For colors we have Color, HslColor and HsvColor. Again these 3 types are representing the same fundamental information (wavelength of light). The more I think about this I think we should take the approach that .NET uses for date/times. That means instead of different formatting for each type all formatting is supported by each type. The necessary "model"/"system" conversion is done automatically just like date/time.

So if you want to output an HslColor as HTML hex you can with one format specifier. The same format specifier code as Color.

This relates to #20928 (comment) (again, it's all the same discussion so I'm keeping it here in one spot).

What do you think about this line of thought?


As a spec this means ALL color types would have the exact same formatting codes and be treated exactly the same.

  • "null/""" -> Keeps type-specific formatting for backwards compatibility
    • Color : Red or #40ff8844
    • HslColor : `hsla(230, 1.0, 0.5, 1.0)`` This corrects the legacy bug...
    • HsvColor : hsva(230, 1.0, 0.5, 1.0)
  • "X" -> #AARRGGBB regardless of color type
  • "x" ->#123456 regardless of color type
  • "H" -> #RRGGBBAA regardless of color type
  • "h" -> This is now the same as "x". With no alpha channel there is no difference.

Things now expand pretty quickly when you are trying to support formatting of 1) all color models, 2) absolute/percent and 3) with/without alpha. The number of permutations expand quite a bit and for this reason I think we should go to two-letter format codes (which differs from your latest changes in code). Going to multiple letters keeps things readable and avoids having to come up with new letters like "P/p" that we might need in the future.

The format specifier is as follows:

  • "R" -> rgba(255, 255, 255, 255)
  • "R%" -> rgba(100%, 100%, 100%, 100%)
  • "r" -> rgb(255, 255, 255) without alpha
  • "r%" -> rgb(100%, 100%, 100%) without alpha

This can be expanded the same for HslColor (using "L") and HsvColor (using "V"). In the end:

  • Color model is defined by "R/r" for RGB, "L/l" for HSL and "V/v" for HSV
  • Absolute vs percent units is defined by with/without a "%" symbol
  • Capital letter means include alpha, lower-case letter means exclude alpha channel

Now I need to talk about the complex formatting codes. While at first it doesn't seem like it should be done now it's relevant for two reasons:

  1. If you don't account for it now you risk making it impossible to do in the future without breaking changes (which won't usually be acceptable)
  2. You may actually find it more elegantly handles all the cases we are talking about. It can handle the explosion of permutations better.

A full complex format code would be something like {model}:{components} where C1, C2, C3 are the component symbols for all color models and A is for alpha. This would allow something like:

  • hsv:C1,C2,C3,A -> "hsva(230, 1.0, 0.5, 1.0)`"
  • hsv:C1%,C2%,C3%,A% -> "hsva(50%, 50%, 50%, 50%)"
  • rgb:C1,C2,C3 -> "rgb(255, 255, 255)"

The obvious downside here is the complexity of parsing this grammar. It does support all cases though. However, if we keep this in mind and that we should reserve "C" and "A" for future use like this I think we are OK. The current design is future-proof.


Finally we have a few new issues:

  1. All your prefixes are just "hsl" or "hsv". However, the existing code with output hsva or hsla if there is an alpha component. I checked and we should keep doing this as it aligns to CSS: https://www.w3schools.com/css/css_colors_hsl.asp
  2. The existing code does have a bug with HslColor and it will output an hsva prefix when it should be hsla. Because that has to be fixed we are looking at a breaking change no matter what.
  3. "x" and "h" are now exactly the same. Do we still need both? Probably not.
  4. We don't support the standard CSS format like hsla(9, 100%, 64%, 0.2) out of the box. This is a mix of percent and absolute... we probably should support this.

This is blazing a brand-new trail so is getting a lot of thought typical of what you find in the .NET core lib but not necessarily Avalonia itself for this type of thing. Hopefully I'm not scaring you with the depth here.

Pre-existing copy-paste bug: HslColor.ToString() was outputting
"hsva(" instead of "hsla(" for its default format.
All three color types now support all format specifiers via
auto-conversion, following the DateTime analogy where the same
format codes work regardless of source type.

Format specifiers:
  Hex:  X (#AARRGGBB), x (#RRGGBB), H (#RRGGBBAA)
  RGB:  R/r (absolute), R%/r% (percent)
  HSL:  L/l (CSS standard), L%/l% (all percent)
  HSV:  V/v (CSS standard), V%/v% (all percent)

Convention: uppercase = include alpha (rgba/hsla/hsva prefix),
lowercase = exclude alpha (rgb/hsl/hsv prefix).

Each type handles its native model natively and delegates
cross-model formats via ToRgb()/ToHsl()/ToHsv().

Breaking changes from prior PR iteration:
- "h" dropped (identical to "x" without alpha)
- "C"/"c" removed, reserved for future complex format strings
- "P"/"p" replaced by "R%"/"r%"
- "R" now outputs rgba() not rgb() (CSS convention)
- "L" now outputs hsla() not hsl() (CSS convention)
- "V" now outputs hsva() not hsv() (CSS convention)
@NathanDrake2406 NathanDrake2406 force-pushed the feat/color-tostring-formats branch from 744d0d0 to 0b0dd03 Compare March 18, 2026 18:10
@NathanDrake2406 NathanDrake2406 changed the title Implement IFormattable on Color for configurable ToString formats feat: implement unified IFormattable on Color, HslColor, and HsvColor Mar 18, 2026
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@robloo

@avaloniaui-bot
Copy link
Copy Markdown

You can test this PR using the following package version. 12.0.999-cibuild0063723-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@MrJul @robloo hey guys is this PR gonna die a slow death? :(

@robloo
Copy link
Copy Markdown
Contributor

robloo commented Apr 1, 2026

@NathanDrake2406 I don't think this is doing to die at all:

  1. This is useful to have in the framework
  2. It's brand-new functionality so keeping the PR open for a bit allows other developers to find it and add their feedback. That may improve the implementation.
  3. It isn't critical for 12.0 which the team is entirely focusing on right now.

Separately, I have to go back and see what changes you made. Note that just saying "updated" or similar slows down the review. I spent a lot of time on my feedback and having to go back through to figure out what exactly you changed takes additional time I don't always have. I do want to understand the decisions you made around CSS formatting. It's probably the right decision to always default to CSS-compliant formats; but that does deviate from pure absolute/percent formats we originally discussed above.

Each type only implements its native format natively and delegates cross-model formats via conversion:
Zero duplicated formatting logic. Every delegation terminates in one hop.

I'm glad you kept this architecture. It aligns with the original design and simplifies the code quite a bit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Color ToString formats

4 participants