easier to user gradient fills + file type normalization

This commit is contained in:
Jakob Pinterits
2025-01-14 15:36:18 +01:00
parent dbb2f842c2
commit 81131464e2
7 changed files with 158 additions and 56 deletions

View File

@@ -18,6 +18,9 @@
- `rio.Session.scroll_bar_size`
- `rio.Session.primary_pointer_type`
- Gradient stops can now be specified just as colors and Rio will infer their
position
## ???
- New styles for input boxes: "rounded" and "pill"

View File

@@ -36,12 +36,12 @@ export function fillToCss(fill: AnyFill): {
break;
// Linear Gradient
//
// Note: Python already ensures that there are at least two stops, and
// that the first one is at 0 and the last one is at 1. No need to
// verify any of that here.
case "linearGradient":
if (fill.stops.length === 1) {
background = colorToCssString(fill.stops[0][0]);
} else {
background = gradientToCssString(fill.angleDegrees, fill.stops);
}
background = gradientToCssString(fill.angleDegrees, fill.stops);
break;
// Image

View File

@@ -202,7 +202,7 @@ class FilePickerArea(FundamentalComponent):
if self.file_types is not None:
result["file_types"] = list(
{
utils.normalize_file_type(file_type)
utils.normalize_file_extension(file_type)
for file_type in self.file_types
}
)

View File

@@ -9,7 +9,7 @@ from uniserde import Jsonable
import rio
from . import assets, deprecations
from . import assets, deprecations, utils
from .color import Color
from .self_serializing import SelfSerializing
from .utils import ImageLike
@@ -101,16 +101,12 @@ class LinearGradientFill(Fill):
def __init__(
self,
*stops: tuple[Color, float],
*stops: rio.Color | tuple[rio.Color, float],
angle_degrees: float = 0.0,
) -> None:
# Make sure there's at least one stop
if not stops:
raise ValueError("Gradients must have at least 1 stop")
# Sort and store the stops
# Postprocess & store the stops
vars(self).update(
stops=tuple(sorted(stops, key=lambda x: x[1])),
stops=utils.verify_and_interpolate_gradient_stops(stops),
angle_degrees=angle_degrees,
)

View File

@@ -2207,7 +2207,7 @@ window.history.{method}(null, "", {json.dumps(relative_url)})
# Normalize and deduplicate, but maintain the order
file_types = list(
ordered_set.OrderedSet(
utils.normalize_file_type(file_type)
utils.normalize_file_extension(file_type)
for file_type in file_types
)
)

View File

@@ -46,8 +46,8 @@ class EmptyChatPlaceholder(rio.Component):
font_size=5,
font_weight="bold",
fill=rio.LinearGradientFill(
(self.session.theme.secondary_color, 0),
(self.session.theme.primary_color, 1),
self.session.theme.secondary_color,
self.session.theme.primary_color,
),
),
),

View File

