Refactor ruin_app, add some README files, minor usability in reactivity

This commit is contained in:
2026-03-25 22:32:11 -04:00
parent 5f67c06083
commit 341d07d82f
28 changed files with 2782 additions and 2118 deletions

466
lib/ruin_app/src/app.rs Normal file
View File

@@ -0,0 +1,466 @@
use std::cell::Cell as StdCell;
use std::cell::RefCell;
use std::iter;
use std::rc::Rc;
use std::time::Instant;
use ruin_reactivity::effect;
use ruin_ui::{
Color, Element, InteractionTree, KeyboardEvent, LayoutCache, LayoutSnapshot, PlatformEvent,
PointerButton, PointerEvent, PointerEventKind, PointerRouter, RoutedPointerEvent,
RoutedPointerEventKind, TextSystem, UiSize, WindowController, WindowSpec, WindowUpdate,
layout_snapshot_with_cache,
};
use ruin_ui_platform_wayland::start_wayland_ui;
use crate::Result;
use crate::context::{RenderState, render_with_context};
use crate::input::{
EventBindings, InputState, ShortcutBinding, TextSelection, TextSelectionDrag,
TextSelectionState, apply_text_selection_overlay, focused_element_for_pointer,
sync_primary_selection,
};
use crate::view::View;
#[derive(Clone, Debug)]
pub struct Window {
pub(crate) spec: WindowSpec,
}
impl Window {
pub fn new() -> Self {
Self {
spec: WindowSpec::new("RUIN App"),
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.spec.title = title.into();
self
}
pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
self.spec = self.spec.app_id(app_id);
self
}
pub fn size(mut self, width: f32, height: f32) -> Self {
self.spec = self.spec.requested_inner_size(UiSize::new(width, height));
self
}
pub fn min_size(mut self, min_width: f32, min_height: f32) -> Self {
self.spec = self.spec.min_inner_size(UiSize::new(min_width, min_height));
self
}
pub fn base_color(mut self, color: Color) -> Self {
self.spec = self.spec.base_color(color);
self
}
}
impl Default for Window {
fn default() -> Self {
Self::new()
}
}
pub struct App {
window: Window,
}
impl App {
pub fn new() -> Self {
Self {
window: Window::new(),
}
}
pub fn window(mut self, window: Window) -> Self {
self.window = window;
self
}
pub fn mount<M: Mountable>(self, root: M) -> MountedApp<M> {
MountedApp {
window: self.window,
root: Rc::new(root),
render_state: Rc::new(RenderState::default()),
}
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
pub trait Mountable: 'static {
fn render(&self) -> View;
}
pub trait Component: 'static {
type Builder;
fn builder() -> Self::Builder;
fn render(&self) -> View;
}
pub trait ContextKey: 'static {
type Value: Clone + 'static;
}
impl<T: Component> Mountable for T {
fn render(&self) -> View {
Component::render(self)
}
}
impl Mountable for View {
fn render(&self) -> View {
self.clone()
}
}
impl Mountable for Element {
fn render(&self) -> View {
View::from_element(self.clone())
}
}
pub struct MountedApp<M: Mountable> {
pub(crate) window: Window,
pub(crate) root: Rc<M>,
pub(crate) render_state: Rc<RenderState>,
}
impl<M: Mountable> MountedApp<M> {
pub async fn run(self) -> Result<()> {
let MountedApp {
window: app_window,
root,
render_state,
} = self;
let mut ui = start_wayland_ui();
let window = ui.create_window(app_window.spec.clone())?;
let initial_viewport = app_window
.spec
.requested_inner_size
.unwrap_or(UiSize::new(960.0, 640.0));
let viewport = ruin_reactivity::cell(initial_viewport);
let scene_version = StdCell::new(0_u64);
let text_system = Rc::new(RefCell::new(TextSystem::new()));
let layout_cache = Rc::new(RefCell::new(LayoutCache::new()));
let interaction_tree = Rc::new(RefCell::new(None::<InteractionTree>));
let bindings = Rc::new(RefCell::new(EventBindings::default()));
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 explicit_min_size = app_window.spec.min_inner_size;
let mut input_state = InputState::new();
let mut pointer_router = PointerRouter::new();
let _scene_effect = effect({
let window = window.clone();
let viewport = viewport.clone();
let text_system = Rc::clone(&text_system);
let layout_cache = Rc::clone(&layout_cache);
let interaction_tree = Rc::clone(&interaction_tree);
let bindings = Rc::clone(&bindings);
let shortcuts = Rc::clone(&shortcuts);
let current_title = Rc::clone(&current_title);
let last_min_size = Rc::clone(&last_min_size);
let root = Rc::clone(&root);
let render_state = Rc::clone(&render_state);
let text_selection = Rc::clone(&input_state.text_selection);
move || {
let viewport = viewport.get();
let version = scene_version.get().wrapping_add(1);
scene_version.set(version);
let _ = text_selection.version.get();
let t_effect = Instant::now();
let render_output = render_with_context(Rc::clone(&render_state), || root.render());
if render_output.side_effects.window_title != *current_title.borrow() {
if let Some(title) = &render_output.side_effects.window_title {
window
.update(WindowUpdate::new().title(title.clone()))
.expect("window should remain alive while the app is running");
}
*current_title.borrow_mut() = render_output.side_effects.window_title.clone();
}
let render_us = t_effect.elapsed().as_micros();
let t_layout = Instant::now();
let LayoutSnapshot {
mut scene,
interaction_tree: next_interaction_tree,
root_min_size,
} = layout_snapshot_with_cache(
version,
viewport,
render_output.view.element(),
&mut text_system.borrow_mut(),
&mut layout_cache.borrow_mut(),
);
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
.max(explicit_min_size.map_or(0.0, |s| s.width)),
root_min_size
.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);
}
let effect_us = t_effect.elapsed().as_micros();
tracing::debug!(
target: "ruin_app::resize",
version,
width = viewport.width,
height = viewport.height,
render_us,
layout_us,
effect_us,
"scene effect complete, sending ReplaceScene"
);
apply_text_selection_overlay(&mut scene, *text_selection.selection.borrow());
*interaction_tree.borrow_mut() = Some(next_interaction_tree);
*bindings.borrow_mut() = render_output.view.bindings;
*shortcuts.borrow_mut() = render_output.side_effects.shortcuts.clone();
window
.replace_scene(scene)
.expect("window should remain alive while the app is running");
}
});
loop {
let Some(event) = ui.next_event().await else {
break;
};
for event in iter::once(event).chain(ui.take_pending_events()) {
match event {
PlatformEvent::Configured {
window_id,
configuration,
} if window_id == window.id() => {
tracing::debug!(
target: "ruin_app::resize",
width = configuration.actual_inner_size.width,
height = configuration.actual_inner_size.height,
"app received Configured, queuing layout effect"
);
let _ = viewport.set(configuration.actual_inner_size);
}
PlatformEvent::Pointer { window_id, event } if window_id == window.id() => {
Self::handle_pointer_event(
&window,
&interaction_tree,
&bindings,
&mut pointer_router,
&mut input_state,
event,
)?;
}
PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => {
Self::handle_keyboard_event(
&interaction_tree,
&bindings,
&shortcuts,
&input_state,
event,
)?;
}
PlatformEvent::CloseRequested { window_id } if window_id == window.id() => {
let _ = window.update(WindowUpdate::new().open(false));
}
PlatformEvent::Closed { window_id } if window_id == window.id() => {
ui.shutdown()?;
return Ok(());
}
_ => {}
}
}
}
ui.shutdown()?;
Ok(())
}
pub(crate) fn handle_pointer_event(
window: &WindowController,
interaction_tree: &RefCell<Option<InteractionTree>>,
bindings: &RefCell<EventBindings>,
pointer_router: &mut PointerRouter,
input_state: &mut InputState,
event: PointerEvent,
) -> Result<()> {
if matches!(
event.kind,
PointerEventKind::Down {
button: PointerButton::Primary | PointerButton::Middle,
}
) {
let interaction_tree = interaction_tree.borrow();
if let Some(interaction_tree) = interaction_tree.as_ref() {
input_state.focused_element = focused_element_for_pointer(interaction_tree, &event);
}
}
let routed = {
let interaction_tree = interaction_tree.borrow();
let Some(interaction_tree) = interaction_tree.as_ref() else {
return Ok(());
};
pointer_router.route(interaction_tree, event)
};
{
let interaction_tree = interaction_tree.borrow();
let Some(interaction_tree) = interaction_tree.as_ref() else {
return Ok(());
};
let hovered_targets = pointer_router.hovered_targets();
for routed_event in &routed {
bindings
.borrow()
.dispatch(routed_event, interaction_tree, hovered_targets);
Self::handle_text_selection_event(
window,
interaction_tree,
routed_event,
&input_state.text_selection,
)?;
}
}
let next_cursor = pointer_router
.hovered_targets()
.last()
.map(|target| target.cursor)
.unwrap_or(ruin_ui::CursorIcon::Default);
if next_cursor != input_state.current_cursor {
input_state.current_cursor = next_cursor;
window.set_cursor_icon(next_cursor)?;
}
Ok(())
}
pub(crate) fn handle_keyboard_event(
interaction_tree: &RefCell<Option<InteractionTree>>,
bindings: &RefCell<EventBindings>,
shortcuts: &RefCell<Vec<ShortcutBinding>>,
input_state: &InputState,
event: KeyboardEvent,
) -> Result<()> {
let interaction_tree = interaction_tree.borrow();
let Some(interaction_tree) = interaction_tree.as_ref() else {
return Ok(());
};
let shortcut_bindings = shortcuts.borrow().clone();
let mut consumed = false;
for shortcut in shortcut_bindings {
if shortcut.matches(&event, input_state.focused_element, interaction_tree) {
shortcut.trigger(interaction_tree);
consumed = true;
}
}
if consumed {
return Ok(());
}
bindings
.borrow()
.dispatch_key(input_state.focused_element, &event, interaction_tree);
Ok(())
}
fn handle_text_selection_event(
window: &WindowController,
interaction_tree: &InteractionTree,
event: &RoutedPointerEvent,
text_selection: &TextSelectionState,
) -> Result<()> {
let mut selection_changed = false;
match event.kind {
RoutedPointerEventKind::Down {
button: PointerButton::Primary,
} => {
if let Some(hit) = interaction_tree.text_hit_test(event.position)
&& let Some(element_id) = hit.target.element_id
{
let next = Some(TextSelection {
element_id,
anchor: hit.byte_offset,
focus: hit.byte_offset,
});
if *text_selection.selection.borrow() != next {
*text_selection.selection.borrow_mut() = next;
selection_changed = true;
}
*text_selection.drag.borrow_mut() = Some(TextSelectionDrag {
element_id,
anchor: hit.byte_offset,
});
} else {
selection_changed = text_selection.selection.borrow_mut().take().is_some();
let _ = text_selection.drag.borrow_mut().take();
}
}
RoutedPointerEventKind::Move => {
let Some(drag) = *text_selection.drag.borrow() else {
return Ok(());
};
let Some(text) = interaction_tree.text_for_element(drag.element_id) else {
return Ok(());
};
let next = Some(TextSelection {
element_id: drag.element_id,
anchor: drag.anchor,
focus: text.byte_offset_for_position(event.position),
});
if *text_selection.selection.borrow() != next {
*text_selection.selection.borrow_mut() = next;
selection_changed = true;
}
}
RoutedPointerEventKind::Up {
button: PointerButton::Primary,
} => {
if text_selection.drag.borrow_mut().take().is_some() {
selection_changed = true;
}
}
_ => {}
}
if selection_changed {
text_selection.version.update(|value| *value += 1);
sync_primary_selection(window, interaction_tree, *text_selection.selection.borrow())?;
}
Ok(())
}
}
#[doc(hidden)]
pub fn __render_mountable_for_test<M: Mountable>(mountable: &M) -> View {
render_with_context(Rc::new(RenderState::default()), || mountable.render()).view
}

