Example 02 realized
This commit is contained in:
@@ -21,3 +21,7 @@ path = "example/00_bootstrap_and_counter.rs"
|
|||||||
[[example]]
|
[[example]]
|
||||||
name = "01_async_data_and_effects"
|
name = "01_async_data_and_effects"
|
||||||
path = "example/01_async_data_and_effects.rs"
|
path = "example/01_async_data_and_effects.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "02_widget_refs_and_commands"
|
||||||
|
path = "example/02_widget_refs_and_commands.rs"
|
||||||
|
|||||||
496
lib/ruin_app/example/02_widget_refs_and_commands.rs
Normal file
496
lib/ruin_app/example/02_widget_refs_and_commands.rs
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
use ruin_app::prelude::*;
|
||||||
|
|
||||||
|
#[ruin_runtime::async_main]
|
||||||
|
async fn main() -> ruin_app::Result<()> {
|
||||||
|
App::new()
|
||||||
|
.window(
|
||||||
|
Window::new()
|
||||||
|
.title("RUIN Widget Refs and Commands")
|
||||||
|
.app_id("dev.ruin.widget-refs")
|
||||||
|
.size(1100.0, 760.0),
|
||||||
|
)
|
||||||
|
.mount(view! {
|
||||||
|
WidgetRefsAndCommandsDemo() {}
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn WidgetRefsAndCommandsDemo() -> impl IntoView {
|
||||||
|
let selected_index = use_signal(|| 0_usize);
|
||||||
|
let last_command = use_signal(|| "No command has been run yet.".to_string());
|
||||||
|
let results_scroll = use_signal(|| 0.0_f32);
|
||||||
|
let results_list = use_widget_ref::<ScrollBoxWidget>();
|
||||||
|
|
||||||
|
use_window_title({
|
||||||
|
let selected_index = selected_index.clone();
|
||||||
|
move || format!("RUIN Commands ({})", COMMANDS[selected_index.get()].title)
|
||||||
|
});
|
||||||
|
|
||||||
|
use_shortcut(
|
||||||
|
Shortcut::new(Key::Character('k')).with_ctrl(),
|
||||||
|
ShortcutScope::Application,
|
||||||
|
{
|
||||||
|
let last_command = last_command.clone();
|
||||||
|
move || {
|
||||||
|
let _ = last_command.set(
|
||||||
|
"Application shortcut fired. Click inside the command list, then use \
|
||||||
|
ArrowUp / ArrowDown / Enter."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
use_shortcut(
|
||||||
|
Shortcut::new(Key::Enter),
|
||||||
|
ShortcutScope::FocusedWithin(results_list.focus_scope()),
|
||||||
|
{
|
||||||
|
let selected_index = selected_index.clone();
|
||||||
|
let last_command = last_command.clone();
|
||||||
|
move || {
|
||||||
|
let command = &COMMANDS[selected_index.get()];
|
||||||
|
let _ = last_command.set(format!("Executed: {}", command.title));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let selected = &COMMANDS[selected_index.get()];
|
||||||
|
|
||||||
|
view! {
|
||||||
|
column(gap = 16.0, padding = 24.0) {
|
||||||
|
text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold) {
|
||||||
|
"Widget refs and commands"
|
||||||
|
}
|
||||||
|
|
||||||
|
text(color = colors::muted(), wrap = TextWrap::Word) {
|
||||||
|
"This is a smaller truthful slice of example 02: the results list installs a widget \
|
||||||
|
ref-backed focus scope, typed shortcuts are explicit about application vs focused-within \
|
||||||
|
behavior, and the scroll box keeps its own default arrow-key scrolling."
|
||||||
|
}
|
||||||
|
|
||||||
|
block(
|
||||||
|
padding = 16.0,
|
||||||
|
gap = 10.0,
|
||||||
|
background = surfaces::raised(),
|
||||||
|
border_radius = 12.0,
|
||||||
|
) {
|
||||||
|
text(size = 18.0, weight = FontWeight::Semibold) { "Shortcuts" }
|
||||||
|
text(color = colors::muted(), wrap = TextWrap::Word) {
|
||||||
|
"Ctrl+K writes a global status message. Click any command row below to focus the \
|
||||||
|
list, then use ArrowUp / ArrowDown / Enter. The explicit shortcuts now override \
|
||||||
|
the scroll box's default arrow-key scrolling in this focused scope."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row(gap = 16.0) {
|
||||||
|
block(
|
||||||
|
flex = 1.0,
|
||||||
|
padding = 16.0,
|
||||||
|
gap = 10.0,
|
||||||
|
background = surfaces::raised(),
|
||||||
|
border_radius = 12.0,
|
||||||
|
) {
|
||||||
|
text(size = 18.0, weight = FontWeight::Semibold) { "Commands" }
|
||||||
|
|
||||||
|
button(
|
||||||
|
on_press = {
|
||||||
|
let selected_index = selected_index.clone();
|
||||||
|
let last_command = last_command.clone();
|
||||||
|
move |_| {
|
||||||
|
let command = &COMMANDS[selected_index.get()];
|
||||||
|
let _ = last_command.set(format!("Executed: {}", command.title));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
"Run selected command"
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll_box(
|
||||||
|
widget_ref = results_list.clone(),
|
||||||
|
offset_y = results_scroll.clone(),
|
||||||
|
height = 420.0,
|
||||||
|
padding = 12.0,
|
||||||
|
background = surfaces::canvas(),
|
||||||
|
border_radius = 10.0,
|
||||||
|
border = (2.0, colors::muted()),
|
||||||
|
) {
|
||||||
|
column(gap = 8.0) {
|
||||||
|
CommandRows(
|
||||||
|
selected_index = selected_index.clone(),
|
||||||
|
results_list = results_list.clone(),
|
||||||
|
results_scroll = results_scroll.clone(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
block(
|
||||||
|
width = 320.0,
|
||||||
|
padding = 16.0,
|
||||||
|
gap = 10.0,
|
||||||
|
background = surfaces::raised(),
|
||||||
|
border_radius = 12.0,
|
||||||
|
) {
|
||||||
|
text(size = 18.0, weight = FontWeight::Semibold) { "Selection" }
|
||||||
|
text() { "title = "; selected.title }
|
||||||
|
text(color = colors::muted(), wrap = TextWrap::Word) { selected.subtitle }
|
||||||
|
|
||||||
|
block(
|
||||||
|
padding = 12.0,
|
||||||
|
gap = 8.0,
|
||||||
|
background = surfaces::canvas(),
|
||||||
|
border_radius = 10.0,
|
||||||
|
) {
|
||||||
|
text(size = 16.0, weight = FontWeight::Semibold) { "Last command" }
|
||||||
|
text(color = colors::muted(), wrap = TextWrap::Word) { last_command.clone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn CommandRows(
|
||||||
|
selected_index: Signal<usize>,
|
||||||
|
results_list: WidgetRef<ScrollBoxWidget>,
|
||||||
|
results_scroll: Signal<f32>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let row0 = use_widget_ref::<BlockWidget>();
|
||||||
|
let row1 = use_widget_ref::<BlockWidget>();
|
||||||
|
let row2 = use_widget_ref::<BlockWidget>();
|
||||||
|
let row3 = use_widget_ref::<BlockWidget>();
|
||||||
|
let row4 = use_widget_ref::<BlockWidget>();
|
||||||
|
let row5 = use_widget_ref::<BlockWidget>();
|
||||||
|
let row6 = use_widget_ref::<BlockWidget>();
|
||||||
|
let row7 = use_widget_ref::<BlockWidget>();
|
||||||
|
let row8 = use_widget_ref::<BlockWidget>();
|
||||||
|
let row9 = use_widget_ref::<BlockWidget>();
|
||||||
|
|
||||||
|
use_shortcut_with_context(
|
||||||
|
Shortcut::new(Key::ArrowDown),
|
||||||
|
ShortcutScope::FocusedWithin(results_list.focus_scope()),
|
||||||
|
{
|
||||||
|
let selected_index = selected_index.clone();
|
||||||
|
let results_list = results_list.clone();
|
||||||
|
let results_scroll = results_scroll.clone();
|
||||||
|
let row0 = row0.clone();
|
||||||
|
let row1 = row1.clone();
|
||||||
|
let row2 = row2.clone();
|
||||||
|
let row3 = row3.clone();
|
||||||
|
let row4 = row4.clone();
|
||||||
|
let row5 = row5.clone();
|
||||||
|
let row6 = row6.clone();
|
||||||
|
let row7 = row7.clone();
|
||||||
|
let row8 = row8.clone();
|
||||||
|
let row9 = row9.clone();
|
||||||
|
move |interaction_tree| {
|
||||||
|
let next_index = (selected_index.get() + 1).min(COMMANDS.len().saturating_sub(1));
|
||||||
|
let _ = selected_index.set(next_index);
|
||||||
|
match next_index {
|
||||||
|
0 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row0,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
1 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row1,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
2 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row2,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
3 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row3,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
4 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row4,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
5 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row5,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
6 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row6,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
7 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row7,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
8 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row8,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
9 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row9,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
use_shortcut_with_context(
|
||||||
|
Shortcut::new(Key::ArrowUp),
|
||||||
|
ShortcutScope::FocusedWithin(results_list.focus_scope()),
|
||||||
|
{
|
||||||
|
let selected_index = selected_index.clone();
|
||||||
|
let results_list = results_list.clone();
|
||||||
|
let results_scroll = results_scroll.clone();
|
||||||
|
let row0 = row0.clone();
|
||||||
|
let row1 = row1.clone();
|
||||||
|
let row2 = row2.clone();
|
||||||
|
let row3 = row3.clone();
|
||||||
|
let row4 = row4.clone();
|
||||||
|
let row5 = row5.clone();
|
||||||
|
let row6 = row6.clone();
|
||||||
|
let row7 = row7.clone();
|
||||||
|
let row8 = row8.clone();
|
||||||
|
let row9 = row9.clone();
|
||||||
|
move |interaction_tree| {
|
||||||
|
let next_index = selected_index.get().saturating_sub(1);
|
||||||
|
let _ = selected_index.set(next_index);
|
||||||
|
match next_index {
|
||||||
|
0 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row0,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
1 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row1,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
2 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row2,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
3 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row3,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
4 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row4,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
5 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row5,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
6 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row6,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
7 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row7,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
8 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row8,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
9 => scroll_widget_into_view(
|
||||||
|
interaction_tree,
|
||||||
|
&results_list,
|
||||||
|
&row9,
|
||||||
|
&results_scroll,
|
||||||
|
),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
column(gap = 8.0) {
|
||||||
|
column(gap = 8.0) {
|
||||||
|
CommandRow0(selected_index = selected_index.clone(), row_ref = row0.clone()) {}
|
||||||
|
CommandRow1(selected_index = selected_index.clone(), row_ref = row1.clone()) {}
|
||||||
|
CommandRow2(selected_index = selected_index.clone(), row_ref = row2.clone()) {}
|
||||||
|
CommandRow3(selected_index = selected_index.clone(), row_ref = row3.clone()) {}
|
||||||
|
CommandRow4(selected_index = selected_index.clone(), row_ref = row4.clone()) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
column(gap = 8.0) {
|
||||||
|
CommandRow5(selected_index = selected_index.clone(), row_ref = row5.clone()) {}
|
||||||
|
CommandRow6(selected_index = selected_index.clone(), row_ref = row6.clone()) {}
|
||||||
|
CommandRow7(selected_index = selected_index.clone(), row_ref = row7.clone()) {}
|
||||||
|
CommandRow8(selected_index = selected_index.clone(), row_ref = row8.clone()) {}
|
||||||
|
CommandRow9(selected_index = selected_index.clone(), row_ref = row9.clone()) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! command_row_component {
|
||||||
|
($name:ident, $index:expr) => {
|
||||||
|
#[component]
|
||||||
|
fn $name(selected_index: Signal<usize>, row_ref: WidgetRef<BlockWidget>) -> impl IntoView {
|
||||||
|
let command = &COMMANDS[$index];
|
||||||
|
let prefix = if selected_index.get() == $index {
|
||||||
|
"> "
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
let background = if selected_index.get() == $index {
|
||||||
|
surfaces::interactive()
|
||||||
|
} else {
|
||||||
|
surfaces::interactive_muted()
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
block(
|
||||||
|
widget_ref = row_ref,
|
||||||
|
padding = 12.0,
|
||||||
|
gap = 6.0,
|
||||||
|
background = background,
|
||||||
|
border_radius = 10.0,
|
||||||
|
) {
|
||||||
|
text(weight = FontWeight::Semibold) {
|
||||||
|
prefix;
|
||||||
|
command.title
|
||||||
|
}
|
||||||
|
|
||||||
|
text(color = colors::muted(), wrap = TextWrap::Word) {
|
||||||
|
command.subtitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
command_row_component!(CommandRow0, 0);
|
||||||
|
command_row_component!(CommandRow1, 1);
|
||||||
|
command_row_component!(CommandRow2, 2);
|
||||||
|
command_row_component!(CommandRow3, 3);
|
||||||
|
command_row_component!(CommandRow4, 4);
|
||||||
|
command_row_component!(CommandRow5, 5);
|
||||||
|
command_row_component!(CommandRow6, 6);
|
||||||
|
command_row_component!(CommandRow7, 7);
|
||||||
|
command_row_component!(CommandRow8, 8);
|
||||||
|
command_row_component!(CommandRow9, 9);
|
||||||
|
|
||||||
|
struct Command {
|
||||||
|
title: &'static str,
|
||||||
|
subtitle: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
static COMMANDS: &[Command] = &[
|
||||||
|
Command {
|
||||||
|
title: "Open workspace search",
|
||||||
|
subtitle: "Jump into a project-wide search panel with the current repository root prefilled.",
|
||||||
|
},
|
||||||
|
Command {
|
||||||
|
title: "Show diagnostics",
|
||||||
|
subtitle: "Collect the latest compile, lint, and runtime diagnostics into a single review list.",
|
||||||
|
},
|
||||||
|
Command {
|
||||||
|
title: "Reload shaders",
|
||||||
|
subtitle: "Force a renderer-side asset refresh without restarting the process.",
|
||||||
|
},
|
||||||
|
Command {
|
||||||
|
title: "Toggle layout bounds",
|
||||||
|
subtitle: "Render debug outlines for container and text layout boxes.",
|
||||||
|
},
|
||||||
|
Command {
|
||||||
|
title: "Profile text layout",
|
||||||
|
subtitle: "Capture a narrow text-layout performance sample for the active window.",
|
||||||
|
},
|
||||||
|
Command {
|
||||||
|
title: "Dump interaction tree",
|
||||||
|
subtitle: "Serialize the current focus and hit-test tree for debugging pointer routing.",
|
||||||
|
},
|
||||||
|
Command {
|
||||||
|
title: "Clear transient overlays",
|
||||||
|
subtitle: "Dismiss temporary banners, confirmations, and other transient UI state.",
|
||||||
|
},
|
||||||
|
Command {
|
||||||
|
title: "Restart background tasks",
|
||||||
|
subtitle: "Cancel and requeue currently tracked background work items.",
|
||||||
|
},
|
||||||
|
Command {
|
||||||
|
title: "Copy scene summary",
|
||||||
|
subtitle: "Place a concise scene-graph summary onto the clipboard for issue reports.",
|
||||||
|
},
|
||||||
|
Command {
|
||||||
|
title: "Open command help",
|
||||||
|
subtitle: "Explain the currently wired shortcut scopes and default list interactions.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
fn scroll_widget_into_view<T>(
|
||||||
|
interaction_tree: &InteractionTree,
|
||||||
|
scroll_box: &WidgetRef<ScrollBoxWidget>,
|
||||||
|
item: &WidgetRef<T>,
|
||||||
|
scroll_offset: &Signal<f32>,
|
||||||
|
) {
|
||||||
|
let Some(scroll_box_id) = scroll_box.element_id() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(item_id) = item.element_id() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(metrics) = interaction_tree.scroll_metrics_for_element(scroll_box_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(item_rect) = interaction_tree.rect_for_element(item_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let viewport_top = metrics.viewport_rect.origin.y;
|
||||||
|
let viewport_bottom = viewport_top + metrics.viewport_rect.size.height;
|
||||||
|
let item_top = item_rect.origin.y;
|
||||||
|
let item_bottom = item_rect.origin.y + item_rect.size.height;
|
||||||
|
|
||||||
|
scroll_offset.update(|offset| {
|
||||||
|
let mut next_offset = *offset;
|
||||||
|
if item_top < viewport_top {
|
||||||
|
next_offset -= viewport_top - item_top;
|
||||||
|
} else if item_bottom > viewport_bottom {
|
||||||
|
next_offset += item_bottom - viewport_bottom;
|
||||||
|
}
|
||||||
|
*offset = next_offset.clamp(0.0, metrics.max_offset_y);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use std::collections::HashMap;
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::iter;
|
use std::iter;
|
||||||
|
use std::marker::PhantomData;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use ruin_reactivity::effect;
|
use ruin_reactivity::effect;
|
||||||
@@ -148,6 +149,7 @@ impl<M: Mountable> MountedApp<M> {
|
|||||||
let text_system = Rc::new(RefCell::new(TextSystem::new()));
|
let text_system = Rc::new(RefCell::new(TextSystem::new()));
|
||||||
let interaction_tree = Rc::new(RefCell::new(None::<InteractionTree>));
|
let interaction_tree = Rc::new(RefCell::new(None::<InteractionTree>));
|
||||||
let bindings = Rc::new(RefCell::new(EventBindings::default()));
|
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 current_title = Rc::new(RefCell::new(None::<String>));
|
||||||
let mut input_state = InputState::new();
|
let mut input_state = InputState::new();
|
||||||
let mut pointer_router = PointerRouter::new();
|
let mut pointer_router = PointerRouter::new();
|
||||||
@@ -158,6 +160,7 @@ impl<M: Mountable> MountedApp<M> {
|
|||||||
let text_system = Rc::clone(&text_system);
|
let text_system = Rc::clone(&text_system);
|
||||||
let interaction_tree = Rc::clone(&interaction_tree);
|
let interaction_tree = Rc::clone(&interaction_tree);
|
||||||
let bindings = Rc::clone(&bindings);
|
let bindings = Rc::clone(&bindings);
|
||||||
|
let shortcuts = Rc::clone(&shortcuts);
|
||||||
let current_title = Rc::clone(¤t_title);
|
let current_title = Rc::clone(¤t_title);
|
||||||
let root = Rc::clone(&root);
|
let root = Rc::clone(&root);
|
||||||
let render_state = Rc::clone(&render_state);
|
let render_state = Rc::clone(&render_state);
|
||||||
@@ -193,6 +196,7 @@ impl<M: Mountable> MountedApp<M> {
|
|||||||
|
|
||||||
*interaction_tree.borrow_mut() = Some(next_interaction_tree);
|
*interaction_tree.borrow_mut() = Some(next_interaction_tree);
|
||||||
*bindings.borrow_mut() = render_output.view.bindings;
|
*bindings.borrow_mut() = render_output.view.bindings;
|
||||||
|
*shortcuts.borrow_mut() = render_output.side_effects.shortcuts.clone();
|
||||||
window
|
window
|
||||||
.replace_scene(scene)
|
.replace_scene(scene)
|
||||||
.expect("window should remain alive while the app is running");
|
.expect("window should remain alive while the app is running");
|
||||||
@@ -226,6 +230,7 @@ impl<M: Mountable> MountedApp<M> {
|
|||||||
Self::handle_keyboard_event(
|
Self::handle_keyboard_event(
|
||||||
&interaction_tree,
|
&interaction_tree,
|
||||||
&bindings,
|
&bindings,
|
||||||
|
&shortcuts,
|
||||||
&input_state,
|
&input_state,
|
||||||
event,
|
event,
|
||||||
)?;
|
)?;
|
||||||
@@ -309,6 +314,7 @@ impl<M: Mountable> MountedApp<M> {
|
|||||||
fn handle_keyboard_event(
|
fn handle_keyboard_event(
|
||||||
interaction_tree: &RefCell<Option<InteractionTree>>,
|
interaction_tree: &RefCell<Option<InteractionTree>>,
|
||||||
bindings: &RefCell<EventBindings>,
|
bindings: &RefCell<EventBindings>,
|
||||||
|
shortcuts: &RefCell<Vec<ShortcutBinding>>,
|
||||||
input_state: &InputState,
|
input_state: &InputState,
|
||||||
event: KeyboardEvent,
|
event: KeyboardEvent,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -316,6 +322,17 @@ impl<M: Mountable> MountedApp<M> {
|
|||||||
let Some(interaction_tree) = interaction_tree.as_ref() else {
|
let Some(interaction_tree) = interaction_tree.as_ref() else {
|
||||||
return Ok(());
|
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
|
bindings
|
||||||
.borrow()
|
.borrow()
|
||||||
.dispatch_key(input_state.focused_element, &event, interaction_tree);
|
.dispatch_key(input_state.focused_element, &event, interaction_tree);
|
||||||
@@ -676,18 +693,21 @@ pub mod surfaces {
|
|||||||
pub fn column() -> ContainerBuilder {
|
pub fn column() -> ContainerBuilder {
|
||||||
ContainerBuilder {
|
ContainerBuilder {
|
||||||
element: Element::column().background(surfaces::canvas()),
|
element: Element::column().background(surfaces::canvas()),
|
||||||
|
widget_ref: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn row() -> ContainerBuilder {
|
pub fn row() -> ContainerBuilder {
|
||||||
ContainerBuilder {
|
ContainerBuilder {
|
||||||
element: Element::row(),
|
element: Element::row(),
|
||||||
|
widget_ref: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn block() -> ContainerBuilder {
|
pub fn block() -> ContainerBuilder {
|
||||||
ContainerBuilder {
|
ContainerBuilder {
|
||||||
element: Element::column(),
|
element: Element::column(),
|
||||||
|
widget_ref: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,11 +724,13 @@ pub fn scroll_box() -> ScrollBoxBuilder {
|
|||||||
element: Element::scroll_box(0.0),
|
element: Element::scroll_box(0.0),
|
||||||
offset_y: None,
|
offset_y: None,
|
||||||
drag: with_hook_slot(|| Signal::new(None::<ScrollbarDrag>), |drag| drag.clone()),
|
drag: with_hook_slot(|| Signal::new(None::<ScrollbarDrag>), |drag| drag.clone()),
|
||||||
|
widget_ref: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ContainerBuilder {
|
pub struct ContainerBuilder {
|
||||||
element: Element,
|
element: Element,
|
||||||
|
widget_ref: Option<Signal<Option<ElementId>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContainerBuilder {
|
impl ContainerBuilder {
|
||||||
@@ -753,7 +775,17 @@ impl ContainerBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn children(self, children: impl Children) -> View {
|
pub fn widget_ref<T>(mut self, widget_ref: WidgetRef<T>) -> Self {
|
||||||
|
self.widget_ref = Some(widget_ref.element_id.clone());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn children(mut self, children: impl Children) -> View {
|
||||||
|
if let Some(widget_ref) = &self.widget_ref {
|
||||||
|
let element_id = allocate_element_id();
|
||||||
|
self.element = self.element.id(element_id);
|
||||||
|
let _ = widget_ref.set(Some(element_id));
|
||||||
|
}
|
||||||
View::from_container(self.element, children.into_views())
|
View::from_container(self.element, children.into_views())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -762,6 +794,7 @@ pub struct ScrollBoxBuilder {
|
|||||||
element: Element,
|
element: Element,
|
||||||
offset_y: Option<Signal<f32>>,
|
offset_y: Option<Signal<f32>>,
|
||||||
drag: Signal<Option<ScrollbarDrag>>,
|
drag: Signal<Option<ScrollbarDrag>>,
|
||||||
|
widget_ref: Option<Signal<Option<ElementId>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScrollBoxBuilder {
|
impl ScrollBoxBuilder {
|
||||||
@@ -812,9 +845,17 @@ impl ScrollBoxBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn widget_ref<T>(mut self, widget_ref: WidgetRef<T>) -> Self {
|
||||||
|
self.widget_ref = Some(widget_ref.element_id.clone());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn children(mut self, children: impl Children) -> View {
|
pub fn children(mut self, children: impl Children) -> View {
|
||||||
let element_id = allocate_element_id();
|
let element_id = allocate_element_id();
|
||||||
self.element = self.element.id(element_id);
|
self.element = self.element.id(element_id);
|
||||||
|
if let Some(widget_ref) = &self.widget_ref {
|
||||||
|
let _ = widget_ref.set(Some(element_id));
|
||||||
|
}
|
||||||
|
|
||||||
let mut view = View::from_container(self.element, children.into_views());
|
let mut view = View::from_container(self.element, children.into_views());
|
||||||
if let Some(offset_y) = self.offset_y {
|
if let Some(offset_y) = self.offset_y {
|
||||||
@@ -1129,6 +1170,169 @@ pub fn use_window_title(compute: impl FnOnce() -> String) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum Key {
|
||||||
|
Character(char),
|
||||||
|
Enter,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Home,
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Key {
|
||||||
|
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 {
|
||||||
|
key: Key,
|
||||||
|
control: bool,
|
||||||
|
shift: bool,
|
||||||
|
alt: bool,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
element_id: Signal<Option<ElementId>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum ShortcutScope {
|
||||||
|
Application,
|
||||||
|
FocusedWithin(FocusScope),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShortcutScope {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
element_contains_element(interaction_tree, scope_element, focused_element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WidgetRef<T> {
|
||||||
|
element_id: Signal<Option<ElementId>>,
|
||||||
|
_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> {
|
||||||
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct Resource<T, E> {
|
pub struct Resource<T, E> {
|
||||||
state: Signal<ResourceState<T, E>>,
|
state: Signal<ResourceState<T, E>>,
|
||||||
@@ -1190,9 +1394,32 @@ struct RenderState {
|
|||||||
next_element_id: StdCell<u64>,
|
next_element_id: StdCell<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default, PartialEq)]
|
#[derive(Clone)]
|
||||||
|
struct ShortcutBinding {
|
||||||
|
shortcut: Shortcut,
|
||||||
|
scope: ShortcutScope,
|
||||||
|
action: Rc<dyn Fn(&InteractionTree)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShortcutBinding {
|
||||||
|
fn matches(
|
||||||
|
&self,
|
||||||
|
event: &KeyboardEvent,
|
||||||
|
focused_element: Option<ElementId>,
|
||||||
|
interaction_tree: &InteractionTree,
|
||||||
|
) -> bool {
|
||||||
|
self.shortcut.matches(event) && self.scope.matches(focused_element, interaction_tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trigger(&self, interaction_tree: &InteractionTree) {
|
||||||
|
(self.action)(interaction_tree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
struct RenderSideEffects {
|
struct RenderSideEffects {
|
||||||
window_title: Option<String>,
|
window_title: Option<String>,
|
||||||
|
shortcuts: Vec<ShortcutBinding>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -1384,10 +1611,8 @@ impl EventBindings {
|
|||||||
event: &KeyboardEvent,
|
event: &KeyboardEvent,
|
||||||
interaction_tree: &InteractionTree,
|
interaction_tree: &InteractionTree,
|
||||||
) {
|
) {
|
||||||
let Some(element_id) = focused_element else {
|
let Some(handler) = key_handler_for_focus(&self.on_key, focused_element, interaction_tree)
|
||||||
return;
|
else {
|
||||||
};
|
|
||||||
let Some(handler) = self.on_key.get(&element_id) else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let _ = handler(event, interaction_tree);
|
let _ = handler(event, interaction_tree);
|
||||||
@@ -1526,18 +1751,98 @@ fn focused_element_for_pointer(
|
|||||||
.find_map(|target| target.focusable.then_some(target.element_id).flatten())
|
.find_map(|target| target.focusable.then_some(target.element_id).flatten())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
.rev()
|
||||||
|
.find_map(|element_id| handlers.get(&element_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
pub mod prelude {
|
pub mod prelude {
|
||||||
pub use crate::{
|
pub use crate::{
|
||||||
App, ButtonBuilder, Children, Component, ContainerBuilder, FontWeight, IntoBorder,
|
App, BlockWidget, ButtonBuilder, Children, Component, ContainerBuilder, FocusScope,
|
||||||
IntoEdges, IntoView, Memo, Mountable, Pending, Ready, Resource, ResourceState, Result,
|
FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable, Pending, Ready,
|
||||||
ScrollBoxBuilder, Signal, TextBuilder, TextChildren, TextRole, View, Window, block, button,
|
Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, Shortcut,
|
||||||
colors, column, component, row, scroll_box, surfaces, text, use_effect, use_memo,
|
ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, View, WidgetRef, Window, block,
|
||||||
use_resource, use_signal, use_window_title, view,
|
button, colors, column, component, row, scroll_box, surfaces, text, use_effect, use_memo,
|
||||||
|
use_resource, use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref,
|
||||||
|
use_window_title, view,
|
||||||
};
|
};
|
||||||
pub use ruin_ui::{
|
pub use ruin_ui::{
|
||||||
Border, Color, CursorIcon, Edges, Element, ElementId, PointerButton, PointerEventKind,
|
Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton,
|
||||||
RoutedPointerEvent, RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextStyle,
|
PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind, ScrollbarStyle,
|
||||||
TextWrap, UiSize,
|
TextFontFamily, TextStyle, TextWrap, UiSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
struct SignalInner<T> {
|
struct SignalInner<T> {
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ fn expand_component(mut function: ItemFn) -> Result<proc_macro2::TokenStream> {
|
|||||||
Ok(quote! {
|
Ok(quote! {
|
||||||
#function
|
#function
|
||||||
|
|
||||||
|
#[allow(non_snake_case)]
|
||||||
#component_tokens
|
#component_tokens
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ impl InteractionTree {
|
|||||||
pub fn scroll_metrics_for_element(&self, element_id: ElementId) -> Option<&ScrollMetrics> {
|
pub fn scroll_metrics_for_element(&self, element_id: ElementId) -> Option<&ScrollMetrics> {
|
||||||
scroll_metrics_for_element_node(&self.root, element_id)
|
scroll_metrics_for_element_node(&self.root, element_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn rect_for_element(&self, element_id: ElementId) -> Option<Rect> {
|
||||||
|
rect_for_element_node(&self.root, element_id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn layout_snapshot(version: u64, logical_size: UiSize, root: &Element) -> LayoutSnapshot {
|
pub fn layout_snapshot(version: u64, logical_size: UiSize, root: &Element) -> LayoutSnapshot {
|
||||||
@@ -309,7 +313,9 @@ fn layout_element(
|
|||||||
perf_stats,
|
perf_stats,
|
||||||
);
|
);
|
||||||
let provisional_content_height = content_size.height.max(viewport_rect.size.height);
|
let provisional_content_height = content_size.height.max(viewport_rect.size.height);
|
||||||
let mut offset_y = scroll_box.offset_y.max(0.0);
|
let provisional_max_offset_y =
|
||||||
|
(provisional_content_height - viewport_rect.size.height).max(0.0);
|
||||||
|
let mut offset_y = scroll_box.offset_y.max(0.0).min(provisional_max_offset_y);
|
||||||
|
|
||||||
if viewport_rect.size.width > 0.0 && viewport_rect.size.height > 0.0 {
|
if viewport_rect.size.width > 0.0 && viewport_rect.size.height > 0.0 {
|
||||||
scene.push_clip(viewport_rect, 0.0);
|
scene.push_clip(viewport_rect, 0.0);
|
||||||
@@ -509,6 +515,18 @@ fn scroll_metrics_for_element_node(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rect_for_element_node(node: &LayoutNode, element_id: ElementId) -> Option<Rect> {
|
||||||
|
if node.element_id == Some(element_id) {
|
||||||
|
return Some(node.rect);
|
||||||
|
}
|
||||||
|
for child in &node.children {
|
||||||
|
if let Some(rect) = rect_for_element_node(child, element_id) {
|
||||||
|
return Some(rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn point_hits_node_shape(node: &LayoutNode, point: crate::scene::Point) -> bool {
|
fn point_hits_node_shape(node: &LayoutNode, point: crate::scene::Point) -> bool {
|
||||||
node.rect.contains(point)
|
node.rect.contains(point)
|
||||||
&& (node.corner_radius <= 0.0
|
&& (node.corner_radius <= 0.0
|
||||||
@@ -1598,6 +1616,51 @@ mod tests {
|
|||||||
assert!(scroll_metrics.scrollbar_thumb.is_some());
|
assert!(scroll_metrics.scrollbar_thumb.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_box_clamps_stale_offset_before_laying_out_reflowed_content() {
|
||||||
|
let scrollbox_id = ElementId::new(19);
|
||||||
|
let root = Element::column().child(
|
||||||
|
Element::scroll_box(240.0)
|
||||||
|
.id(scrollbox_id)
|
||||||
|
.width(320.0)
|
||||||
|
.height(120.0)
|
||||||
|
.padding(Edges::all(8.0))
|
||||||
|
.child(Element::paragraph(
|
||||||
|
"When the viewport becomes wider, wrapped scroll-box content can get much \
|
||||||
|
shorter. Layout should clamp any now-invalid stale offset before positioning \
|
||||||
|
the children so the viewport does not open up an empty gap at the bottom.",
|
||||||
|
TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||||
|
.with_line_height(22.0)
|
||||||
|
.with_wrap(TextWrap::Word),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let snapshot = layout_snapshot(1, UiSize::new(360.0, 220.0), &root);
|
||||||
|
let scroll_metrics = snapshot
|
||||||
|
.interaction_tree
|
||||||
|
.scroll_metrics_for_element(scrollbox_id)
|
||||||
|
.expect("scroll box should expose scroll metrics");
|
||||||
|
let visible_text = snapshot
|
||||||
|
.scene
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find_map(|item| match item {
|
||||||
|
DisplayItem::Text(text) => Some(text),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("scroll box should still emit text");
|
||||||
|
|
||||||
|
let text_bounds = visible_text
|
||||||
|
.bounds
|
||||||
|
.expect("prepared text should expose bounds for wrapped content");
|
||||||
|
let text_bottom = visible_text.origin.y + text_bounds.height;
|
||||||
|
let viewport_bottom =
|
||||||
|
scroll_metrics.viewport_rect.origin.y + scroll_metrics.viewport_rect.size.height;
|
||||||
|
|
||||||
|
assert!(scroll_metrics.offset_y <= scroll_metrics.max_offset_y);
|
||||||
|
assert!(text_bottom >= viewport_bottom - 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interaction_tree_hit_test_returns_deepest_pointer_target() {
|
fn interaction_tree_hit_test_returns_deepest_pointer_target() {
|
||||||
let root = Element::column()
|
let root = Element::column()
|
||||||
|
|||||||
@@ -43,10 +43,20 @@ struct TextVertex {
|
|||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
struct ActiveClip {
|
struct ActiveClip {
|
||||||
|
rect_active: bool,
|
||||||
rect: Option<Rect>,
|
rect: Option<Rect>,
|
||||||
rounded: Option<(Rect, f32)>,
|
rounded: Option<(Rect, f32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ActiveClip {
|
||||||
|
fn resolved_rect(self) -> Option<Rect> {
|
||||||
|
if !self.rect_active {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(self.rect.unwrap_or_else(empty_clip_rect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
struct PixelRect {
|
struct PixelRect {
|
||||||
left: i32,
|
left: i32,
|
||||||
@@ -995,8 +1005,20 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
let text_bounds_clip = text.bounds.map(|bounds| {
|
let text_bounds_clip = text.bounds.map(|bounds| {
|
||||||
Rect::new(text.origin.x, text.origin.y, bounds.width, bounds.height)
|
Rect::new(text.origin.x, text.origin.y, bounds.width, bounds.height)
|
||||||
});
|
});
|
||||||
let clip_rect =
|
let logical_clip_rect =
|
||||||
intersect_rects(active_clip.rect, text_bounds_clip).map(rect_to_pixel_rect);
|
match (active_clip.rect_active, active_clip.rect, text_bounds_clip) {
|
||||||
|
(true, Some(active_clip_rect), Some(text_bounds_rect)) => {
|
||||||
|
intersect_rects(Some(active_clip_rect), Some(text_bounds_rect))
|
||||||
|
}
|
||||||
|
(true, Some(active_clip_rect), None) => Some(active_clip_rect),
|
||||||
|
(true, None, _) => None,
|
||||||
|
(false, _, Some(text_bounds_rect)) => Some(text_bounds_rect),
|
||||||
|
(false, _, None) => None,
|
||||||
|
};
|
||||||
|
if active_clip.rect_active && logical_clip_rect.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let clip_rect = logical_clip_rect.map(rect_to_pixel_rect);
|
||||||
|
|
||||||
for glyph in &text.glyphs {
|
for glyph in &text.glyphs {
|
||||||
let Some(cache_key) = glyph.cache_key else {
|
let Some(cache_key) = glyph.cache_key else {
|
||||||
@@ -1173,7 +1195,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
.image_cache
|
.image_cache
|
||||||
.get(&key)
|
.get(&key)
|
||||||
.expect("image cache entry should exist after insertion");
|
.expect("image cache entry should exist after insertion");
|
||||||
let vertices = build_image_vertices(image.rect, image.uv_rect, logical_size, clip);
|
let vertices = build_image_vertices(image.rect, image.uv_rect, logical_size, clip)?;
|
||||||
let vertex_buffer = self
|
let vertex_buffer = self
|
||||||
.device
|
.device
|
||||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
@@ -1210,7 +1232,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
text.origin.x + cached.origin_offset.x,
|
text.origin.x + cached.origin_offset.x,
|
||||||
text.origin.y + cached.origin_offset.y,
|
text.origin.y + cached.origin_offset.y,
|
||||||
);
|
);
|
||||||
let vertices = build_text_vertices(origin, cached.size, logical_size, clip);
|
let vertices = build_text_vertices(origin, cached.size, logical_size, clip)?;
|
||||||
let vertex_buffer = self
|
let vertex_buffer = self
|
||||||
.device
|
.device
|
||||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
@@ -1635,7 +1657,7 @@ fn build_text_vertices(
|
|||||||
size: UiSize,
|
size: UiSize,
|
||||||
logical_size: UiSize,
|
logical_size: UiSize,
|
||||||
clip: ActiveClip,
|
clip: ActiveClip,
|
||||||
) -> [TextVertex; 6] {
|
) -> Option<[TextVertex; 6]> {
|
||||||
let rect = Rect::new(origin.x, origin.y, size.width, size.height);
|
let rect = Rect::new(origin.x, origin.y, size.width, size.height);
|
||||||
build_textured_vertices(
|
build_textured_vertices(
|
||||||
rect,
|
rect,
|
||||||
@@ -1651,7 +1673,7 @@ fn build_image_vertices(
|
|||||||
uv_rect: (f32, f32, f32, f32),
|
uv_rect: (f32, f32, f32, f32),
|
||||||
logical_size: UiSize,
|
logical_size: UiSize,
|
||||||
clip: ActiveClip,
|
clip: ActiveClip,
|
||||||
) -> [TextVertex; 6] {
|
) -> Option<[TextVertex; 6]> {
|
||||||
build_textured_vertices(rect, uv_rect, [1.0, 1.0, 1.0, 1.0], logical_size, clip)
|
build_textured_vertices(rect, uv_rect, [1.0, 1.0, 1.0, 1.0], logical_size, clip)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1661,7 +1683,8 @@ fn build_textured_vertices(
|
|||||||
color: [f32; 4],
|
color: [f32; 4],
|
||||||
logical_size: UiSize,
|
logical_size: UiSize,
|
||||||
clip: ActiveClip,
|
clip: ActiveClip,
|
||||||
) -> [TextVertex; 6] {
|
) -> Option<[TextVertex; 6]> {
|
||||||
|
let (rect, (u0, v0, u1, v1)) = clip_textured_rect(rect, uv_rect, clip.resolved_rect())?;
|
||||||
let left = to_ndc_x(rect.origin.x, logical_size.width.max(1.0));
|
let left = to_ndc_x(rect.origin.x, logical_size.width.max(1.0));
|
||||||
let right = to_ndc_x(rect.origin.x + rect.size.width, logical_size.width.max(1.0));
|
let right = to_ndc_x(rect.origin.x + rect.size.width, logical_size.width.max(1.0));
|
||||||
let top = to_ndc_y(rect.origin.y, logical_size.height.max(1.0));
|
let top = to_ndc_y(rect.origin.y, logical_size.height.max(1.0));
|
||||||
@@ -1669,11 +1692,10 @@ fn build_textured_vertices(
|
|||||||
rect.origin.y + rect.size.height,
|
rect.origin.y + rect.size.height,
|
||||||
logical_size.height.max(1.0),
|
logical_size.height.max(1.0),
|
||||||
);
|
);
|
||||||
let (u0, v0, u1, v1) = uv_rect;
|
let clip_rect = clip_rect_array(clip);
|
||||||
let clip_rect = clip_rect_array(clip.rect);
|
|
||||||
let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip);
|
let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip);
|
||||||
|
|
||||||
[
|
Some([
|
||||||
TextVertex {
|
TextVertex {
|
||||||
position: [left, top],
|
position: [left, top],
|
||||||
world_position: [rect.origin.x, rect.origin.y],
|
world_position: [rect.origin.x, rect.origin.y],
|
||||||
@@ -1731,7 +1753,7 @@ fn build_textured_vertices(
|
|||||||
rounded_clip_rect,
|
rounded_clip_rect,
|
||||||
clip_params,
|
clip_params,
|
||||||
},
|
},
|
||||||
]
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -1754,7 +1776,7 @@ fn push_glyph_vertices(
|
|||||||
let bottom = to_ndc_y(dest_rect.bottom as f32, logical_size.height.max(1.0));
|
let bottom = to_ndc_y(dest_rect.bottom as f32, logical_size.height.max(1.0));
|
||||||
|
|
||||||
let color = color_to_f32(color);
|
let color = color_to_f32(color);
|
||||||
let clip_rect = clip_rect_array(clip.rect);
|
let clip_rect = clip_rect_array(clip);
|
||||||
let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip);
|
let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip);
|
||||||
vertices.extend_from_slice(&[
|
vertices.extend_from_slice(&[
|
||||||
TextVertex {
|
TextVertex {
|
||||||
@@ -1959,7 +1981,7 @@ fn push_shape_vertices(
|
|||||||
let rect_data = rect_to_array(shader_rect);
|
let rect_data = rect_to_array(shader_rect);
|
||||||
let fill_color = fill_color.map_or([0.0, 0.0, 0.0, 0.0], color_to_f32);
|
let fill_color = fill_color.map_or([0.0, 0.0, 0.0, 0.0], color_to_f32);
|
||||||
let border_color = border_color.map_or([0.0, 0.0, 0.0, 0.0], color_to_f32);
|
let border_color = border_color.map_or([0.0, 0.0, 0.0, 0.0], color_to_f32);
|
||||||
let clip_rect = clip_rect_array(clip.rect);
|
let clip_rect = clip_rect_array(clip);
|
||||||
let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip);
|
let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip);
|
||||||
let shadow_base_rect = shadow_base_rect.map_or([0.0, 0.0, 0.0, 0.0], rect_to_array);
|
let shadow_base_rect = shadow_base_rect.map_or([0.0, 0.0, 0.0, 0.0], rect_to_array);
|
||||||
|
|
||||||
@@ -2091,8 +2113,9 @@ fn shadow_blur_extent(blur: f32) -> f32 {
|
|||||||
blur.max(0.0) * 2.0
|
blur.max(0.0) * 2.0
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clip_rect_array(rect: Option<Rect>) -> [f32; 4] {
|
fn clip_rect_array(clip: ActiveClip) -> [f32; 4] {
|
||||||
rect.map_or([0.0, 0.0, 0.0, 0.0], rect_to_array)
|
clip.resolved_rect()
|
||||||
|
.map_or([0.0, 0.0, 0.0, 0.0], rect_to_array)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rounded_clip_arrays(clip: ActiveClip) -> ([f32; 4], [f32; 2]) {
|
fn rounded_clip_arrays(clip: ActiveClip) -> ([f32; 4], [f32; 2]) {
|
||||||
@@ -2100,7 +2123,7 @@ fn rounded_clip_arrays(clip: ActiveClip) -> ([f32; 4], [f32; 2]) {
|
|||||||
.rounded
|
.rounded
|
||||||
.map_or([0.0, 0.0, 0.0, 0.0], |(rect, _)| rect_to_array(rect));
|
.map_or([0.0, 0.0, 0.0, 0.0], |(rect, _)| rect_to_array(rect));
|
||||||
let clip_params = [
|
let clip_params = [
|
||||||
if clip.rect.is_some() { 1.0 } else { 0.0 },
|
if clip.rect_active { 1.0 } else { 0.0 },
|
||||||
clip.rounded.map_or(0.0, |(_, radius)| radius),
|
clip.rounded.map_or(0.0, |(_, radius)| radius),
|
||||||
];
|
];
|
||||||
(rounded_clip_rect, clip_params)
|
(rounded_clip_rect, clip_params)
|
||||||
@@ -2111,7 +2134,12 @@ fn push_clip_state(stack: &mut Vec<ActiveClip>, active: &mut ActiveClip, region:
|
|||||||
if region.radius > 0.0 {
|
if region.radius > 0.0 {
|
||||||
active.rounded = Some((region.rect, region.radius));
|
active.rounded = Some((region.rect, region.radius));
|
||||||
}
|
}
|
||||||
active.rect = Some(intersect_rects(active.rect, Some(region.rect)).unwrap_or(region.rect));
|
active.rect = if active.rect_active {
|
||||||
|
intersect_rects(active.rect, Some(region.rect))
|
||||||
|
} else {
|
||||||
|
Some(region.rect)
|
||||||
|
};
|
||||||
|
active.rect_active = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pop_clip_state(stack: &mut Vec<ActiveClip>, active: &mut ActiveClip) {
|
fn pop_clip_state(stack: &mut Vec<ActiveClip>, active: &mut ActiveClip) {
|
||||||
@@ -2136,6 +2164,10 @@ fn intersect_rects(first: Option<Rect>, second: Option<Rect>) -> Option<Rect> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn empty_clip_rect() -> Rect {
|
||||||
|
Rect::new(1.0, 1.0, -1.0, -1.0)
|
||||||
|
}
|
||||||
|
|
||||||
fn to_ndc_x(x: f32, width: f32) -> f32 {
|
fn to_ndc_x(x: f32, width: f32) -> f32 {
|
||||||
(x / width) * 2.0 - 1.0
|
(x / width) * 2.0 - 1.0
|
||||||
}
|
}
|
||||||
@@ -2183,6 +2215,43 @@ fn clipped_glyph_quad(
|
|||||||
Some((clipped, (u0, v0, u1, v1)))
|
Some((clipped, (u0, v0, u1, v1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clip_textured_rect(
|
||||||
|
rect: Rect,
|
||||||
|
uv_rect: (f32, f32, f32, f32),
|
||||||
|
clip_rect: Option<Rect>,
|
||||||
|
) -> Option<(Rect, (f32, f32, f32, f32))> {
|
||||||
|
let clipped = if let Some(clip) = clip_rect {
|
||||||
|
let left = rect.origin.x.max(clip.origin.x);
|
||||||
|
let top = rect.origin.y.max(clip.origin.y);
|
||||||
|
let right = (rect.origin.x + rect.size.width).min(clip.origin.x + clip.size.width);
|
||||||
|
let bottom = (rect.origin.y + rect.size.height).min(clip.origin.y + clip.size.height);
|
||||||
|
if right <= left || bottom <= top {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Rect::new(left, top, right - left, bottom - top)
|
||||||
|
} else {
|
||||||
|
rect
|
||||||
|
};
|
||||||
|
|
||||||
|
if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (u0, v0, u1, v1) = uv_rect;
|
||||||
|
let clipped_u0 = u0 + (u1 - u0) * ((clipped.origin.x - rect.origin.x) / rect.size.width);
|
||||||
|
let clipped_v0 = v0 + (v1 - v0) * ((clipped.origin.y - rect.origin.y) / rect.size.height);
|
||||||
|
let clipped_u1 = u1
|
||||||
|
- (u1 - u0)
|
||||||
|
* (((rect.origin.x + rect.size.width) - (clipped.origin.x + clipped.size.width))
|
||||||
|
/ rect.size.width);
|
||||||
|
let clipped_v1 = v1
|
||||||
|
- (v1 - v0)
|
||||||
|
* (((rect.origin.y + rect.size.height) - (clipped.origin.y + clipped.size.height))
|
||||||
|
/ rect.size.height);
|
||||||
|
|
||||||
|
Some((clipped, (clipped_u0, clipped_v0, clipped_u1, clipped_v1)))
|
||||||
|
}
|
||||||
|
|
||||||
fn text_texture_key(text: &PreparedText) -> TextTextureKey {
|
fn text_texture_key(text: &PreparedText) -> TextTextureKey {
|
||||||
TextTextureKey {
|
TextTextureKey {
|
||||||
text: text.text.clone(),
|
text: text.text.clone(),
|
||||||
@@ -2208,9 +2277,13 @@ fn text_texture_key(text: &PreparedText) -> TextTextureKey {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{blend_rgba, build_vertices, text_texture_key};
|
use super::{
|
||||||
|
ActiveClip, blend_rgba, build_text_vertices, build_vertices, clip_rect_array,
|
||||||
|
push_clip_state, rounded_clip_arrays, text_texture_key,
|
||||||
|
};
|
||||||
use ruin_ui::{
|
use ruin_ui::{
|
||||||
Color, GlyphInstance, Point, PreparedText, Rect, SceneSnapshot, TextSelectionStyle, UiSize,
|
ClipRegion, Color, GlyphInstance, Point, PreparedText, Rect, SceneSnapshot,
|
||||||
|
TextSelectionStyle, UiSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2284,4 +2357,44 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(text_texture_key(&first), text_texture_key(&second));
|
assert_eq!(text_texture_key(&first), text_texture_key(&second));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_clip_with_empty_intersection_stays_clipped() {
|
||||||
|
let mut clip_stack = Vec::new();
|
||||||
|
let mut active_clip = ActiveClip::default();
|
||||||
|
|
||||||
|
push_clip_state(
|
||||||
|
&mut clip_stack,
|
||||||
|
&mut active_clip,
|
||||||
|
ClipRegion {
|
||||||
|
rect: Rect::new(0.0, 0.0, 100.0, 100.0),
|
||||||
|
radius: 0.0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
push_clip_state(
|
||||||
|
&mut clip_stack,
|
||||||
|
&mut active_clip,
|
||||||
|
ClipRegion {
|
||||||
|
rect: Rect::new(150.0, 150.0, 50.0, 50.0),
|
||||||
|
radius: 8.0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(active_clip.rect_active);
|
||||||
|
assert_eq!(active_clip.rect, None);
|
||||||
|
let clip_rect = clip_rect_array(active_clip);
|
||||||
|
assert!(clip_rect[0] > clip_rect[2]);
|
||||||
|
assert!(clip_rect[1] > clip_rect[3]);
|
||||||
|
let (_, clip_params) = rounded_clip_arrays(active_clip);
|
||||||
|
assert_eq!(clip_params[0], 1.0);
|
||||||
|
assert!(
|
||||||
|
build_text_vertices(
|
||||||
|
Point::new(160.0, 160.0),
|
||||||
|
UiSize::new(24.0, 12.0),
|
||||||
|
UiSize::new(400.0, 400.0),
|
||||||
|
active_clip,
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user