@@ -20,6 +20,8 @@ from yarl import URL
import rio
from . import deprecations
__all__ = [
"EventHandler",
"FileInfo",
@@ -675,17 +677,17 @@ def is_python_script(path: Path) -> bool:
return path.suffix in (".py", ".pyc", ".pyd", ".pyo", ".pyw")
def normalize_file_type(file_type: str) -> str:
def normalize_file_extension(suffix: str) -> str:
"""
Converts different file type formats into a common one.
Brings different notations for file extensions into the same form.
This function takes various formats of file types, such as file extensions
(e.g., ".pdf", "PDF", "*.pdf") or MIME types (e.g., "application/pdf"), and
converts them into a standardized file extension. The result is always
lowercase and without any leading dots or wildcard characters.
This function takes various formats of file extension and converts them into
a standardized one. The result is always lowercase and without any leading
dots or wildcard characters.
For historical reasons, MIME types are also accepted, but will raise a
deprecation warning.
This is best-effort. If the input type is invalid or unknown, the cleaned
input may not be accurate.
## Examples
@@ -696,45 +698,29 @@ def normalize_file_type(file_type: str) -> str:
"pdf"
>>> standardize_file_type("*.pdf")
"pdf"
>>> standardize_file_type("application/pdf")
>>> standardize_file_type("application/pdf") # Deprecated
"pdf"
```
"""
# Normalize the input string
file_type = file_type.lower().strip()
# Perform some basic normalization
suffix = suffix.lower().strip()
# If this is a MIME type, guess the extension
if "/" in file_type:
guessed_suffix = mimetypes.guess_extension(file_type, strict=False)
if guessed_suffix is None:
file_type = file_type.rsplit("/", 1)[-1]
else:
file_type = guessed_suffix.lstrip(".")
# If it isn't a MIME type, convert it to one anyway. Some file types have
# multiple commonly used extensions. This will always map them to the same
# one. For example "jpeg" and "jpg" are both mapped to "jpg".
else:
guessed_type, _ = mimetypes.guess_type(
f"file.{file_type}", strict=False
# If this is a mime type, run old-school logic
if "/" in suffix:
deprecations.warn(
"MIME types are no longer accepted as allowed file types. Please use file extension instead (e.g. `.pdf` instead of `application/pdf`).",
since="0.10.10",
)
file_type = file_type.lstrip(".*")
guessed_suffix = mimetypes.guess_extension(suffix, strict=False)
if guessed_type is not None:
guessed_type = mimetypes.guess_extension(
guessed_type,
strict=False,
)
if guessed_suffix is None:
return suffix.rsplit("/", 1)[-1]
else:
return guessed_suffix.lstrip(".")
# Yes, this really happens on some systems. For some reason, we can
# get the type for a suffix, but not the suffix for the same type.
if guessed_type is not None:
file_type = guessed_type.lstrip(".")
# Done
return file_type
# Remove any leading dots or wildcards
return suffix.lstrip(".*")
def soft_sort(
@@ -801,3 +787,120 @@ def soft_sort(
for element, _, _ in keyed_elements:
elements.append(element)
def verify_and_interpolate_gradient_stops(
raw_stops: t.Iterable[rio.Color | tuple[rio.Color, float]],
) -> list[tuple[rio.Color, float]]:
"""
Gradient stops can be provided either as just colors, or colors, or colors
with a specific position in the gradient. This function normalizes the input
so that every stop has both color and position.
- The first and last stop - if not specified - will have positions of 0 and
1 respectively.
- All other missing positions will be linearly interpolated
This also makes sure that:
- there is at least one stop
- no explicit stop position is outside the range [0, 1]
- all explicitly provided stops are ascending
Finally, the result is guaranteed to have one stop at position 0 and one at
position 1.
## Raises
`ValueError`: If any of the above conditions are not met.
"""
# Make sure we can index into the iterable and iterate over it as often as
# we'd like
stops = list(raw_stops)
# Make sure we got at least one stop
if not stops:
raise ValueError("Gradients must have at least one stop")
# Inserting stops shifts the indices relative to what the user expects.
# Account for that.
user_index_shift = 0
# Make sure there is a stop at position 0
if not isinstance(stops[0], tuple):
first_stop: rio.Color = stops.pop(0) # type: ignore
stops.insert(0, (first_stop, 0.0))
elif stops[0][1] != 0.0:
stops.insert(0, (stops[0][0], 0.0))
user_index_shift = -1
# Make sure there is a stop at position 1
if not isinstance(stops[-1], tuple):
last_stop: rio.Color = stops.pop() # type: ignore
stops.append((last_stop, 1.0))
elif stops[-1][1] != 1.0:
stops.append((stops[-1][0], 1.0))
# Stop 0 needs a separate position check because the loop below only checks
# the right hand side of each stop range
first_stop_pos = stops[0][1] # type: ignore
if not 0 <= first_stop_pos <= 1: # type: ignore
raise ValueError(
f"Gradient stop positions must be in range [0, 1], but stop 0 is at position {first_stop_pos}"
)
# Walk the stops, interpolating positions as needed
prev_positioned_index: int = 0
def interpolate_stops_and_verify_ascending_position(
left_positioned_ii: int,
right_positioned_ii: int,
) -> None:
assert right_positioned_ii > left_positioned_ii
left_pos = stops[left_positioned_ii][1] # type: ignore
right_pos = stops[right_positioned_ii][1] # type: ignore
# Make sure the positions are ascending
if left_pos > right_pos:
raise ValueError(
f"Gradient stops must be in ascending order, but stop {left_positioned_ii+user_index_shift} is at position {left_pos} while stop {right_positioned_ii+user_index_shift} is at position {right_pos}"
)
# Interpolate all latent stops
step_size = (right_pos - left_pos) / (
right_positioned_ii - left_positioned_ii
)
cur_pos = left_pos + step_size
for ii in range(left_positioned_ii + 1, right_positioned_ii):
color: rio.Color = stops[ii] # type: ignore
stops[ii] = (color, cur_pos)
cur_pos += step_size
for ii in range(1, len(stops)):
# Get the current stop
stop = stops[ii]
# If this stop is not positioned, skip it for now
if not isinstance(stop, tuple):
continue
# It is positioned
#
# Make sure it's in range [0, 1]
if not 0 <= stop[1] <= 1:
raise ValueError(
f"Gradient stop positions must be in range [0, 1], but stop {ii+user_index_shift} is at position {stop[1]}"
)
# interpolate the latent stops
interpolate_stops_and_verify_ascending_position(
prev_positioned_index, ii
)
# This is the new anchor
prev_positioned_index = ii
# Done
return stops # type: ignore