View File

@@ -0,0 +1,13 @@
use ruin_ui::Color;
pub const fn text() -> Color {
Color::rgb(0xFF, 0xFF, 0xFF)
}
pub const fn muted() -> Color {
Color::rgb(0xB6, 0xC2, 0xD9)
}
pub const fn danger() -> Color {
Color::rgb(0xFF, 0x7B, 0x72)
}

187
lib/ruin_app/src/context.rs Normal file
View File

@@ -0,0 +1,187 @@
use std::any::{Any, TypeId};
use std::cell::{Cell as StdCell, RefCell};
use std::rc::Rc;
use ruin_ui::ElementId;
use crate::ContextKey;
use crate::view::View;
#[derive(Clone)]
pub(crate) struct ContextEntry {
pub(crate) key: TypeId,
pub(crate) value: Rc<dyn Any>,
}
impl ContextEntry {
pub(crate) fn new<C: ContextKey>(value: C::Value) -> Self {
Self {
key: TypeId::of::<C>(),
value: Rc::new(value),
}
}
}
#[derive(Default)]
pub(crate) struct RenderState {
pub(crate) hooks: RefCell<Vec<Box<dyn Any>>>,
pub(crate) element_ids: RefCell<Vec<ElementId>>,
pub(crate) next_element_id: StdCell<u64>,
}
#[derive(Clone, Default)]
pub(crate) struct RenderSideEffects {
pub(crate) window_title: Option<String>,
pub(crate) shortcuts: Vec<crate::input::ShortcutBinding>,
}
#[derive(Clone)]
pub(crate) struct RenderContext {
pub(crate) state: Rc<RenderState>,
pub(crate) hook_index: Rc<StdCell<usize>>,
pub(crate) element_index: Rc<StdCell<usize>>,
pub(crate) side_effects: Rc<RefCell<RenderSideEffects>>,
pub(crate) context_entries: Rc<Vec<ContextEntry>>,
}
impl RenderContext {
pub(crate) fn with_context_entries(&self, context_entries: Rc<Vec<ContextEntry>>) -> Self {
Self {
state: Rc::clone(&self.state),
hook_index: Rc::clone(&self.hook_index),
element_index: Rc::clone(&self.element_index),
side_effects: Rc::clone(&self.side_effects),
context_entries,
}
}
}
pub(crate) struct RenderOutput {
pub(crate) view: View,
pub(crate) side_effects: RenderSideEffects,
}
thread_local! {
pub(crate) static CURRENT_RENDER_CONTEXT: RefCell<Option<RenderContext>> = const { RefCell::new(None) };
}
pub(crate) fn render_with_context(
state: Rc<RenderState>,
render: impl FnOnce() -> View,
) -> RenderOutput {
let context = RenderContext {
state,
hook_index: Rc::new(StdCell::new(0)),
element_index: Rc::new(StdCell::new(0)),
side_effects: Rc::new(RefCell::new(RenderSideEffects::default())),
context_entries: Rc::new(Vec::new()),
};
let view = with_render_context(context.clone(), render);
let side_effects = context.side_effects.borrow().clone();
RenderOutput { view, side_effects }
}
pub(crate) fn with_render_context(
context: RenderContext,
render: impl FnOnce() -> View,
) -> View {
CURRENT_RENDER_CONTEXT.with(|slot| {
let previous = slot.replace(Some(context.clone()));
struct Guard<'a> {
slot: &'a RefCell<Option<RenderContext>>,
previous: Option<RenderContext>,
}
impl Drop for Guard<'_> {
fn drop(&mut self) {
let _ = self.slot.replace(self.previous.take());
}
}
let _guard = Guard { slot, previous };
render()
})
}
pub(crate) fn with_render_context_state<R>(f: impl FnOnce(&RenderContext) -> R) -> R {
CURRENT_RENDER_CONTEXT.with(|slot| {
let context = slot
.borrow()
.clone()
.expect("ruin_app hooks can only run while rendering a mounted component");
f(&context)
})
}
pub(crate) fn with_hook_slot<T: 'static, R>(
init: impl FnOnce() -> T,
f: impl FnOnce(&mut T) -> R,
) -> R {
with_render_context_state(|context| {
let index = context.hook_index.get();
context.hook_index.set(index + 1);
let mut hooks = context.state.hooks.borrow_mut();
if hooks.len() == index {
hooks.push(Box::new(init()));
}
let slot = hooks[index]
.downcast_mut::<T>()
.expect("ruin_app hook call order changed between renders");
f(slot)
})
}
pub(crate) fn allocate_element_id() -> ElementId {
with_render_context_state(|context| {
let index = context.element_index.get();
context.element_index.set(index + 1);
let mut element_ids = context.state.element_ids.borrow_mut();
if element_ids.len() == index {
let next = context.state.next_element_id.get().wrapping_add(1);
context.state.next_element_id.set(next);
element_ids.push(ElementId::new(next));
}
element_ids[index]
})
}
pub(crate) struct MemoSlot<T> {
pub(crate) compute: Rc<RefCell<Box<dyn Fn() -> T>>>,
pub(crate) handle: crate::hooks::Memo<T>,
}
pub(crate) struct ResourceSlot<T, E> {
pub(crate) resource: crate::hooks::Resource<T, E>,
pub(crate) _effect: ruin_reactivity::EffectHandle,
}
impl<T: 'static> MemoSlot<T> {
pub(crate) fn new(compute: Rc<RefCell<Box<dyn Fn() -> T>>>) -> Self {
let handle = crate::hooks::Memo {
compute: Rc::new({
let compute = Rc::clone(&compute);
move || {
let compute = compute.borrow();
(compute.as_ref())()
}
}),
};
Self { compute, handle }
}
pub(crate) fn replace_compute(&mut self, compute: Rc<RefCell<Box<dyn Fn() -> T>>>) {
self.compute = Rc::clone(&compute);
self.handle.compute = Rc::new({
let compute = Rc::clone(&compute);
move || {
let compute = compute.borrow();
(compute.as_ref())()
}
});
}
}

