This commit is contained in:
2026-03-21 02:25:17 -04:00
parent 6954c8c74d
commit c70f42704c
11 changed files with 1430 additions and 98 deletions

View File

@@ -4,12 +4,12 @@ use std::time::{Duration, Instant};
use ruin_runtime::{TimeoutHandle, clear_timeout, set_timeout};
use ruin_ui::{
Color, CursorIcon, DisplayItem, Edges, Element, ElementId, InteractionTree, KeyboardEvent,
KeyboardEventKind, KeyboardKey, LayoutSnapshot, PlatformEvent, PointerButton, PointerEvent,
PointerEventKind, PointerRouter, PreparedText, Quad, RoutedPointerEventKind, SceneSnapshot,
TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight,
TextStyle, TextSystem, TextWrap, UiSize, WindowController, WindowSpec, WindowUpdate,
layout_snapshot_with_text_system,
Color, CursorIcon, DisplayItem, Edges, Element, ElementId, ImageFit, ImageResource,
InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutSnapshot, PlatformEvent,
PointerButton, PointerEvent, PointerEventKind, PointerRouter, PreparedText, Quad,
RoutedPointerEventKind, SceneSnapshot, TextAlign, TextFontFamily, TextSelectionStyle, TextSpan,
TextSpanSlant, TextSpanWeight, TextStyle, TextSystem, TextWrap, UiSize, WindowController,
WindowSpec, WindowUpdate, layout_snapshot_with_text_system,
};
use ruin_ui_platform_wayland::start_wayland_ui;
use tracing_subscriber::layer::SubscriberExt;
@@ -34,6 +34,7 @@ const SECOND_INPUT_TEXT_ID: ElementId = ElementId::new(107);
const INPUT_CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
const MULTI_CLICK_INTERVAL: Duration = Duration::from_millis(350);
const MULTI_CLICK_DISTANCE_SQUARED: f32 = 36.0;
const HERO_IMAGE_ASSET: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ruin.png");
const DEMO_SELECTION_STYLE: TextSelectionStyle =
TextSelectionStyle::new(Color::rgba(0x6C, 0x8E, 0xFF, 0xB8))
.with_text_color(Color::rgb(0x0D, 0x14, 0x25));
@@ -192,7 +193,10 @@ fn active_input_selection_bounds(
Some(selection_bounds(selection))
}
fn has_active_input_selection(selection: Option<TextSelection>, input_field: &InputFieldState) -> bool {
fn has_active_input_selection(
selection: Option<TextSelection>,
input_field: &InputFieldState,
) -> bool {
active_input_selection_bounds(selection, input_field).is_some()
}
@@ -348,6 +352,7 @@ fn install_tracing() {
#[ruin_runtime::async_main]
async fn main() -> Result<(), Box<dyn Error>> {
install_tracing();
let hero_image = ImageResource::load_path(HERO_IMAGE_ASSET).await?;
let mut ui = start_wayland_ui();
let window = ui.create_window(
WindowSpec::new("RUIN paragraph demo")
@@ -522,6 +527,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
viewport,
version,
hovered_card,
&hero_image,
&input_fields,
focused_element,
&mut text_system,
@@ -656,8 +662,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
&text,
);
needs_input_rebuild |= input_outcome.text_changed;
needs_overlay_present |=
input_outcome.caret_changed || input_outcome.selection_changed;
needs_overlay_present |= input_outcome.caret_changed || input_outcome.selection_changed;
}
if let Some(text) = pending_primary_selection_text {
let input_outcome = insert_text_into_focused_input(
@@ -715,6 +720,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
viewport,
version,
hovered_card,
&hero_image,
&input_fields,
focused_element,
&mut text_system,
@@ -771,11 +777,18 @@ fn build_snapshot(
viewport: UiSize,
version: u64,
hovered_card: Option<ElementId>,
hero_image: &ImageResource,
input_fields: &[InputFieldState],
focused_element: Option<ElementId>,
text_system: &mut TextSystem,
) -> LayoutSnapshot {
let tree = build_document_tree(viewport, hovered_card, input_fields, focused_element);
let tree = build_document_tree(
viewport,
hovered_card,
hero_image,
input_fields,
focused_element,
);
layout_snapshot_with_text_system(version, viewport, &tree, text_system)
}
@@ -1051,17 +1064,14 @@ fn handle_keyboard_input_event(
let prepared_text = interaction_tree.text_for_element(text_id);
if event.modifiers.shift
&& let Some(prepared_text) = prepared_text
&& let Some(next_caret) =
navigation_target_offset(
prepared_text,
selection
.filter(|current| current.element_id == text_id)
.map(|current| {
selection_navigation_anchor_and_focus(current, &event.key).1
})
.unwrap_or(input_fields[input_index].caret),
&event.key,
)
&& let Some(next_caret) = navigation_target_offset(
prepared_text,
selection
.filter(|current| current.element_id == text_id)
.map(|current| selection_navigation_anchor_and_focus(current, &event.key).1)
.unwrap_or(input_fields[input_index].caret),
&event.key,
)
{
let anchor = selection
.filter(|current| current.element_id == text_id)
@@ -1312,10 +1322,6 @@ fn demo_body_style(font_size: f32, color: Color) -> TextStyle {
TextStyle::new(font_size, color).with_selection_style(DEMO_SELECTION_STYLE)
}
fn demo_unselectable_title(text: impl Into<String>, id: ElementId, style: TextStyle) -> Element {
Element::paragraph(text, style.with_selectable(false)).id(id)
}
fn open_rust_website() {
if let Err(error) = Command::new("xdg-open")
.arg("https://www.rust-lang.org")
@@ -1374,72 +1380,76 @@ fn input_field_element(input_field: &InputFieldState, focused: bool) -> Element
fn build_document_tree(
viewport: UiSize,
hovered_card: Option<ElementId>,
hero_image: &ImageResource,
input_fields: &[InputFieldState],
focused_element: Option<ElementId>,
) -> Element {
let gutter = (viewport.width * 0.025).clamp(18.0, 30.0);
let sidebar_width = (viewport.width * 0.28).clamp(220.0, 320.0);
let hero_image_width = (viewport.width * 0.32).clamp(200.0, 320.0);
let hero_image_height = (hero_image_width * 0.34).clamp(72.0, 128.0);
Element::column()
.background(Color::rgb(0x0F, 0x13, 0x1E))
.padding(Edges::all(gutter))
.gap(gutter)
.children([
Element::column()
Element::row()
.padding(Edges::all(gutter))
.gap(gutter * 0.45)
.gap(gutter)
.background(Color::rgb(0x16, 0x1D, 0x2B))
.children([
demo_unselectable_title(
"RUIN paragraph demo",
HERO_TITLE_ID,
TextStyle::new(34.0, Color::rgb(0xF5, 0xF7, 0xFB))
.with_line_height(40.0)
.with_align(TextAlign::Center),
),
Element::rich_paragraph(
[
TextSpan::new("RUIN is exploring a "),
TextSpan::new("retained")
.weight(TextSpanWeight::Semibold)
.color(Color::rgb(0xF5, 0xD0, 0x74)),
TextSpan::new(" layout tree backed by explicit scene building, a dedicated "),
TextSpan::new("platform")
.weight(TextSpanWeight::Semibold)
.color(Color::rgb(0x7D, 0xD3, 0xFC)),
TextSpan::new(" thread, and a "),
TextSpan::new("renderer")
Element::image(hero_image.clone())
.id(HERO_TITLE_ID)
.width(hero_image_width)
.height(hero_image_height)
.image_fit(ImageFit::Contain)
.pointer_events(false),
Element::column().flex(1.0).gap(gutter * 0.45).children([
Element::rich_paragraph(
[
TextSpan::new("RUIN is exploring a "),
TextSpan::new("retained")
.weight(TextSpanWeight::Semibold)
.color(Color::rgb(0xF5, 0xD0, 0x74)),
TextSpan::new(" layout tree backed by explicit scene building, a dedicated "),
TextSpan::new("platform")
.weight(TextSpanWeight::Semibold)
.color(Color::rgb(0x7D, 0xD3, 0xFC)),
TextSpan::new(" thread, and a "),
TextSpan::new("renderer")
.weight(TextSpanWeight::Bold)
.slant(TextSpanSlant::Oblique)
.family(TextFontFamily::Monospace)
.color(Color::rgb(0xA7, 0xF3, 0xD0)),
TextSpan::new(
" that can stay simple while the higher-level UI model evolves. This example is intentionally calm: no animated panels, no pulsing widths, just a document-like surface that makes paragraph behavior easier to inspect.",
),
],
demo_body_style(18.0, Color::rgb(0xC9, 0xD2, 0xE3))
.with_line_height(28.0)
.with_align(TextAlign::Center),
)
.id(HERO_BODY_ID),
Element::rich_paragraph(
[TextSpan::new("Visit the Rust website")
.weight(TextSpanWeight::Bold)
.slant(TextSpanSlant::Oblique)
.family(TextFontFamily::Monospace)
.color(Color::rgb(0xA7, 0xF3, 0xD0)),
TextSpan::new(
" that can stay simple while the higher-level UI model evolves. This example is intentionally calm: no animated panels, no pulsing widths, just a document-like surface that makes paragraph behavior easier to inspect.",
),
],
demo_body_style(18.0, Color::rgb(0xC9, 0xD2, 0xE3))
.with_line_height(28.0)
.with_align(TextAlign::Center),
)
.id(HERO_BODY_ID),
Element::rich_paragraph(
[TextSpan::new("Visit the Rust website")
.weight(TextSpanWeight::Bold)
.color(Color::rgb(0x60, 0xA5, 0xFA))],
TextStyle::new(18.0, Color::rgb(0x60, 0xA5, 0xFA))
.with_line_height(24.0)
.with_selectable(false),
)
.id(RUST_LINK_ID)
.cursor(CursorIcon::Pointer),
input_field_element(
&input_fields[0],
focused_element == Some(input_fields[0].field_id),
),
input_field_element(
&input_fields[1],
focused_element == Some(input_fields[1].field_id),
),
.color(Color::rgb(0x60, 0xA5, 0xFA))],
TextStyle::new(18.0, Color::rgb(0x60, 0xA5, 0xFA))
.with_line_height(24.0)
.with_selectable(false),
)
.id(RUST_LINK_ID)
.cursor(CursorIcon::Pointer),
input_field_element(
&input_fields[0],
focused_element == Some(input_fields[0].field_id),
),
input_field_element(
&input_fields[1],
focused_element == Some(input_fields[1].field_id),
),
]),
]),
Element::row().flex(1.0).gap(gutter).children([
Element::column()