diff --git a/frontend/code/components/slider.ts b/frontend/code/components/slider.ts index 86c46f27..f21e8752 100644 --- a/frontend/code/components/slider.ts +++ b/frontend/code/components/slider.ts @@ -54,19 +54,25 @@ export class SliderComponent extends ComponentBase { return this.state.value; } - // Calculate the value as a fraction of the track width + // Calculate the selected value from the event coordinates let rect = this.innerElement.getBoundingClientRect(); let fraction = (event.clientX - rect.left) / rect.width; fraction = Math.max(0, Math.min(1, fraction)); - // Enforce the step size let valueRange = this.state.maximum - this.state.minimum; + let value = fraction * valueRange + this.state.minimum; + // Enforce the step size + // + // Converting to a value, and back to fractions may seem convoluted, but + // this ensures that the step size is enforced correctly. Careless math + // can lead to floating point errors that cause the reported value to be + // off by a little (e.g. 7.99999999 instead of 8). if (this.state.step !== 0) { - let normalizedStepSize = this.state.step / valueRange; + let stepIndex = Math.round(value / this.state.step); + value = stepIndex * this.state.step; - fraction = - Math.round(fraction / normalizedStepSize) * normalizedStepSize; + fraction = (value - this.state.minimum) / valueRange; } // Move the knob @@ -76,7 +82,7 @@ export class SliderComponent extends ComponentBase { ); // Return the new value - return fraction * valueRange + this.state.minimum; + return value; } private onDragStart(event: MouseEvent): boolean { diff --git a/frontend/code/components/textInput.ts b/frontend/code/components/textInput.ts index 805363bb..4e010a2f 100644 --- a/frontend/code/components/textInput.ts +++ b/frontend/code/components/textInput.ts @@ -82,11 +82,15 @@ export class TextInputComponent extends ComponentBase { // date value. this.inputElement.addEventListener('keydown', (event) => { if (event.key === 'Enter') { + // Update the state this.state.text = this.inputElement.value; - this.onChangeLimiter.call(this.state.text); - this.onChangeLimiter.flush(); + // There is no need for the debouncer to report this call, since + // Python will already trigger both change & confirm events when + // it receives the message that is about to be sent. + this.onChangeLimiter.clear(); + // Inform the backend this.sendMessageToBackend({ text: this.state.text, }); diff --git a/frontend/code/debouncer.ts b/frontend/code/debouncer.ts index 375621bd..279b375c 100644 --- a/frontend/code/debouncer.ts +++ b/frontend/code/debouncer.ts @@ -119,4 +119,15 @@ export class Debouncer { this.mostRecentPerformedCall = Date.now(); this.pendingArguments = null; } + + /// Clears any pending calls, ensuring that the debouncer will not call the + /// function in the future unless `call` is invoked again. + public clear(): void { + this.pendingArguments = null; + + if (this.timeout !== null) { + clearTimeout(this.timeout); + this.timeout = null; + } + } } diff --git a/rio/components/text_input.py b/rio/components/text_input.py index 09b1d84a..ef5df75c 100644 --- a/rio/components/text_input.py +++ b/rio/components/text_input.py @@ -187,11 +187,13 @@ class TextInput(KeyboardFocusableFundamentalComponent): self._apply_delta_state_from_frontend({"text": msg["text"]}) + # Trigger both the change event... await self.call_event_handler( self.on_change, TextInputChangeEvent(self.text), ) + # And the confirm event await self.call_event_handler( self.on_confirm, TextInputConfirmEvent(self.text), diff --git a/rio/debug/dev_tools/layout_explainer.py b/rio/debug/dev_tools/layout_explainer.py index 13dc533d..1d319db3 100644 --- a/rio/debug/dev_tools/layout_explainer.py +++ b/rio/debug/dev_tools/layout_explainer.py @@ -17,7 +17,33 @@ import rio.data_models # - revealer # - scroll_container # - scroll_target -# - slideshow + + +# These components pass on the entirety of the available space to their +# children +FULL_SIZE_SINGLE_CONTAINERS: set[type[rio.Component]] = { + rio.Button, + rio.Card, + rio.Container, + rio.CustomListItem, + rio.KeyEventListener, + rio.Link, + rio.MouseEventListener, + rio.PageView, + rio.Rectangle, + rio.Slideshow, + rio.Stack, + rio.Switcher, +} + + +# These components make use of the `"grow"` value with `width`, `height` or +# both. +CONTAINERS_SUPPORTING_GROW: Iterable[type[rio.Component]] = { + rio.Column, + rio.Grid, + rio.Row, +} class LayoutExplainer: @@ -124,9 +150,11 @@ class LayoutExplainer: if axis == "width": parent_allocated_space = self._layout.parent_allocated_width parent_natural_size = self._layout.parent_natural_width + specified_size = self.component.width else: parent_allocated_space = self._layout.parent_allocated_height parent_natural_size = self._layout.parent_natural_height + specified_size = self.component.height parent_class_name = type(parent).__name__ @@ -135,19 +163,7 @@ class LayoutExplainer: return f"This is the app's top-level component. As such, the {target_class_name} was allocated the full a {axis} of {allocated_space_before_alignment:.1f} available in the window." # Single container? - if type(parent) in ( - rio.Button, - rio.Card, - rio.Container, - rio.CustomListItem, - rio.KeyEventListener, - rio.MouseEventListener, - rio.Link, - rio.PageView, - rio.Rectangle, - rio.Stack, - rio.Switcher, - ): + if type(parent) in FULL_SIZE_SINGLE_CONTAINERS: return f"Because `{parent_class_name}` components pass on all available space to their children, the component's {axis} is the full {allocated_space_before_alignment:.1f} units available in its parent." # Overlay @@ -163,6 +179,11 @@ class LayoutExplainer: or isinstance(parent, rio.Column) and axis == "width" ): + if specified_size == "grow": + self.warnings.append( + f'The component has `{axis}="grow"` set, but it is placed inside of a `{parent_class_name}`. Because {parent_class_name}s pass on the entire available space in this direction to all children it has no effect.' + ) + return f"The component is placed inside of a {parent_class_name}. Since all children of {parent_class_name}s receive the full {axis}, it has received the entire {allocated_space_before_alignment:.1f} units available in its parent." # Major axis @@ -236,6 +257,11 @@ class LayoutExplainer: allocated the space it was, in a single direction. """ # Prepare some values based on axis + try: + parent = self.session._weak_components_by_id[self._layout.parent_id] + except KeyError: + parent = None + if axis == "width": allocated_space = self._layout.allocated_width allocated_space_before_alignment = ( @@ -269,6 +295,17 @@ class LayoutExplainer: target_class_name = type(self.component).__name__ + # Warn if the component has a `grow` attribute set, but is placed inside + # of a container that doesn't support it + if ( + specified_size == "grow" + and parent is not None + and type(parent) not in CONTAINERS_SUPPORTING_GROW + ): + self.warnings.append( + f'The component has `{axis}="grow"` set, but it is placed inside of a `{type(parent).__name__}`. {type(parent).__name__} components can not make use of this property, so it has no effect.' + ) + # How much space did the parent hand down? result = io.StringIO() result.write(