diff --git a/changelog.md b/changelog.md index 4dd869b2..29b7f987 100644 --- a/changelog.md +++ b/changelog.md @@ -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" diff --git a/frontend/code/cssUtils.ts b/frontend/code/cssUtils.ts index 853e7ddc..9e786d6f 100644 --- a/frontend/code/cssUtils.ts +++ b/frontend/code/cssUtils.ts @@ -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 diff --git a/rio/components/file_picker_area.py b/rio/components/file_picker_area.py index 66a26dcd..0a71ddbb 100644 --- a/rio/components/file_picker_area.py +++ b/rio/components/file_picker_area.py @@ -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 } ) diff --git a/rio/fills.py b/rio/fills.py index a5a299ec..e599dd06 100644 --- a/rio/fills.py +++ b/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, ) diff --git a/rio/session.py b/rio/session.py index ff92e7a0..2f04c0ab 100644 --- a/rio/session.py +++ b/rio/session.py @@ -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 ) ) diff --git a/rio/snippets/snippet-files/project-template-AI Chatbot/components/empty_chat_placeholder.py b/rio/snippets/snippet-files/project-template-AI Chatbot/components/empty_chat_placeholder.py index 0abe281a..dd8444c7 100644 --- a/rio/snippets/snippet-files/project-template-AI Chatbot/components/empty_chat_placeholder.py +++ b/rio/snippets/snippet-files/project-template-AI Chatbot/components/empty_chat_placeholder.py @@ -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, ), ), ), diff --git a/rio/utils.py b/rio/utils.py index cd99b1b1..566077ae 100644 --- a/rio/utils.py +++ b/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