Lots of owork on a calculator example, transparency, etc.

This commit is contained in:
2026-05-16 16:18:55 -04:00
parent e2c2563b7e
commit 6dcc2d0d00
40 changed files with 5783 additions and 125 deletions

View File

@@ -14,7 +14,9 @@ use ruin_ui::{
use ruin_ui_platform_wayland::start_wayland_ui;
use crate::Result;
use crate::context::{RenderState, render_with_context};
use crate::context::{
InteractionSnapshot, RenderState, render_with_context, render_with_context_and_interaction,
};
use crate::input::{
EventBindings, InputState, ShortcutBinding, TextSelection, TextSelectionDrag,
TextSelectionState, apply_text_selection_overlay, focused_element_for_pointer,
@@ -58,6 +60,11 @@ impl Window {
self.spec = self.spec.base_color(color);
self
}
pub fn resizable(mut self, resizable: bool) -> Self {
self.spec.resizable = resizable;
self
}
}
impl Default for Window {
@@ -160,7 +167,10 @@ impl<M: Mountable> MountedApp<M> {
let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new()));
let current_title = Rc::new(RefCell::new(None::<String>));
let last_min_size = Rc::new(RefCell::new(None::<UiSize>));
let last_fixed_size = Rc::new(RefCell::new(None::<UiSize>));
let explicit_min_size = app_window.spec.min_inner_size;
let explicit_max_size = app_window.spec.max_inner_size;
let window_resizable = app_window.spec.resizable;
let mut input_state = InputState::new();
let mut pointer_router = PointerRouter::new();
@@ -174,18 +184,31 @@ impl<M: Mountable> MountedApp<M> {
let shortcuts = Rc::clone(&shortcuts);
let current_title = Rc::clone(&current_title);
let last_min_size = Rc::clone(&last_min_size);
let last_fixed_size = Rc::clone(&last_fixed_size);
let root = Rc::clone(&root);
let render_state = Rc::clone(&render_state);
let text_selection = Rc::clone(&input_state.text_selection);
let hovered_elements = input_state.hovered_elements.clone();
let pressed_elements = input_state.pressed_elements.clone();
let interaction_version = input_state.interaction_version.clone();
move || {
let viewport = viewport.get();
let version = scene_version.get().wrapping_add(1);
scene_version.set(version);
let _ = text_selection.version.get();
let _ = interaction_version.get();
let t_effect = Instant::now();
let interaction_snapshot = Rc::new(InteractionSnapshot {
hovered: hovered_elements.get(),
pressed: pressed_elements.get(),
});
let render_output = render_with_context(Rc::clone(&render_state), || root.render());
let render_output = render_with_context_and_interaction(
Rc::clone(&render_state),
interaction_snapshot,
|| root.render(),
);
if render_output.side_effects.window_title != *current_title.borrow() {
if let Some(title) = &render_output.side_effects.window_title {
@@ -211,7 +234,6 @@ impl<M: Mountable> MountedApp<M> {
);
let layout_us = t_layout.elapsed().as_micros();
// Compute and send min-size hint: max of layout min and explicit window min.
let desired_min = UiSize::new(
root_min_size
.width
@@ -220,10 +242,31 @@ impl<M: Mountable> MountedApp<M> {
.height
.max(explicit_min_size.map_or(0.0, |s| s.height)),
);
let current_min = *last_min_size.borrow();
if current_min != Some(desired_min) {
let _ = window.update(WindowUpdate::new().min_inner_size(Some(desired_min)));
*last_min_size.borrow_mut() = Some(desired_min);
if window_resizable {
let current_min = *last_min_size.borrow();
if current_min != Some(desired_min) {
let _ = window.update(WindowUpdate::new().min_inner_size(Some(desired_min)));
*last_min_size.borrow_mut() = Some(desired_min);
}
} else {
let desired_fixed = UiSize::new(
desired_min
.width
.min(explicit_max_size.map_or(f32::INFINITY, |s| s.width)),
desired_min
.height
.min(explicit_max_size.map_or(f32::INFINITY, |s| s.height)),
);
let current_fixed = *last_fixed_size.borrow();
if current_fixed != Some(desired_fixed) {
let _ = window.update(
WindowUpdate::new()
.requested_inner_size(Some(desired_fixed))
.min_inner_size(Some(desired_fixed))
.max_inner_size(Some(desired_fixed)),
);
*last_fixed_size.borrow_mut() = Some(desired_fixed);
}
}
let effect_us = t_effect.elapsed().as_micros();
@@ -355,10 +398,15 @@ impl<M: Mountable> MountedApp<M> {
.last()
.map(|target| target.cursor)
.unwrap_or(ruin_ui::CursorIcon::Default);
let hovered_ids = Self::interaction_ids(pointer_router.hovered_targets());
let pressed_ids = Self::interaction_ids(pointer_router.pressed_targets());
if next_cursor != input_state.current_cursor {
input_state.current_cursor = next_cursor;
window.set_cursor_icon(next_cursor)?;
}
if Self::update_interaction_state(input_state, hovered_ids, pressed_ids) {
input_state.interaction_version.update(|value| *value += 1);
}
Ok(())
}
@@ -458,6 +506,32 @@ impl<M: Mountable> MountedApp<M> {
Ok(())
}
fn interaction_ids(targets: &[ruin_ui::HitTarget]) -> Vec<ruin_ui::ElementId> {
targets.iter().filter_map(|target| target.element_id).collect()
}
fn update_interaction_state(
input_state: &mut InputState,
hovered_ids: Vec<ruin_ui::ElementId>,
pressed_ids: Vec<ruin_ui::ElementId>,
) -> bool {
let previous_hovered = input_state.hovered_elements.get();
let previous_pressed = input_state.pressed_elements.get();
let hovered_changed = input_state.hovered_elements.set(hovered_ids.clone()).is_some();
let pressed_changed = input_state.pressed_elements.set(pressed_ids.clone()).is_some();
if hovered_changed || pressed_changed {
tracing::trace!(
target: "ruin_app::interaction",
hovered_before = ?previous_hovered,
hovered_after = ?hovered_ids,
pressed_before = ?previous_pressed,
pressed_after = ?pressed_ids,
"interaction state changed"
);
}
hovered_changed || pressed_changed
}
}
#[doc(hidden)]

View File

@@ -29,6 +29,12 @@ pub(crate) struct RenderState {
pub(crate) next_element_id: StdCell<u64>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct InteractionSnapshot {
pub(crate) hovered: Vec<ElementId>,
pub(crate) pressed: Vec<ElementId>,
}
#[derive(Clone, Default)]
pub(crate) struct RenderSideEffects {
pub(crate) window_title: Option<String>,
@@ -42,6 +48,7 @@ pub(crate) struct RenderContext {
pub(crate) element_index: Rc<StdCell<usize>>,
pub(crate) side_effects: Rc<RefCell<RenderSideEffects>>,
pub(crate) context_entries: Rc<Vec<ContextEntry>>,
pub(crate) interaction: Rc<InteractionSnapshot>,
}
impl RenderContext {
@@ -52,6 +59,7 @@ impl RenderContext {
element_index: Rc::clone(&self.element_index),
side_effects: Rc::clone(&self.side_effects),
context_entries,
interaction: Rc::clone(&self.interaction),
}
}
}
@@ -68,6 +76,14 @@ thread_local! {
pub(crate) fn render_with_context(
state: Rc<RenderState>,
render: impl FnOnce() -> View,
) -> RenderOutput {
render_with_context_and_interaction(state, Rc::new(InteractionSnapshot::default()), render)
}
pub(crate) fn render_with_context_and_interaction(
state: Rc<RenderState>,
interaction: Rc<InteractionSnapshot>,
render: impl FnOnce() -> View,
) -> RenderOutput {
let context = RenderContext {
state,
@@ -75,6 +91,7 @@ pub(crate) fn render_with_context(
element_index: Rc::new(StdCell::new(0)),
side_effects: Rc::new(RefCell::new(RenderSideEffects::default())),
context_entries: Rc::new(Vec::new()),
interaction,
};
let view = with_render_context(context.clone(), render);

View File

@@ -155,6 +155,11 @@ pub fn use_window_title(compute: impl FnOnce() -> String) {
pub enum Key {
Character(char),
Enter,
Backspace,
Escape,
Delete,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowDown,
Home,
@@ -169,6 +174,11 @@ impl Key {
.next()
.is_some_and(|actual| actual.eq_ignore_ascii_case(expected)),
(Self::Enter, KeyboardKey::Enter) => true,
(Self::Backspace, KeyboardKey::Backspace) => true,
(Self::Escape, KeyboardKey::Escape) => true,
(Self::Delete, KeyboardKey::Delete) => true,
(Self::ArrowLeft, KeyboardKey::ArrowLeft) => true,
(Self::ArrowRight, KeyboardKey::ArrowRight) => true,
(Self::ArrowUp, KeyboardKey::ArrowUp) => true,
(Self::ArrowDown, KeyboardKey::ArrowDown) => true,
(Self::Home, KeyboardKey::Home) => true,
@@ -209,12 +219,27 @@ impl Shortcut {
}
pub(crate) fn matches(&self, event: &KeyboardEvent) -> bool {
event.kind == KeyboardEventKind::Pressed
&& self.key.matches(event)
&& event.modifiers.control == self.control
&& event.modifiers.shift == self.shift
&& event.modifiers.alt == self.alt
&& event.modifiers.super_key == self.super_key
if event.kind != KeyboardEventKind::Pressed {
return false;
}
if !self.key.matches(event) {
return false;
}
if event.modifiers.control != self.control {
return false;
}
if event.modifiers.alt != self.alt {
return false;
}
if event.modifiers.super_key != self.super_key {
return false;
}
// For character shortcuts the produced character already encodes the shift
// state, so don't additionally require that Shift is (un)held.
if !matches!(self.key, Key::Character(_)) && event.modifiers.shift != self.shift {
return false;
}
true
}
}
@@ -223,6 +248,12 @@ pub struct FocusScope {
pub(crate) element_id: Signal<Option<ElementId>>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct InteractionState {
pub hovered: bool,
pub pressed: bool,
}
#[derive(Clone)]
pub enum ShortcutScope {
Application,
@@ -296,6 +327,18 @@ pub fn use_widget_ref<T: 'static>() -> WidgetRef<T> {
})
}
pub fn use_interaction_state<T: 'static>(widget_ref: WidgetRef<T>) -> InteractionState {
with_render_context_state(|context| {
let Some(element_id) = widget_ref.element_id() else {
return InteractionState::default();
};
InteractionState {
hovered: context.interaction.hovered.contains(&element_id),
pressed: context.interaction.pressed.contains(&element_id),
}
})
}
pub fn use_shortcut(shortcut: Shortcut, scope: ShortcutScope, action: impl Fn() + 'static) {
use_shortcut_with_context(shortcut, scope, move |_| action());
}
@@ -371,4 +414,3 @@ where
|slot: &mut ResourceSlot<T, E>| slot.resource.clone(),
)
}

View File

@@ -124,6 +124,9 @@ pub(crate) struct InputState {
pub(crate) current_cursor: CursorIcon,
pub(crate) focused_element: Option<ElementId>,
pub(crate) text_selection: Rc<TextSelectionState>,
pub(crate) hovered_elements: Signal<Vec<ElementId>>,
pub(crate) pressed_elements: Signal<Vec<ElementId>>,
pub(crate) interaction_version: Signal<u64>,
}
impl InputState {
@@ -132,6 +135,9 @@ impl InputState {
current_cursor: CursorIcon::Default,
focused_element: None,
text_selection: Rc::new(TextSelectionState::new()),
hovered_elements: Signal::new(Vec::new()),
pressed_elements: Signal::new(Vec::new()),
interaction_version: Signal::new(0),
}
}
}

View File

@@ -23,9 +23,10 @@ pub mod view;
pub use app::{App, Component, ContextKey, MountedApp, Mountable, Window, __render_mountable_for_test};
pub use converters::{IntoBorder, IntoEdges, IntoShadow};
pub use hooks::{
BlockWidget, FocusScope, Key, Memo, Resource, ResourceState, ScrollBoxWidget, Shortcut,
ShortcutScope, Signal, WidgetRef, provide, use_context, use_effect, use_memo, use_resource,
use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title,
BlockWidget, FocusScope, InteractionState, Key, Memo, Resource, ResourceState,
ScrollBoxWidget, Shortcut, ShortcutScope, Signal, WidgetRef, provide, use_context,
use_effect, use_interaction_state, use_memo, use_resource, use_shortcut,
use_shortcut_with_context, use_signal, use_widget_ref, use_window_title,
};
pub use primitives::{
ButtonBuilder, ContainerBuilder, ContainerProps, FontWeight, ScrollBoxBuilder, TextBuilder,
@@ -59,19 +60,20 @@ pub type Result<T> = std::result::Result<T, Box<dyn Error>>;
pub mod prelude {
pub use crate::{
App, BlockWidget, ButtonBuilder, ChildViews, Children, Component, ContainerBuilder,
ContextKey, FocusScope, FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable,
Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget,
Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View,
WidgetRef, Window, block, button, colors, column, component, context_provider, provide,
row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource,
use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title,
view,
ContextKey, FocusScope, FontWeight, InteractionState, IntoBorder, IntoEdges, IntoView,
Key, Memo, Mountable, Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder,
ScrollBoxWidget, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole,
TextValue, View, WidgetRef, Window, block, button, colors, column, component,
context_provider, provide, row, scroll_box, surfaces, text, use_context, use_effect,
use_interaction_state, use_memo, use_resource, use_shortcut, use_shortcut_with_context,
use_signal, use_widget_ref, use_window_title, view,
};
pub use ruin_ui::{
AlignItems, Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree,
PointerButton, PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind,
ScrollbarStyle, TextFontFamily, TextStyle, TextWrap, UiSize,
};
pub use crate::PartialTextStyle;
}
#[cfg(test)]
@@ -84,7 +86,9 @@ mod tests {
use ruin_ui::{KeyboardModifiers, Point, WindowSpec};
use super::*;
use crate::context::{RenderState, render_with_context};
use crate::context::{
InteractionSnapshot, RenderState, render_with_context, render_with_context_and_interaction,
};
use crate::input::{
InputState, KeyHandler, focused_element_for_pointer, scroll_handler_for_event,
};
@@ -177,6 +181,53 @@ mod tests {
assert!(debug.contains("body child"), "{debug}");
}
#[test]
fn interaction_state_tracks_hover_and_press_for_widget_refs() {
let render_state = Rc::new(RenderState::default());
let hovered = Rc::new(RefCell::new(None::<bool>));
let pressed = Rc::new(RefCell::new(None::<bool>));
let widget_ref_slot = Rc::new(RefCell::new(None::<WidgetRef<BlockWidget>>));
let _ = render_with_context(Rc::clone(&render_state), {
let hovered = Rc::clone(&hovered);
let pressed = Rc::clone(&pressed);
let widget_ref_slot = Rc::clone(&widget_ref_slot);
move || {
let widget_ref = use_widget_ref::<BlockWidget>();
*hovered.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).hovered);
*pressed.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).pressed);
*widget_ref_slot.borrow_mut() = Some(widget_ref.clone());
block().widget_ref(widget_ref).children(())
}
});
let widget_ref = widget_ref_slot.borrow().clone().expect("widget ref should be set");
let element_id = widget_ref
.element_id()
.expect("widget ref should have an element id after render");
let _ = render_with_context_and_interaction(
render_state,
Rc::new(InteractionSnapshot {
hovered: vec![element_id],
pressed: vec![element_id],
}),
{
let hovered = Rc::clone(&hovered);
let pressed = Rc::clone(&pressed);
let widget_ref_slot = Rc::clone(&widget_ref_slot);
move || {
let widget_ref = widget_ref_slot.borrow().clone().expect("widget ref should persist");
*hovered.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).hovered);
*pressed.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).pressed);
block().widget_ref(widget_ref).children(())
}
},
);
assert_eq!(*hovered.borrow(), Some(true));
assert_eq!(*pressed.borrow(), Some(true));
}
#[test]
fn key_dispatch_prefers_the_nearest_focused_ancestor_handler() {
use ruin_ui::{ElementId, KeyboardEvent, KeyboardEventKind, KeyboardKey};

View File

@@ -31,6 +31,11 @@ pub fn button() -> ButtonBuilder {
impl ButtonBuilder {
impl_props_methods!();
pub fn shadow(mut self, shadow: impl crate::converters::IntoShadow) -> Self {
self.props = self.props.shadow(shadow);
self
}
pub fn on_press(mut self, handler: impl Fn(&RoutedPointerEvent) + 'static) -> Self {
self.on_press = Some(Rc::new(handler));
self
@@ -44,6 +49,9 @@ impl ButtonBuilder {
pub fn children(mut self, children: impl Children) -> View {
let id = allocate_element_id();
self.props.element = self.props.element.id(id);
if let Some(widget_ref) = &self.props.widget_ref {
let _ = widget_ref.set(Some(id));
}
let had_style = self.props.partial_text_style.is_some();
if let Some(style) = self.props.partial_text_style.take() {
push_text_style(style);

View File

@@ -128,16 +128,6 @@ pub fn block() -> ContainerBuilder {
impl ContainerBuilder {
impl_props_methods!();
pub fn align_items(mut self, align: AlignItems) -> Self {
self.props = self.props.align_items(align);
self
}
pub fn align_self(mut self, align: AlignItems) -> Self {
self.props = self.props.align_self(align);
self
}
pub fn shadow(mut self, shadow: impl IntoShadow) -> Self {
self.props = self.props.shadow(shadow);
self

View File

@@ -45,6 +45,14 @@ macro_rules! impl_props_methods {
self.props = self.props.flex(flex);
self
}
pub fn align_items(mut self, align: ruin_ui::AlignItems) -> Self {
self.props = self.props.align_items(align);
self
}
pub fn align_self(mut self, align: ruin_ui::AlignItems) -> Self {
self.props = self.props.align_self(align);
self
}
pub fn widget_ref<T>(mut self, widget_ref: $crate::hooks::WidgetRef<T>) -> Self {
self.props = self.props.widget_ref(widget_ref);
self