View File

@@ -0,0 +1,43 @@
use ruin_ui::{Border, BoxShadow, Color, Edges};
pub trait IntoEdges {
fn into_edges(self) -> Edges;
}
impl IntoEdges for Edges {
fn into_edges(self) -> Edges {
self
}
}
impl IntoEdges for f32 {
fn into_edges(self) -> Edges {
Edges::all(self)
}
}
pub trait IntoBorder {
fn into_border(self) -> Border;
}
impl IntoBorder for Border {
fn into_border(self) -> Border {
self
}
}
impl IntoBorder for (f32, Color) {
fn into_border(self) -> Border {
Border::new(self.0, self.1)
}
}
pub trait IntoShadow {
fn into_shadow(self) -> BoxShadow;
}
impl IntoShadow for BoxShadow {
fn into_shadow(self) -> BoxShadow {
self
}
}

374
lib/ruin_app/src/hooks.rs Normal file
View File

@@ -0,0 +1,374 @@
use std::any::type_name;
use std::any::TypeId;
use std::cell::Cell as StdCell;
use std::future::Future;
use std::marker::PhantomData;
use std::rc::Rc;
use ruin_ui::{ElementId, InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey};
use crate::ContextKey;
use crate::context::{
ContextEntry, MemoSlot, ResourceSlot, with_hook_slot,
with_render_context, with_render_context_state,
};
use crate::input::ShortcutBinding;
pub struct SignalInner<T> {
pub(crate) cell: ruin_reactivity::Cell<T>,
}
pub struct Signal<T> {
pub(crate) inner: Rc<SignalInner<T>>,
}
impl<T> Clone for Signal<T> {
fn clone(&self) -> Self {
Self {
inner: Rc::clone(&self.inner),
}
}
}
impl<T: 'static> Signal<T> {
pub(crate) fn new(initial: T) -> Self {
Self {
inner: Rc::new(SignalInner {
cell: ruin_reactivity::cell(initial),
}),
}
}
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R {
self.inner.cell.with(f)
}
pub fn replace(&self, value: T) -> T {
self.inner.cell.replace(value)
}
pub fn update<R>(&self, f: impl FnOnce(&mut T) -> R) -> R {
self.inner.cell.update(f)
}
}
impl<T: Clone + 'static> Signal<T> {
pub fn get(&self) -> T {
self.inner.cell.get()
}
}
impl<T: PartialEq + 'static> Signal<T> {
pub fn set(&self, value: T) -> Option<T> {
self.inner.cell.set(value)
}
}
pub struct Memo<T> {
pub(crate) compute: Rc<dyn Fn() -> T>,
}
impl<T> Clone for Memo<T> {
fn clone(&self) -> Self {
Self {
compute: Rc::clone(&self.compute),
}
}
}
impl<T> Memo<T> {
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R {
let value = (self.compute)();
f(&value)
}
}
impl<T: Clone> Memo<T> {
pub fn get(&self) -> T {
(self.compute)()
}
}
pub fn use_signal<T: 'static>(initial: impl FnOnce() -> T) -> Signal<T> {
with_hook_slot(|| Signal::new(initial()), |signal| signal.clone())
}
pub fn use_context<C: ContextKey>() -> C::Value {
with_render_context_state(|context| {
context
.context_entries
.iter()
.rev()
.find_map(|entry| {
(entry.key == TypeId::of::<C>())
.then(|| entry.value.downcast_ref::<C::Value>())
.flatten()
.cloned()
})
.unwrap_or_else(|| {
panic!(
"missing context provider for {} while rendering",
type_name::<C>()
)
})
})
}
pub fn provide<C: ContextKey>(value: C::Value, render: impl FnOnce() -> crate::view::View) -> crate::view::View {
with_render_context_state(|context| {
let mut context_entries = (*context.context_entries).clone();
context_entries.push(ContextEntry::new::<C>(value));
with_render_context(
context.with_context_entries(Rc::new(context_entries)),
render,
)
})
}
pub fn use_memo<T: 'static>(compute: impl Fn() -> T + 'static) -> Memo<T> {
use std::cell::RefCell;
let compute: Rc<RefCell<Box<dyn Fn() -> T>>> =
Rc::new(RefCell::new(Box::new(compute) as Box<dyn Fn() -> T>));
with_hook_slot(
{
let compute = Rc::clone(&compute);
move || MemoSlot::new(compute)
},
|slot: &mut MemoSlot<T>| {
slot.replace_compute(Rc::clone(&compute));
slot.handle.clone()
},
)
}
pub fn use_effect(effect: impl Fn() + 'static) {
with_hook_slot(|| ruin_reactivity::effect(effect), |_| ());
}
pub fn use_window_title(compute: impl FnOnce() -> String) {
with_render_context_state(|context| {
context.side_effects.borrow_mut().window_title = Some(compute());
});
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Key {
Character(char),
Enter,
ArrowUp,
ArrowDown,
Home,
End,
}
impl Key {
pub(crate) fn matches(&self, event: &KeyboardEvent) -> bool {
match (self, &event.key) {
(Self::Character(expected), KeyboardKey::Character(actual)) => actual
.chars()
.next()
.is_some_and(|actual| actual.eq_ignore_ascii_case(expected)),
(Self::Enter, KeyboardKey::Enter) => true,
(Self::ArrowUp, KeyboardKey::ArrowUp) => true,
(Self::ArrowDown, KeyboardKey::ArrowDown) => true,
(Self::Home, KeyboardKey::Home) => true,
(Self::End, KeyboardKey::End) => true,
_ => false,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Shortcut {
pub(crate) key: Key,
pub(crate) control: bool,
pub(crate) shift: bool,
pub(crate) alt: bool,
pub(crate) super_key: bool,
}
impl Shortcut {
pub fn new(key: Key) -> Self {
Self {
key,
control: false,
shift: false,
alt: false,
super_key: false,
}
}
pub fn with_ctrl(mut self) -> Self {
self.control = true;
self
}
pub fn with_shift(mut self) -> Self {
self.shift = true;
self
}
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
}
}
#[derive(Clone)]
pub struct FocusScope {
pub(crate) element_id: Signal<Option<ElementId>>,
}
#[derive(Clone)]
pub enum ShortcutScope {
Application,
FocusedWithin(FocusScope),
}
impl ShortcutScope {
pub(crate) fn matches(
&self,
focused_element: Option<ElementId>,
interaction_tree: &InteractionTree,
) -> bool {
match self {
Self::Application => true,
Self::FocusedWithin(scope) => {
let Some(scope_element) = scope.element_id.get() else {
return false;
};
let Some(focused_element) = focused_element else {
return false;
};
crate::input::element_contains_element(
interaction_tree,
scope_element,
focused_element,
)
}
}
}
}
pub struct WidgetRef<T> {
pub(crate) element_id: Signal<Option<ElementId>>,
pub(crate) _marker: PhantomData<fn() -> T>,
}
impl<T> Clone for WidgetRef<T> {
fn clone(&self) -> Self {
Self {
element_id: self.element_id.clone(),
_marker: PhantomData,
}
}
}
impl<T> WidgetRef<T> {
pub(crate) fn new() -> Self {
Self {
element_id: Signal::new(None),
_marker: PhantomData,
}
}
pub fn element_id(&self) -> Option<ElementId> {
self.element_id.get()
}
pub fn focus_scope(&self) -> FocusScope {
FocusScope {
element_id: self.element_id.clone(),
}
}
}
pub struct ScrollBoxWidget;
pub struct BlockWidget;
pub fn use_widget_ref<T: 'static>() -> WidgetRef<T> {
with_hook_slot(WidgetRef::new, |widget_ref: &mut WidgetRef<T>| {
widget_ref.clone()
})
}
pub fn use_shortcut(shortcut: Shortcut, scope: ShortcutScope, action: impl Fn() + 'static) {
use_shortcut_with_context(shortcut, scope, move |_| action());
}
pub fn use_shortcut_with_context(
shortcut: Shortcut,
scope: ShortcutScope,
action: impl Fn(&InteractionTree) + 'static,
) {
with_render_context_state(|context| {
context
.side_effects
.borrow_mut()
.shortcuts
.push(ShortcutBinding {
shortcut,
scope,
action: Rc::new(action),
});
});
}
#[derive(Clone)]
pub struct Resource<T, E> {
pub(crate) state: Signal<ResourceState<T, E>>,
}
impl<T: Clone + 'static, E: Clone + 'static> Resource<T, E> {
pub fn read(&self) -> ResourceState<T, E> {
self.state.get()
}
}
#[derive(Clone)]
pub enum ResourceState<T, E> {
Pending,
Ready(std::result::Result<T, E>),
}
pub fn use_resource<T, E, Fut, F>(factory: F) -> Resource<T, E>
where
T: Clone + 'static,
E: Clone + 'static,
Fut: Future<Output = std::result::Result<T, E>> + 'static,
F: Fn() -> Fut + 'static,
{
with_hook_slot(
|| {
let resource = Resource {
state: Signal::new(ResourceState::Pending),
};
let generation = Rc::new(StdCell::new(0_u64));
let _effect = ruin_reactivity::effect({
let resource = resource.clone();
let generation = Rc::clone(&generation);
move || {
let next_generation = generation.get().wrapping_add(1);
generation.set(next_generation);
let _ = resource.state.replace(ResourceState::Pending);
let resource = resource.clone();
let future = factory();
let generation = Rc::clone(&generation);
std::mem::drop(ruin_runtime::queue_future(async move {
let result = future.await;
if generation.get() == next_generation {
let _ = resource.state.replace(ResourceState::Ready(result));
}
}));
}
});
ResourceSlot { resource, _effect }
},
|slot: &mut ResourceSlot<T, E>| slot.resource.clone(),
)
}

344
lib/ruin_app/src/input.rs Normal file
View File

@@ -0,0 +1,344 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use ruin_ui::{
CursorIcon, DisplayItem, ElementId, HitTarget, InteractionTree, KeyboardEvent,
PointerButton, PointerEvent, Quad, RoutedPointerEvent,
RoutedPointerEventKind, WindowController,
};
use crate::hooks::{Shortcut, ShortcutScope, Signal};
use crate::view::View;
pub(crate) type PressHandler = Rc<dyn Fn(&RoutedPointerEvent) + 'static>;
pub(crate) type ScrollHandler = Rc<dyn Fn(&RoutedPointerEvent, &InteractionTree) + 'static>;
pub(crate) type KeyHandler = Rc<dyn Fn(&KeyboardEvent, &InteractionTree) -> bool + 'static>;
#[derive(Clone, Default)]
pub(crate) struct EventBindings {
pub(crate) on_press: HashMap<ElementId, PressHandler>,
pub(crate) on_scroll: HashMap<ElementId, ScrollHandler>,
pub(crate) on_key: HashMap<ElementId, KeyHandler>,
}
impl EventBindings {
pub(crate) fn extend(&mut self, other: EventBindings) {
self.on_press.extend(other.on_press);
self.on_scroll.extend(other.on_scroll);
self.on_key.extend(other.on_key);
}
pub(crate) fn dispatch(
&self,
event: &RoutedPointerEvent,
interaction_tree: &InteractionTree,
hovered_targets: &[HitTarget],
) {
match event.kind {
RoutedPointerEventKind::Up {
button: PointerButton::Primary,
} => {
if let Some(element_id) = event.target.element_id
&& let Some(handler) = self.on_press.get(&element_id)
{
handler(event);
}
if let Some(handler) = scroll_handler_for_event(
&self.on_scroll,
event.target.element_id,
hovered_targets,
) {
handler(event, interaction_tree);
}
}
RoutedPointerEventKind::Down {
button: PointerButton::Primary,
}
| RoutedPointerEventKind::Move
| RoutedPointerEventKind::Scroll { .. }
| RoutedPointerEventKind::Leave => {
if let Some(handler) = scroll_handler_for_event(
&self.on_scroll,
event.target.element_id,
hovered_targets,
) {
handler(event, interaction_tree);
}
}
_ => {}
}
}
pub(crate) fn dispatch_key(
&self,
focused_element: Option<ElementId>,
event: &KeyboardEvent,
interaction_tree: &InteractionTree,
) {
let Some(handler) =
key_handler_for_focus(&self.on_key, focused_element, interaction_tree)
else {
return;
};
let _ = handler(event, interaction_tree);
}
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) struct ScrollbarDrag {
pub(crate) start_pointer_y: f32,
pub(crate) start_offset_y: f32,
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) struct TextSelection {
pub(crate) element_id: ElementId,
pub(crate) anchor: usize,
pub(crate) focus: usize,
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) struct TextSelectionDrag {
pub(crate) element_id: ElementId,
pub(crate) anchor: usize,
}
pub(crate) struct TextSelectionState {
pub(crate) selection: RefCell<Option<TextSelection>>,
pub(crate) drag: RefCell<Option<TextSelectionDrag>>,
pub(crate) version: Signal<u64>,
}
impl TextSelectionState {
pub(crate) fn new() -> Self {
Self {
selection: RefCell::new(None),
drag: RefCell::new(None),
version: Signal::new(0),
}
}
}
pub(crate) struct InputState {
pub(crate) current_cursor: CursorIcon,
pub(crate) focused_element: Option<ElementId>,
pub(crate) text_selection: Rc<TextSelectionState>,
}
impl InputState {
pub(crate) fn new() -> Self {
Self {
current_cursor: CursorIcon::Default,
focused_element: None,
text_selection: Rc::new(TextSelectionState::new()),
}
}
}
#[derive(Clone)]
pub(crate) struct ShortcutBinding {
pub(crate) shortcut: Shortcut,
pub(crate) scope: ShortcutScope,
pub(crate) action: Rc<dyn Fn(&InteractionTree)>,
}
impl ShortcutBinding {
pub(crate) fn matches(
&self,
event: &KeyboardEvent,
focused_element: Option<ElementId>,
interaction_tree: &InteractionTree,
) -> bool {
self.shortcut.matches(event) && self.scope.matches(focused_element, interaction_tree)
}
pub(crate) fn trigger(&self, interaction_tree: &InteractionTree) {
(self.action)(interaction_tree);
}
}
pub(crate) fn clamp_scroll_offset(offset_y: f32, max_offset_y: f32) -> f32 {
offset_y.clamp(0.0, max_offset_y.max(0.0))
}
pub(crate) fn apply_text_selection_overlay(
scene: &mut ruin_ui::SceneSnapshot,
selection: Option<TextSelection>,
) {
let Some(selection) = selection else {
return;
};
let mut next_items = Vec::with_capacity(scene.items.len());
for item in scene.items.drain(..) {
match item {
DisplayItem::Text(mut text) if text.element_id == Some(selection.element_id) => {
for rect in text.selection_rects(selection.anchor, selection.focus) {
next_items.push(DisplayItem::Quad(Quad::new(
rect,
text.selection_style.highlight_color,
)));
}
text.apply_selected_text_color(selection.anchor, selection.focus);
next_items.push(DisplayItem::Text(text));
}
other => next_items.push(other),
}
}
scene.items = next_items;
}
pub(crate) fn sync_primary_selection(
window: &WindowController,
interaction_tree: &InteractionTree,
selection: Option<TextSelection>,
) -> crate::Result<()> {
let Some(selection) = selection else {
window.set_primary_selection_text(String::new())?;
return Ok(());
};
let Some(text) = interaction_tree.text_for_element(selection.element_id) else {
return Ok(());
};
let copied = text
.selected_text(selection.anchor, selection.focus)
.unwrap_or_default()
.to_owned();
window.set_primary_selection_text(copied)?;
Ok(())
}
pub(crate) fn scroll_handler_for_event<'a>(
handlers: &'a HashMap<ElementId, ScrollHandler>,
direct_target: Option<ElementId>,
hovered_targets: &[HitTarget],
) -> Option<&'a ScrollHandler> {
if let Some(element_id) = direct_target
&& let Some(handler) = handlers.get(&element_id)
{
return Some(handler);
}
hovered_targets
.iter()
.rev()
.filter_map(|target| target.element_id)
.find_map(|element_id| handlers.get(&element_id))
}
pub(crate) fn focused_element_for_pointer(
interaction_tree: &InteractionTree,
event: &PointerEvent,
) -> Option<ElementId> {
let hit_path = interaction_tree.hit_path(event.position);
hit_path
.iter()
.rev()
.find_map(|target| target.focusable.then_some(target.element_id).flatten())
.or_else(|| hit_path.iter().rev().find_map(|target| target.element_id))
}
pub(crate) fn element_contains_element(
interaction_tree: &InteractionTree,
ancestor: ElementId,
descendant: ElementId,
) -> bool {
fn contains_descendant(node: &ruin_ui::LayoutNode, descendant: ElementId) -> bool {
if node.element_id == Some(descendant) {
return true;
}
node.children
.iter()
.any(|child| contains_descendant(child, descendant))
}
fn ancestor_contains(
node: &ruin_ui::LayoutNode,
ancestor: ElementId,
descendant: ElementId,
) -> bool {
if node.element_id == Some(ancestor) {
return contains_descendant(node, descendant);
}
node.children
.iter()
.any(|child| ancestor_contains(child, ancestor, descendant))
}
ancestor_contains(&interaction_tree.root, ancestor, descendant)
}
pub(crate) fn key_handler_for_focus<'a>(
handlers: &'a HashMap<ElementId, KeyHandler>,
focused_element: Option<ElementId>,
interaction_tree: &InteractionTree,
) -> Option<&'a KeyHandler> {
let focused_element = focused_element?;
focused_ancestor_chain(&interaction_tree.root, focused_element)?
.into_iter()
.find_map(|element_id| handlers.get(&element_id))
}
pub(crate) fn focused_ancestor_chain(
node: &ruin_ui::LayoutNode,
focused_element: ElementId,
) -> Option<Vec<ElementId>> {
let mut chain = Vec::new();
if build_focused_ancestor_chain(node, focused_element, &mut chain) {
Some(chain)
} else {
None
}
}
pub(crate) fn build_focused_ancestor_chain(
node: &ruin_ui::LayoutNode,
focused_element: ElementId,
chain: &mut Vec<ElementId>,
) -> bool {
if node.element_id == Some(focused_element) {
if let Some(element_id) = node.element_id {
chain.push(element_id);
}
return true;
}
for child in &node.children {
if build_focused_ancestor_chain(child, focused_element, chain) {
if let Some(element_id) = node.element_id {
chain.push(element_id);
}
return true;
}
}
false
}
// View helper methods for attaching handlers — used by primitives
impl View {
pub(crate) fn with_press_handler(mut self, element_id: ElementId, handler: PressHandler) -> Self {
self.bindings.on_press.insert(element_id, handler);
self
}
pub(crate) fn with_scroll_handler(
mut self,
element_id: ElementId,
handler: ScrollHandler,
) -> Self {
self.bindings.on_scroll.insert(element_id, handler);
self
}
pub(crate) fn with_key_handler(
mut self,
element_id: ElementId,
handler: KeyHandler,
) -> Self {
self.bindings.on_key.insert(element_id, handler);
self
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
use std::rc::Rc;
use ruin_ui::{CursorIcon, Edges, Element, RoutedPointerEvent};
use crate::context::allocate_element_id;
use crate::input::PressHandler;
use crate::primitives::ContainerProps;
use crate::text_style::{pop_text_style, push_text_style};
use crate::view::{Children, View};
use crate::surfaces;
pub struct ButtonBuilder {
pub(crate) props: ContainerProps,
pub(crate) on_press: Option<PressHandler>,
}
pub fn button() -> ButtonBuilder {
ButtonBuilder {
props: ContainerProps::new(
Element::column()
.padding(Edges::symmetric(14.0, 10.0))
.background(surfaces::interactive())
.corner_radius(10.0)
.cursor(CursorIcon::Pointer)
.focusable(true),
),
on_press: None,
}
}
impl ButtonBuilder {
impl_props_methods!();
pub fn on_press(mut self, handler: impl Fn(&RoutedPointerEvent) + 'static) -> Self {
self.on_press = Some(Rc::new(handler));
self
}
pub fn text_style(mut self, style: crate::text_style::PartialTextStyle) -> Self {
self.props = self.props.text_style(style);
self
}
pub fn children(mut self, children: impl Children) -> View {
let id = allocate_element_id();
self.props.element = self.props.element.id(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);
}
let children_views = children.into_views();
if had_style {
pop_text_style();
}
let view = View::from_container(self.props.element, children_views);
match self.on_press {
Some(handler) => view.with_press_handler(id, handler),
None => view,
}
}
}

View File

@@ -0,0 +1,147 @@
use ruin_ui::{Color, Element, ElementId};
use crate::context::allocate_element_id;
use crate::converters::{IntoBorder, IntoEdges, IntoShadow};
use crate::hooks::{Signal, WidgetRef};
use crate::text_style::{PartialTextStyle, pop_text_style, push_text_style};
use crate::view::{Children, View};
/// Shared box-model properties common to all container-like builders.
pub struct ContainerProps {
pub(crate) element: Element,
pub(crate) widget_ref: Option<Signal<Option<ElementId>>>,
pub(crate) partial_text_style: Option<PartialTextStyle>,
}
impl ContainerProps {
pub(crate) fn new(element: Element) -> Self {
Self {
element,
widget_ref: None,
partial_text_style: None,
}
}
pub fn text_style(mut self, style: PartialTextStyle) -> Self {
self.partial_text_style = Some(style);
self
}
pub fn gap(mut self, gap: f32) -> Self {
self.element = self.element.gap(gap);
self
}
pub fn padding(mut self, padding: impl IntoEdges) -> Self {
self.element = self.element.padding(padding.into_edges());
self
}
pub fn background(mut self, color: Color) -> Self {
self.element = self.element.background(color);
self
}
pub fn border(mut self, border: impl IntoBorder) -> Self {
let border = border.into_border();
self.element = self.element.border(border.width, border.color);
self
}
pub fn border_radius(mut self, radius: f32) -> Self {
self.element = self.element.corner_radius(radius);
self
}
pub fn width(mut self, width: f32) -> Self {
self.element = self.element.width(width);
self
}
pub fn height(mut self, height: f32) -> Self {
self.element = self.element.height(height);
self
}
pub fn min_width(mut self, min_width: f32) -> Self {
self.element = self.element.min_width(min_width);
self
}
pub fn min_height(mut self, min_height: f32) -> Self {
self.element = self.element.min_height(min_height);
self
}
pub fn min_size(self, w: f32, h: f32) -> Self {
self.min_width(w).min_height(h)
}
pub fn flex(mut self, flex: f32) -> Self {
self.element = self.element.flex(flex);
self
}
pub fn shadow(mut self, shadow: impl IntoShadow) -> Self {
self.element = self.element.shadow(shadow.into_shadow());
self
}
pub fn widget_ref<T>(mut self, widget_ref: WidgetRef<T>) -> Self {
self.widget_ref = Some(widget_ref.element_id.clone());
self
}
}
pub struct ContainerBuilder {
pub(crate) props: ContainerProps,
}
pub fn column() -> ContainerBuilder {
ContainerBuilder {
props: ContainerProps::new(Element::column()),
}
}
pub fn row() -> ContainerBuilder {
ContainerBuilder {
props: ContainerProps::new(Element::row()),
}
}
pub fn block() -> ContainerBuilder {
ContainerBuilder {
props: ContainerProps::new(Element::column()),
}
}
impl ContainerBuilder {
impl_props_methods!();
pub fn shadow(mut self, shadow: impl IntoShadow) -> Self {
self.props = self.props.shadow(shadow);
self
}
pub fn text_style(mut self, style: PartialTextStyle) -> Self {
self.props = self.props.text_style(style);
self
}
pub fn children(mut self, children: impl Children) -> View {
if let Some(widget_ref) = &self.props.widget_ref {
let element_id = allocate_element_id();
self.props.element = self.props.element.id(element_id);
let _ = widget_ref.set(Some(element_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);
}
let children_views = children.into_views();
if had_style {
pop_text_style();
}
View::from_container(self.props.element, children_views)
}
}

View File

@@ -0,0 +1,63 @@
/// Generates builder methods that delegate to a `props: ContainerProps` field.
/// Must be invoked inside an `impl SomeBuilder { ... }` block.
macro_rules! impl_props_methods {
() => {
pub fn gap(mut self, gap: f32) -> Self {
self.props = self.props.gap(gap);
self
}
pub fn padding(mut self, padding: impl $crate::converters::IntoEdges) -> Self {
self.props = self.props.padding(padding);
self
}
pub fn background(mut self, color: ruin_ui::Color) -> Self {
self.props = self.props.background(color);
self
}
pub fn border(mut self, border: impl $crate::converters::IntoBorder) -> Self {
self.props = self.props.border(border);
self
}
pub fn border_radius(mut self, radius: f32) -> Self {
self.props = self.props.border_radius(radius);
self
}
pub fn width(mut self, width: f32) -> Self {
self.props = self.props.width(width);
self
}
pub fn height(mut self, height: f32) -> Self {
self.props = self.props.height(height);
self
}
pub fn min_width(mut self, min_width: f32) -> Self {
self.props = self.props.min_width(min_width);
self
}
pub fn min_height(mut self, min_height: f32) -> Self {
self.props = self.props.min_height(min_height);
self
}
pub fn min_size(self, w: f32, h: f32) -> Self {
self.min_width(w).min_height(h)
}
pub fn flex(mut self, flex: f32) -> Self {
self.props = self.props.flex(flex);
self
}
pub fn widget_ref<T>(mut self, widget_ref: $crate::hooks::WidgetRef<T>) -> Self {
self.props = self.props.widget_ref(widget_ref);
self
}
};
}
pub mod button;
pub mod container;
pub mod scroll_box;
pub mod text;
pub use button::{ButtonBuilder, button};
pub use container::{ContainerBuilder, ContainerProps, block, column, row};
pub use scroll_box::{ScrollBoxBuilder, scroll_box};
pub use text::{FontWeight, TextBuilder, TextRole, text};

View File

@@ -0,0 +1,160 @@
use std::rc::Rc;
use ruin_ui::{Element, KeyboardEventKind, PointerButton, RoutedPointerEventKind, ScrollbarStyle};
use crate::context::{allocate_element_id, with_hook_slot};
use crate::hooks::Signal;
use crate::input::{ScrollbarDrag, clamp_scroll_offset};
use crate::primitives::ContainerProps;
use crate::text_style::{pop_text_style, push_text_style};
use crate::view::{Children, View};
pub struct ScrollBoxBuilder {
pub(crate) props: ContainerProps,
pub(crate) offset_y: Option<Signal<f32>>,
pub(crate) drag: Signal<Option<ScrollbarDrag>>,
}
pub fn scroll_box() -> ScrollBoxBuilder {
ScrollBoxBuilder {
props: ContainerProps::new(Element::scroll_box(0.0)),
offset_y: None,
drag: with_hook_slot(|| Signal::new(None::<ScrollbarDrag>), |drag| drag.clone()),
}
}
impl ScrollBoxBuilder {
impl_props_methods!();
pub fn text_style(mut self, style: crate::text_style::PartialTextStyle) -> Self {
self.props = self.props.text_style(style);
self
}
pub fn scrollbar_style(mut self, style: ScrollbarStyle) -> Self {
self.props.element = self.props.element.scrollbar_style(style);
self
}
pub fn offset_y(mut self, offset_y: Signal<f32>) -> Self {
self.props.element = self.props.element.scroll_offset(offset_y.get());
self.offset_y = Some(offset_y);
self
}
pub fn children(mut self, children: impl Children) -> View {
let element_id = allocate_element_id();
self.props.element = self.props.element.id(element_id);
if let Some(widget_ref) = &self.props.widget_ref {
let _ = widget_ref.set(Some(element_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);
}
let children_views = children.into_views();
if had_style {
pop_text_style();
}
let mut view = View::from_container(self.props.element, children_views);
if let Some(offset_y) = self.offset_y {
let drag = self.drag;
let offset_y_for_pointer = offset_y.clone();
let offset_y_for_keys = offset_y.clone();
view = view.with_scroll_handler(
element_id,
Rc::new(move |event, interaction_tree| {
let Some(metrics) = interaction_tree.scroll_metrics_for_element(element_id)
else {
return;
};
match event.kind {
RoutedPointerEventKind::Scroll { delta } => {
offset_y_for_pointer.update(|value| {
*value =
clamp_scroll_offset(*value + delta.y, metrics.max_offset_y);
});
}
RoutedPointerEventKind::Down {
button: PointerButton::Primary,
} => {
let Some(thumb_rect) = metrics.scrollbar_thumb else {
return;
};
if !thumb_rect.contains(event.position) {
return;
}
let _ = drag.set(Some(ScrollbarDrag {
start_pointer_y: event.position.y,
start_offset_y: offset_y_for_pointer.get(),
}));
}
RoutedPointerEventKind::Move => {
let Some(drag_state) = drag.get() else {
return;
};
let Some(track_rect) = metrics.scrollbar_track else {
return;
};
let Some(thumb_rect) = metrics.scrollbar_thumb else {
return;
};
let thumb_travel =
(track_rect.size.height - thumb_rect.size.height).max(0.0);
if thumb_travel <= 0.0 || metrics.max_offset_y <= 0.0 {
return;
}
let pointer_delta = event.position.y - drag_state.start_pointer_y;
let next_offset = drag_state.start_offset_y
+ pointer_delta * (metrics.max_offset_y / thumb_travel);
offset_y_for_pointer.update(|value| {
*value = clamp_scroll_offset(next_offset, metrics.max_offset_y);
});
}
RoutedPointerEventKind::Up {
button: PointerButton::Primary,
} => {
let _ = drag.set(None);
}
_ => {}
}
}),
);
view = view.with_key_handler(
element_id,
Rc::new(move |event, interaction_tree| {
if event.kind != KeyboardEventKind::Pressed
|| event.modifiers.control
|| event.modifiers.alt
|| event.modifiers.super_key
{
return false;
}
let Some(metrics) = interaction_tree.scroll_metrics_for_element(element_id)
else {
return false;
};
let line_step = (metrics.viewport_rect.size.height * 0.12).clamp(28.0, 52.0);
let next_offset = match event.key {
ruin_ui::KeyboardKey::ArrowUp => offset_y_for_keys.get() - line_step,
ruin_ui::KeyboardKey::ArrowDown => offset_y_for_keys.get() + line_step,
ruin_ui::KeyboardKey::Home => 0.0,
ruin_ui::KeyboardKey::End => metrics.max_offset_y,
_ => return false,
};
let next_offset = clamp_scroll_offset(next_offset, metrics.max_offset_y);
let changed = (offset_y_for_keys.get() - next_offset).abs() > f32::EPSILON;
if changed {
let _ = offset_y_for_keys.set(next_offset);
}
changed
}),
);
}
view
}
}

View File

@@ -0,0 +1,129 @@
use ruin_ui::{Color, Element, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, TextWrap};
use crate::colors;
use crate::context::allocate_element_id;
use crate::text_style::current_text_style;
use crate::view::{TextChildren, View};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextRole {
Body,
Heading(u8),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FontWeight {
Normal,
Medium,
Semibold,
Bold,
}
impl FontWeight {
pub(crate) const fn to_text_span_weight(self) -> TextSpanWeight {
match self {
Self::Normal => TextSpanWeight::Normal,
Self::Medium => TextSpanWeight::Medium,
Self::Semibold => TextSpanWeight::Semibold,
Self::Bold => TextSpanWeight::Bold,
}
}
}
#[derive(Default)]
pub struct TextBuilder {
pub(crate) role: Option<TextRole>,
pub(crate) size: Option<f32>,
pub(crate) weight: Option<FontWeight>,
pub(crate) color: Option<Color>,
pub(crate) font_family: Option<TextFontFamily>,
pub(crate) wrap: Option<TextWrap>,
pub(crate) selectable: Option<bool>,
pub(crate) pointer_events: Option<bool>,
}
pub fn text() -> TextBuilder {
TextBuilder::default()
}
impl TextBuilder {
pub fn role(mut self, role: TextRole) -> Self {
self.role = Some(role);
self
}
pub fn size(mut self, size: f32) -> Self {
self.size = Some(size);
self
}
pub fn weight(mut self, weight: FontWeight) -> Self {
self.weight = Some(weight);
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn font_family(mut self, family: TextFontFamily) -> Self {
self.font_family = Some(family);
self
}
pub fn wrap(mut self, wrap: TextWrap) -> Self {
self.wrap = Some(wrap);
self
}
pub fn selectable(mut self, selectable: bool) -> Self {
self.selectable = Some(selectable);
self
}
pub fn pointer_events(mut self, pointer_events: bool) -> Self {
self.pointer_events = Some(pointer_events);
self
}
pub fn children(self, children: impl TextChildren) -> View {
let inherited = current_text_style();
let role = self.role.unwrap_or(TextRole::Body);
let size = self.size.or(inherited.size).unwrap_or(match role {
TextRole::Body => 18.0,
TextRole::Heading(1) => 32.0,
TextRole::Heading(2) => 28.0,
TextRole::Heading(_) => 24.0,
});
let color = self.color.or(inherited.color).unwrap_or(colors::text());
let weight = self.weight.or(inherited.weight).unwrap_or(match role {
TextRole::Body => FontWeight::Normal,
TextRole::Heading(_) => FontWeight::Semibold,
});
let wrap = self.wrap.or(inherited.wrap).unwrap_or(TextWrap::None);
let line_height = inherited.line_height.unwrap_or(size * 1.2);
let mut style = TextStyle::new(size, color)
.with_line_height(line_height)
.with_wrap(wrap);
let font_family = self.font_family.or(inherited.font_family);
if let Some(font_family) = font_family {
style = style.with_font_family(font_family);
}
let span = TextSpan::new(children.into_text())
.color(color)
.weight(weight.to_text_span_weight());
View::from_element(
Element::spans(
[span],
style.with_selectable(self.selectable.unwrap_or(true)),
)
.pointer_events(self.pointer_events.unwrap_or(true))
.id(allocate_element_id()),
)
}
}

View File

@@ -0,0 +1,17 @@
use ruin_ui::Color;
pub const fn canvas() -> Color {
Color::rgb(0x0F, 0x16, 0x25)
}
pub const fn raised() -> Color {
Color::rgb(0x1B, 0x26, 0x3D)
}
pub const fn interactive() -> Color {
Color::rgb(0x2B, 0x3A, 0x67)
}
pub const fn interactive_muted() -> Color {
Color::rgb(0x3D, 0x4B, 0x72)
}

View File

@@ -0,0 +1,61 @@
use std::cell::RefCell;
use ruin_ui::{Color, TextFontFamily, TextWrap};
use crate::primitives::FontWeight;
/// Partially-specified text style for context-based inheritance.
///
/// Set on a container via `.text_style(...)`. All descendant `text()` nodes inherit
/// any fields that are `Some`, falling back to their own defaults for fields that are `None`.
/// Inner (closer) ancestors take precedence over outer ancestors.
#[derive(Clone, Default)]
pub struct PartialTextStyle {
pub size: Option<f32>,
pub color: Option<Color>,
pub weight: Option<FontWeight>,
pub font_family: Option<TextFontFamily>,
pub wrap: Option<TextWrap>,
pub line_height: Option<f32>,
}
impl PartialTextStyle {
/// Merge `other` on top of `self` — `other`'s `Some` fields win.
fn merge_over(self, other: &PartialTextStyle) -> Self {
Self {
size: other.size.or(self.size),
color: other.color.or(self.color),
weight: other.weight.or(self.weight),
font_family: other.font_family.clone().or(self.font_family),
wrap: other.wrap.or(self.wrap),
line_height: other.line_height.or(self.line_height),
}
}
}
thread_local! {
static TEXT_STYLE_STACK: RefCell<Vec<PartialTextStyle>> = const { RefCell::new(Vec::new()) };
}
/// Push a partial style onto the context stack. Must be paired with `pop_text_style`.
pub(crate) fn push_text_style(style: PartialTextStyle) {
TEXT_STYLE_STACK.with(|stack| stack.borrow_mut().push(style));
}
/// Pop the top partial style from the context stack.
pub(crate) fn pop_text_style() {
TEXT_STYLE_STACK.with(|stack| {
stack.borrow_mut().pop();
});
}
/// Returns the effective `PartialTextStyle` at the current render position by merging
/// all layers from outermost to innermost (innermost wins).
pub(crate) fn current_text_style() -> PartialTextStyle {
TEXT_STYLE_STACK.with(|stack| {
stack
.borrow()
.iter()
.fold(PartialTextStyle::default(), |acc, layer| acc.merge_over(layer))
})
}

239
lib/ruin_app/src/view.rs Normal file
View File

@@ -0,0 +1,239 @@
use ruin_ui::Element;
use crate::Component;
use crate::input::EventBindings;
#[derive(Clone, Default)]
pub struct View {
pub(crate) element: Element,
pub(crate) bindings: EventBindings,
}
impl View {
pub fn from_element(element: Element) -> Self {
Self {
element,
bindings: EventBindings::default(),
}
}
pub fn element(&self) -> &Element {
&self.element
}
pub fn from_container(element: Element, children: Vec<View>) -> Self {
let mut composed = Self::from_element(element);
let mut element = composed.element;
for child in children {
element = element.child(child.element);
composed.bindings.extend(child.bindings);
}
composed.element = element;
composed
}
}
impl From<Element> for View {
fn from(element: Element) -> Self {
Self::from_element(element)
}
}
pub trait IntoView {
fn into_view(self) -> View;
}
impl IntoView for View {
fn into_view(self) -> View {
self
}
}
impl IntoView for Element {
fn into_view(self) -> View {
View::from_element(self)
}
}
impl<T: Component> IntoView for T {
fn into_view(self) -> View {
self.render()
}
}
pub trait Children {
fn into_views(self) -> Vec<View>;
}
#[derive(Clone, Default)]
pub struct ChildViews(pub(crate) Vec<View>);
impl ChildViews {
pub fn from_children(children: impl Children) -> Self {
Self(children.into_views())
}
pub fn into_vec(self) -> Vec<View> {
self.0
}
}
impl Children for () {
fn into_views(self) -> Vec<View> {
Vec::new()
}
}
impl<T: IntoView> Children for T {
fn into_views(self) -> Vec<View> {
vec![self.into_view()]
}
}
impl<T: IntoView> Children for Vec<T> {
fn into_views(self) -> Vec<View> {
self.into_iter().map(IntoView::into_view).collect()
}
}
impl Children for ChildViews {
fn into_views(self) -> Vec<View> {
self.0
}
}
/// Wraps a lazy closure so it can be used as container children.
///
/// The closure is called exactly once, inside the builder's `.children()` method,
/// after any `PartialTextStyle` context has been pushed. This enables text style
/// inheritance from ancestor containers.
///
/// The `view!` macro automatically generates `LazyChildren(|| vec![...])` for
/// primitive container nodes so that style context propagates correctly.
pub struct LazyChildren<F: FnOnce() -> Vec<View>>(pub F);
impl<F: FnOnce() -> Vec<View>> Children for LazyChildren<F> {
fn into_views(self) -> Vec<View> {
(self.0)()
}
}
macro_rules! impl_children_tuple {
($($name:ident),+ $(,)?) => {
#[allow(non_camel_case_types)]
impl<$($name: IntoView),+> Children for ($($name,)+) {
fn into_views(self) -> Vec<View> {
let ($($name,)+) = self;
vec![$($name.into_view(),)+]
}
}
};
}
impl_children_tuple!(a, b);
impl_children_tuple!(a, b, c);
impl_children_tuple!(a, b, c, d);
impl_children_tuple!(a, b, c, d, e);
impl_children_tuple!(a, b, c, d, e, f);
impl_children_tuple!(a, b, c, d, e, f, g);
impl_children_tuple!(a, b, c, d, e, f, g, h);
pub trait TextChildren {
fn into_text(self) -> String;
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TextValue(pub(crate) String);
impl TextValue {
pub fn from_text(children: impl TextChildren) -> Self {
Self(children.into_text())
}
pub fn into_string(self) -> String {
self.0
}
}
impl TextChildren for TextValue {
fn into_text(self) -> String {
self.0
}
}
impl TextChildren for &'static str {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for String {
fn into_text(self) -> String {
self
}
}
impl TextChildren for i32 {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for i64 {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for usize {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for u64 {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for f32 {
fn into_text(self) -> String {
self.to_string()
}
}
impl<T: std::fmt::Display + 'static> TextChildren for crate::hooks::Signal<T> {
fn into_text(self) -> String {
self.with(|value| value.to_string())
}
}
impl<T: std::fmt::Display + 'static> TextChildren for crate::hooks::Memo<T> {
fn into_text(self) -> String {
self.with(|value| value.to_string())
}
}
macro_rules! impl_text_children_tuple {
($($name:ident),+ $(,)?) => {
#[allow(non_camel_case_types)]
impl<$($name: TextChildren),+> TextChildren for ($($name,)+) {
fn into_text(self) -> String {
let ($($name,)+) = self;
let mut text = String::new();
$(text.push_str(&$name.into_text());)+
text
}
}
};
}
impl_text_children_tuple!(a, b);
impl_text_children_tuple!(a, b, c);
impl_text_children_tuple!(a, b, c, d);
impl_text_children_tuple!(a, b, c, d, e);
impl_text_children_tuple!(a, b, c, d, e, f);