mirror of
https://github.com/rio-labs/rio.git
synced 2026-01-07 05:39:47 -06:00
easier to user gradient fills + file type normalization
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
12
rio/fills.py
12
rio/fills.py
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
181
rio/utils.py
181
rio/utils.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user