2455 lines
91 KiB
Rust
2455 lines
91 KiB
Rust
use std::error::Error;
|
|
use std::process::Command;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use ruin_runtime::{
|
|
IntervalHandle, TimeoutHandle, clear_interval, clear_timeout, set_interval, set_timeout,
|
|
};
|
|
use ruin_ui::{
|
|
BoxShadow, BoxShadowKind, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, ImageFit,
|
|
ImageResource, InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutSnapshot,
|
|
PlatformEvent, PointerButton, PointerEvent, PointerEventKind, PointerRouter, PreparedText,
|
|
Quad, Rect, RoutedPointerEventKind, SceneSnapshot, ScrollbarStyle, 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;
|
|
use tracing_subscriber::util::SubscriberInitExt;
|
|
use tracing_subscriber::{EnvFilter, fmt};
|
|
|
|
const BODY_ONE: &str = "Paragraph widgets are the next useful layer above raw text leaves. They should be able to wrap naturally inside containers, respect alignment, clamp to a maximum number of lines when appropriate, and participate in layout without forcing every example to become a custom text experiment. That gets us closer to real application surfaces instead of just proving that glyphs can reach the screen.";
|
|
const BODY_TWO: &str = "This demo keeps the overall layout mostly static while still responding to window resizing. The centered title, the body copy, and the clamped sidebar notes all use the same retained layout pipeline, but they exercise different paragraph semantics. It should be a much less exhausting place to look at text than the reactive dashboard stress test.";
|
|
const WHY_CARD_ID: ElementId = ElementId::new(1);
|
|
const CALM_CARD_ID: ElementId = ElementId::new(2);
|
|
const NEXT_CARD_ID: ElementId = ElementId::new(3);
|
|
const QUOTE_CARD_ID: ElementId = ElementId::new(4);
|
|
const NOTE_CARD_ID: ElementId = ElementId::new(5);
|
|
const STATUS_CARD_ID: ElementId = ElementId::new(6);
|
|
const STATUS_SCROLLBOX_ID: ElementId = ElementId::new(7);
|
|
const HERO_TITLE_ID: ElementId = ElementId::new(101);
|
|
const HERO_BODY_ID: ElementId = ElementId::new(102);
|
|
const RUST_LINK_ID: ElementId = ElementId::new(103);
|
|
const INPUT_FIELD_ID: ElementId = ElementId::new(104);
|
|
const INPUT_TEXT_ID: ElementId = ElementId::new(105);
|
|
const SECOND_INPUT_FIELD_ID: ElementId = ElementId::new(106);
|
|
const SECOND_INPUT_TEXT_ID: ElementId = ElementId::new(107);
|
|
const INPUT_CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
|
|
const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(16);
|
|
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));
|
|
|
|
#[derive(Clone, Default)]
|
|
struct SidebarCardOptions {
|
|
align: Option<TextAlign>,
|
|
font_family: Option<TextFontFamily>,
|
|
max_lines: Option<usize>,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct InFlightResize {
|
|
scene_version: u64,
|
|
viewport: UiSize,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
struct SelectionDrag {
|
|
group_id: ElementId,
|
|
anchor: SelectionEndpoint,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct ScrollbarDrag {
|
|
start_pointer_y: f32,
|
|
start_offset_y: f32,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct PendingSelectionRetarget {
|
|
drag: SelectionDrag,
|
|
position: ruin_ui::Point,
|
|
finish: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct ClickTracker {
|
|
element_id: ElementId,
|
|
last_position: ruin_ui::Point,
|
|
last_click_at: Instant,
|
|
count: u8,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
struct SelectionEndpoint {
|
|
element_id: ElementId,
|
|
offset: usize,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
struct TextSelection {
|
|
group_id: ElementId,
|
|
anchor: SelectionEndpoint,
|
|
focus: SelectionEndpoint,
|
|
}
|
|
|
|
impl TextSelection {
|
|
fn is_collapsed(self) -> bool {
|
|
self.anchor == self.focus
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct SelectionOutcome {
|
|
changed: bool,
|
|
copied_text: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
struct InputFieldState {
|
|
field_id: ElementId,
|
|
text_id: ElementId,
|
|
label: &'static str,
|
|
placeholder: &'static str,
|
|
text: String,
|
|
caret: usize,
|
|
}
|
|
|
|
impl InputFieldState {
|
|
fn new(
|
|
field_id: ElementId,
|
|
text_id: ElementId,
|
|
label: &'static str,
|
|
placeholder: &'static str,
|
|
text: impl Into<String>,
|
|
) -> Self {
|
|
let text = text.into();
|
|
let caret = text.len();
|
|
Self {
|
|
field_id,
|
|
text_id,
|
|
label,
|
|
placeholder,
|
|
text,
|
|
caret,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
struct InputOutcome {
|
|
focus_changed: bool,
|
|
text_changed: bool,
|
|
caret_changed: bool,
|
|
selection_changed: bool,
|
|
request_primary_paste: bool,
|
|
request_clipboard_paste: bool,
|
|
copied_text: Option<String>,
|
|
}
|
|
|
|
fn focused_input_index(
|
|
focused_element: Option<ElementId>,
|
|
input_fields: &[InputFieldState],
|
|
) -> Option<usize> {
|
|
focused_element.and_then(|focused| {
|
|
input_fields
|
|
.iter()
|
|
.position(|field| field.field_id == focused)
|
|
})
|
|
}
|
|
|
|
fn focused_input(
|
|
focused_element: Option<ElementId>,
|
|
input_fields: &[InputFieldState],
|
|
) -> Option<&InputFieldState> {
|
|
focused_input_index(focused_element, input_fields).map(|index| &input_fields[index])
|
|
}
|
|
|
|
fn focused_input_mut(
|
|
focused_element: Option<ElementId>,
|
|
input_fields: &mut [InputFieldState],
|
|
) -> Option<&mut InputFieldState> {
|
|
focused_input_index(focused_element, input_fields).map(|index| &mut input_fields[index])
|
|
}
|
|
|
|
fn next_input_focus(
|
|
focused_element: Option<ElementId>,
|
|
input_fields: &[InputFieldState],
|
|
) -> ElementId {
|
|
match focused_input_index(focused_element, input_fields) {
|
|
Some(index) => input_fields[(index + 1) % input_fields.len()].field_id,
|
|
None => input_fields[0].field_id,
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct GroupPreparedText<'a> {
|
|
element_id: ElementId,
|
|
prepared_text: &'a PreparedText,
|
|
}
|
|
|
|
fn status_scroll_text_id(slot: u64) -> ElementId {
|
|
ElementId::new(700 + slot)
|
|
}
|
|
|
|
fn selection_group_for_element(element_id: ElementId) -> Option<ElementId> {
|
|
match element_id {
|
|
HERO_BODY_ID | INPUT_TEXT_ID | SECOND_INPUT_TEXT_ID => Some(element_id),
|
|
id if id == status_scroll_text_id(1)
|
|
|| id == status_scroll_text_id(2)
|
|
|| id == status_scroll_text_id(3)
|
|
|| id == status_scroll_text_id(4)
|
|
|| id == status_scroll_text_id(5) =>
|
|
{
|
|
Some(STATUS_SCROLLBOX_ID)
|
|
}
|
|
id if (11..=69).contains(&id.raw()) => Some(ElementId::new(id.raw() / 10)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn collect_group_prepared_texts<'a>(
|
|
node: &'a ruin_ui::LayoutNode,
|
|
group_id: ElementId,
|
|
texts: &mut Vec<GroupPreparedText<'a>>,
|
|
) {
|
|
if let (Some(element_id), Some(prepared_text)) = (node.element_id, node.prepared_text.as_ref())
|
|
&& prepared_text.selectable
|
|
&& selection_group_for_element(element_id) == Some(group_id)
|
|
{
|
|
texts.push(GroupPreparedText {
|
|
element_id,
|
|
prepared_text,
|
|
});
|
|
}
|
|
for child in &node.children {
|
|
collect_group_prepared_texts(child, group_id, texts);
|
|
}
|
|
}
|
|
|
|
fn group_prepared_texts<'a>(
|
|
interaction_tree: &'a InteractionTree,
|
|
group_id: ElementId,
|
|
) -> Vec<GroupPreparedText<'a>> {
|
|
let mut texts = Vec::new();
|
|
collect_group_prepared_texts(&interaction_tree.root, group_id, &mut texts);
|
|
texts
|
|
}
|
|
|
|
fn selection_endpoint_order(
|
|
interaction_tree: &InteractionTree,
|
|
group_id: ElementId,
|
|
start: SelectionEndpoint,
|
|
end: SelectionEndpoint,
|
|
) -> Option<std::cmp::Ordering> {
|
|
let texts = group_prepared_texts(interaction_tree, group_id);
|
|
let start_index = texts
|
|
.iter()
|
|
.position(|text| text.element_id == start.element_id)?;
|
|
let end_index = texts
|
|
.iter()
|
|
.position(|text| text.element_id == end.element_id)?;
|
|
Some(
|
|
start_index
|
|
.cmp(&end_index)
|
|
.then(start.offset.cmp(&end.offset)),
|
|
)
|
|
}
|
|
|
|
fn ordered_selection_endpoints(
|
|
interaction_tree: &InteractionTree,
|
|
selection: TextSelection,
|
|
) -> Option<(SelectionEndpoint, SelectionEndpoint)> {
|
|
match selection_endpoint_order(
|
|
interaction_tree,
|
|
selection.group_id,
|
|
selection.anchor,
|
|
selection.focus,
|
|
)? {
|
|
std::cmp::Ordering::Greater => Some((selection.focus, selection.anchor)),
|
|
std::cmp::Ordering::Equal | std::cmp::Ordering::Less => {
|
|
Some((selection.anchor, selection.focus))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn selection_navigation_anchor_and_focus(
|
|
selection: TextSelection,
|
|
key: &KeyboardKey,
|
|
) -> (usize, usize) {
|
|
let (start, end) = if selection.anchor.offset <= selection.focus.offset {
|
|
(selection.anchor.offset, selection.focus.offset)
|
|
} else {
|
|
(selection.focus.offset, selection.anchor.offset)
|
|
};
|
|
match key {
|
|
KeyboardKey::ArrowLeft | KeyboardKey::ArrowUp | KeyboardKey::Home => (end, start),
|
|
KeyboardKey::ArrowRight | KeyboardKey::ArrowDown | KeyboardKey::End => (start, end),
|
|
_ => (selection.anchor.offset, selection.focus.offset),
|
|
}
|
|
}
|
|
|
|
fn active_input_selection_bounds(
|
|
selection: Option<TextSelection>,
|
|
input_field: &InputFieldState,
|
|
) -> Option<(usize, usize)> {
|
|
let selection = selection?;
|
|
if selection.group_id != input_field.text_id
|
|
|| selection.anchor.element_id != input_field.text_id
|
|
|| selection.focus.element_id != input_field.text_id
|
|
|| selection.is_collapsed()
|
|
{
|
|
return None;
|
|
}
|
|
if selection.anchor.offset <= selection.focus.offset {
|
|
Some((selection.anchor.offset, selection.focus.offset))
|
|
} else {
|
|
Some((selection.focus.offset, selection.anchor.offset))
|
|
}
|
|
}
|
|
|
|
fn has_active_input_selection(
|
|
selection: Option<TextSelection>,
|
|
input_field: &InputFieldState,
|
|
) -> bool {
|
|
active_input_selection_bounds(selection, input_field).is_some()
|
|
}
|
|
|
|
fn clear_input_selection_for(
|
|
selection: &mut Option<TextSelection>,
|
|
input_field: &InputFieldState,
|
|
) -> bool {
|
|
if selection.is_some_and(|current| current.group_id == input_field.text_id) {
|
|
*selection = None;
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn replace_input_range(
|
|
input_field: &mut InputFieldState,
|
|
selection: &mut Option<TextSelection>,
|
|
range: (usize, usize),
|
|
replacement: &str,
|
|
) -> InputOutcome {
|
|
let (start, end) = range;
|
|
input_field.text.replace_range(start..end, replacement);
|
|
input_field.caret = start + replacement.len();
|
|
*selection = None;
|
|
InputOutcome {
|
|
text_changed: true,
|
|
caret_changed: true,
|
|
selection_changed: true,
|
|
..InputOutcome::default()
|
|
}
|
|
}
|
|
|
|
fn insert_text_into_focused_input(
|
|
focused_element: Option<ElementId>,
|
|
input_fields: &mut [InputFieldState],
|
|
selection: &mut Option<TextSelection>,
|
|
text: &str,
|
|
) -> InputOutcome {
|
|
let Some(input_field) = focused_input_mut(focused_element, input_fields) else {
|
|
return InputOutcome::default();
|
|
};
|
|
if let Some(range) = active_input_selection_bounds(*selection, input_field) {
|
|
return replace_input_range(input_field, selection, range, text);
|
|
}
|
|
let caret = input_field.caret;
|
|
input_field.text.insert_str(caret, text);
|
|
input_field.caret += text.len();
|
|
let selection_changed = clear_input_selection_for(selection, input_field);
|
|
InputOutcome {
|
|
text_changed: true,
|
|
caret_changed: true,
|
|
selection_changed,
|
|
..InputOutcome::default()
|
|
}
|
|
}
|
|
|
|
fn selection_range_for_prepared_text(
|
|
interaction_tree: &InteractionTree,
|
|
selection: TextSelection,
|
|
element_id: ElementId,
|
|
) -> Option<std::ops::Range<usize>> {
|
|
let texts = group_prepared_texts(interaction_tree, selection.group_id);
|
|
let (start, end) = ordered_selection_endpoints(interaction_tree, selection)?;
|
|
if start == end {
|
|
return None;
|
|
}
|
|
let start_index = texts
|
|
.iter()
|
|
.position(|text| text.element_id == start.element_id)?;
|
|
let end_index = texts
|
|
.iter()
|
|
.position(|text| text.element_id == end.element_id)?;
|
|
let current_index = texts
|
|
.iter()
|
|
.position(|text| text.element_id == element_id)?;
|
|
let current_text = texts.get(current_index)?.prepared_text;
|
|
if current_index < start_index || current_index > end_index {
|
|
return None;
|
|
}
|
|
let range = if start_index == end_index {
|
|
start.offset..end.offset
|
|
} else if current_index == start_index {
|
|
start.offset..current_text.text.len()
|
|
} else if current_index == end_index {
|
|
0..end.offset
|
|
} else {
|
|
0..current_text.text.len()
|
|
};
|
|
(!range.is_empty()).then_some(range)
|
|
}
|
|
|
|
fn selection_text(
|
|
interaction_tree: &InteractionTree,
|
|
selection: Option<TextSelection>,
|
|
) -> Option<String> {
|
|
let selection = selection?;
|
|
let texts = group_prepared_texts(interaction_tree, selection.group_id);
|
|
let mut fragments = Vec::new();
|
|
for text in texts {
|
|
let Some(range) =
|
|
selection_range_for_prepared_text(interaction_tree, selection, text.element_id)
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(fragment) = text.prepared_text.text.get(range) else {
|
|
continue;
|
|
};
|
|
if !fragment.is_empty() {
|
|
fragments.push(fragment.to_owned());
|
|
}
|
|
}
|
|
(!fragments.is_empty()).then(|| fragments.join("\n"))
|
|
}
|
|
|
|
fn selection_target_prepared_text(
|
|
interaction_tree: &InteractionTree,
|
|
selection: Option<TextSelection>,
|
|
) -> Option<(TextSelection, &PreparedText)> {
|
|
let selection = selection?;
|
|
interaction_tree
|
|
.text_for_element(selection.focus.element_id)
|
|
.map(|prepared_text| (selection, prepared_text))
|
|
}
|
|
|
|
fn single_text_selection(element_id: ElementId, anchor: usize, focus: usize) -> TextSelection {
|
|
TextSelection {
|
|
group_id: selection_group_for_element(element_id).unwrap_or(element_id),
|
|
anchor: SelectionEndpoint {
|
|
element_id,
|
|
offset: anchor,
|
|
},
|
|
focus: SelectionEndpoint {
|
|
element_id,
|
|
offset: focus,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn selection_endpoint_for_group_position(
|
|
interaction_tree: &InteractionTree,
|
|
group_id: ElementId,
|
|
point: ruin_ui::Point,
|
|
) -> Option<SelectionEndpoint> {
|
|
let texts = group_prepared_texts(interaction_tree, group_id);
|
|
let first = texts.first()?;
|
|
let last = texts.last()?;
|
|
|
|
let text_rect = |text: &GroupPreparedText<'_>| {
|
|
let bounds = text
|
|
.prepared_text
|
|
.bounds
|
|
.unwrap_or(UiSize::new(0.0, text.prepared_text.line_height));
|
|
Rect::new(
|
|
text.prepared_text.origin.x,
|
|
text.prepared_text.origin.y,
|
|
bounds.width,
|
|
bounds.height,
|
|
)
|
|
};
|
|
|
|
if point.y <= text_rect(first).origin.y {
|
|
return Some(SelectionEndpoint {
|
|
element_id: first.element_id,
|
|
offset: 0,
|
|
});
|
|
}
|
|
|
|
let last_rect = text_rect(last);
|
|
if point.y >= last_rect.origin.y + last_rect.size.height {
|
|
return Some(SelectionEndpoint {
|
|
element_id: last.element_id,
|
|
offset: last.prepared_text.text.len(),
|
|
});
|
|
}
|
|
|
|
for text in &texts {
|
|
let rect = text_rect(text);
|
|
if point.y < rect.origin.y {
|
|
return Some(SelectionEndpoint {
|
|
element_id: text.element_id,
|
|
offset: 0,
|
|
});
|
|
}
|
|
if point.y <= rect.origin.y + rect.size.height {
|
|
return Some(SelectionEndpoint {
|
|
element_id: text.element_id,
|
|
offset: text.prepared_text.byte_offset_for_position(point),
|
|
});
|
|
}
|
|
}
|
|
|
|
Some(SelectionEndpoint {
|
|
element_id: last.element_id,
|
|
offset: last.prepared_text.text.len(),
|
|
})
|
|
}
|
|
|
|
fn selection_at_position(
|
|
interaction_tree: &InteractionTree,
|
|
point: ruin_ui::Point,
|
|
) -> Option<(ElementId, SelectionEndpoint)> {
|
|
let hit = interaction_tree.text_hit_test(point)?;
|
|
let element_id = hit.target.element_id?;
|
|
let group_id = selection_group_for_element(element_id)?;
|
|
selection_endpoint_for_group_position(interaction_tree, group_id, point)
|
|
.map(|endpoint| (group_id, endpoint))
|
|
}
|
|
|
|
fn full_group_selection(
|
|
interaction_tree: &InteractionTree,
|
|
group_id: ElementId,
|
|
) -> Option<TextSelection> {
|
|
let texts = group_prepared_texts(interaction_tree, group_id);
|
|
let first = texts.first()?;
|
|
let last = texts.last()?;
|
|
Some(TextSelection {
|
|
group_id,
|
|
anchor: SelectionEndpoint {
|
|
element_id: first.element_id,
|
|
offset: 0,
|
|
},
|
|
focus: SelectionEndpoint {
|
|
element_id: last.element_id,
|
|
offset: last.prepared_text.text.len(),
|
|
},
|
|
})
|
|
}
|
|
|
|
fn navigation_target_offset(
|
|
prepared_text: &PreparedText,
|
|
offset: usize,
|
|
key: &KeyboardKey,
|
|
) -> Option<usize> {
|
|
match key {
|
|
KeyboardKey::ArrowLeft => Some(prepared_text.previous_char_boundary(offset)),
|
|
KeyboardKey::ArrowRight => Some(prepared_text.next_char_boundary(offset)),
|
|
KeyboardKey::ArrowUp => prepared_text.vertical_offset(offset, -1),
|
|
KeyboardKey::ArrowDown => prepared_text.vertical_offset(offset, 1),
|
|
KeyboardKey::Home => prepared_text.line_start_offset(offset),
|
|
KeyboardKey::End => prepared_text.line_end_offset(offset),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn update_click_tracker(
|
|
click_tracker: &mut Option<ClickTracker>,
|
|
element_id: ElementId,
|
|
position: ruin_ui::Point,
|
|
) -> u8 {
|
|
let now = Instant::now();
|
|
let next_count = click_tracker
|
|
.as_ref()
|
|
.filter(|tracker| {
|
|
tracker.element_id == element_id
|
|
&& now.duration_since(tracker.last_click_at) <= MULTI_CLICK_INTERVAL
|
|
&& {
|
|
let dx = tracker.last_position.x - position.x;
|
|
let dy = tracker.last_position.y - position.y;
|
|
dx * dx + dy * dy <= MULTI_CLICK_DISTANCE_SQUARED
|
|
}
|
|
})
|
|
.map_or(1, |tracker| tracker.count.saturating_add(1).min(3));
|
|
*click_tracker = Some(ClickTracker {
|
|
element_id,
|
|
last_position: position,
|
|
last_click_at: now,
|
|
count: next_count,
|
|
});
|
|
next_count
|
|
}
|
|
|
|
fn shortcut_matches(event: &KeyboardEvent, ch: char) -> bool {
|
|
matches!(
|
|
&event.key,
|
|
KeyboardKey::Character(text) if text.len() == 1 && text.eq_ignore_ascii_case(&ch.to_string())
|
|
)
|
|
}
|
|
|
|
fn install_tracing() {
|
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
|
EnvFilter::new(
|
|
"info,ruin_ui::layout_perf=debug,ruin_ui_platform_wayland=trace,ruin_ui_text_paragraph_demo=trace",
|
|
)
|
|
});
|
|
|
|
let fmt_layer = fmt::layer()
|
|
.with_target(true)
|
|
.with_thread_ids(true)
|
|
.with_thread_names(true)
|
|
.compact();
|
|
|
|
let _ = tracing_subscriber::registry()
|
|
.with(filter)
|
|
.with(fmt_layer)
|
|
.try_init();
|
|
}
|
|
|
|
#[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")
|
|
.app_id("dev.ruin.text-paragraph-demo")
|
|
.requested_inner_size(UiSize::new(1040.0, 760.0)),
|
|
)?;
|
|
|
|
let mut viewport = UiSize::new(1040.0, 760.0);
|
|
let mut version = 0_u64;
|
|
let mut text_system = TextSystem::new();
|
|
let mut pointer_router = PointerRouter::new();
|
|
let mut hovered_card = None;
|
|
let mut base_scene: Option<SceneSnapshot> = None;
|
|
let mut interaction_tree: Option<InteractionTree> = None;
|
|
let mut pending_resize = None;
|
|
let mut in_flight_resize = None;
|
|
let mut latest_submitted_viewport = None;
|
|
let mut current_cursor = CursorIcon::Default;
|
|
let mut focused_element = None::<ElementId>;
|
|
let mut input_fields = [
|
|
InputFieldState::new(
|
|
INPUT_FIELD_ID,
|
|
INPUT_TEXT_ID,
|
|
"Primary input",
|
|
"Click or press Tab, then type...",
|
|
"",
|
|
),
|
|
InputFieldState::new(
|
|
SECOND_INPUT_FIELD_ID,
|
|
SECOND_INPUT_TEXT_ID,
|
|
"Secondary input",
|
|
"Use selection, Backspace, and middle-click paste here too...",
|
|
"",
|
|
),
|
|
];
|
|
let mut status_scroll_offset = 0.0_f32;
|
|
let mut scrollbar_drag = None::<ScrollbarDrag>;
|
|
let mut caret_visible = false;
|
|
let mut caret_blink_timer = None::<TimeoutHandle>;
|
|
let mut caret_blink_token = 0_u64;
|
|
let mut selection_autoscroll_timer = None::<IntervalHandle>;
|
|
let mut selection_autoscroll_token = 0_u64;
|
|
let mut selection = None;
|
|
let mut selection_drag = None;
|
|
let mut selection_drag_pointer = None::<ruin_ui::Point>;
|
|
let mut pending_selection_retarget = None::<PendingSelectionRetarget>;
|
|
let mut click_tracker = None::<ClickTracker>;
|
|
|
|
println!("Opening RUIN paragraph demo window...");
|
|
window.set_cursor_icon(current_cursor)?;
|
|
|
|
loop {
|
|
let Some(event) = ui.next_event().await else {
|
|
break;
|
|
};
|
|
|
|
let mut latest_configuration = None;
|
|
let mut resize_presented = false;
|
|
let mut pointer_events = Vec::new();
|
|
let mut keyboard_events = Vec::new();
|
|
let mut pending_wake_tokens = Vec::<u64>::new();
|
|
let mut pending_clipboard_text = None::<String>;
|
|
let mut pending_primary_selection_text = None::<String>;
|
|
let mut close_requested = false;
|
|
let mut closed = false;
|
|
|
|
for event in std::iter::once(event).chain(ui.take_pending_events()) {
|
|
match event {
|
|
PlatformEvent::Configured {
|
|
window_id,
|
|
configuration,
|
|
} if window_id == window.id() => {
|
|
latest_configuration = Some(configuration);
|
|
}
|
|
PlatformEvent::Pointer { window_id, event } if window_id == window.id() => {
|
|
pointer_events.push(event);
|
|
}
|
|
PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => {
|
|
tracing::trace!(
|
|
target: "ruin_ui_text_paragraph_demo::input",
|
|
keycode = event.keycode,
|
|
?event.kind,
|
|
?event.key,
|
|
text = event.text.as_deref().unwrap_or(""),
|
|
"received platform keyboard event"
|
|
);
|
|
keyboard_events.push(event);
|
|
}
|
|
PlatformEvent::Wake { window_id, token } if window_id == window.id() => {
|
|
pending_wake_tokens.push(token);
|
|
}
|
|
PlatformEvent::ClipboardText { window_id, text } if window_id == window.id() => {
|
|
pending_clipboard_text = Some(text);
|
|
}
|
|
PlatformEvent::PrimarySelectionText { window_id, text }
|
|
if window_id == window.id() =>
|
|
{
|
|
pending_primary_selection_text = Some(text);
|
|
}
|
|
PlatformEvent::FramePresented {
|
|
window_id,
|
|
scene_version,
|
|
..
|
|
} if window_id == window.id() => {
|
|
resize_presented =
|
|
in_flight_resize
|
|
.as_ref()
|
|
.is_some_and(|in_flight: &InFlightResize| {
|
|
in_flight.scene_version == scene_version
|
|
});
|
|
}
|
|
PlatformEvent::CloseRequested { window_id } if window_id == window.id() => {
|
|
close_requested = true;
|
|
}
|
|
PlatformEvent::Closed { window_id } if window_id == window.id() => {
|
|
closed = true;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
if latest_configuration.is_some()
|
|
|| !pointer_events.is_empty()
|
|
|| !keyboard_events.is_empty()
|
|
{
|
|
tracing::trace!(
|
|
target: "ruin_ui_text_paragraph_demo::events",
|
|
has_configured = latest_configuration.is_some(),
|
|
pointer_events = pointer_events.len(),
|
|
keyboard_events = keyboard_events.len(),
|
|
"processing coalesced event batch"
|
|
);
|
|
}
|
|
|
|
let has_resize_configuration = latest_configuration.is_some();
|
|
let pending_blink = pending_wake_tokens.contains(&caret_blink_token);
|
|
let pending_selection_autoscroll =
|
|
pending_wake_tokens.contains(&selection_autoscroll_token);
|
|
if let Some(configuration) = latest_configuration {
|
|
let next_viewport = configuration.actual_inner_size;
|
|
let is_duplicate =
|
|
pending_resize
|
|
.as_ref()
|
|
.is_some_and(|pending: &ruin_ui::WindowConfigured| {
|
|
pending.actual_inner_size == next_viewport
|
|
})
|
|
|| latest_submitted_viewport == Some(next_viewport);
|
|
if !is_duplicate {
|
|
pending_resize = Some(configuration);
|
|
}
|
|
}
|
|
|
|
if resize_presented
|
|
&& let Some(in_flight) = in_flight_resize.take()
|
|
&& pending_resize
|
|
.as_ref()
|
|
.is_some_and(|pending: &ruin_ui::WindowConfigured| {
|
|
pending.actual_inner_size == in_flight.viewport
|
|
})
|
|
{
|
|
pending_resize = None;
|
|
}
|
|
|
|
if in_flight_resize.is_none()
|
|
&& let Some(configuration) = pending_resize.take()
|
|
{
|
|
viewport = configuration.actual_inner_size;
|
|
version = version.wrapping_add(1);
|
|
let build_started = Instant::now();
|
|
tracing::debug!(
|
|
target: "ruin_ui_text_paragraph_demo::resize",
|
|
width = viewport.width,
|
|
height = viewport.height,
|
|
scene_version = version,
|
|
"rebuilding snapshot for configured size"
|
|
);
|
|
let LayoutSnapshot {
|
|
scene,
|
|
interaction_tree: next_interaction_tree,
|
|
} = build_snapshot(
|
|
viewport,
|
|
version,
|
|
hovered_card,
|
|
&hero_image,
|
|
status_scroll_offset,
|
|
&input_fields,
|
|
focused_element,
|
|
&mut text_system,
|
|
);
|
|
if selection.is_some_and(|selection: TextSelection| {
|
|
next_interaction_tree
|
|
.text_for_element(selection.focus.element_id)
|
|
.is_none_or(|prepared_text| !prepared_text.selectable)
|
|
}) {
|
|
selection = None;
|
|
selection_drag = None;
|
|
}
|
|
tracing::debug!(
|
|
target: "ruin_ui_text_paragraph_demo::resize",
|
|
width = viewport.width,
|
|
height = viewport.height,
|
|
scene_version = version,
|
|
build_ms = build_started.elapsed().as_secs_f64() * 1_000.0,
|
|
"finished snapshot rebuild for configured size"
|
|
);
|
|
window.replace_scene(scene_with_overlays(
|
|
&scene,
|
|
&next_interaction_tree,
|
|
selection,
|
|
focused_element,
|
|
&input_fields,
|
|
caret_visible,
|
|
version,
|
|
))?;
|
|
base_scene = Some(scene);
|
|
latest_submitted_viewport = Some(viewport);
|
|
in_flight_resize = Some(InFlightResize {
|
|
scene_version: version,
|
|
viewport,
|
|
});
|
|
interaction_tree = Some(next_interaction_tree);
|
|
}
|
|
|
|
let mut needs_hover_rebuild = false;
|
|
let mut needs_input_rebuild = false;
|
|
let mut needs_overlay_present = false;
|
|
let mut copied_primary_selection_text = None::<String>;
|
|
let mut copied_clipboard_text = None::<String>;
|
|
let mut request_primary_paste = false;
|
|
let mut request_clipboard_paste = false;
|
|
let resize_active =
|
|
has_resize_configuration || pending_resize.is_some() || in_flight_resize.is_some();
|
|
if !resize_active && let Some(current_interaction_tree) = interaction_tree.as_ref() {
|
|
for event in pointer_events {
|
|
if handle_status_scrollbar_drag_event(
|
|
current_interaction_tree,
|
|
event,
|
|
&mut focused_element,
|
|
&mut scrollbar_drag,
|
|
&mut status_scroll_offset,
|
|
) {
|
|
needs_input_rebuild = true;
|
|
continue;
|
|
}
|
|
if handle_status_scroll_pointer_event(
|
|
current_interaction_tree,
|
|
event,
|
|
&mut status_scroll_offset,
|
|
) {
|
|
needs_input_rebuild = true;
|
|
continue;
|
|
}
|
|
let input_outcome = handle_input_focus_event(
|
|
current_interaction_tree,
|
|
event,
|
|
&mut focused_element,
|
|
&mut input_fields,
|
|
&mut selection,
|
|
);
|
|
needs_input_rebuild |= input_outcome.focus_changed;
|
|
needs_overlay_present |=
|
|
input_outcome.caret_changed || input_outcome.selection_changed;
|
|
request_primary_paste |= input_outcome.request_primary_paste;
|
|
request_clipboard_paste |= input_outcome.request_clipboard_paste;
|
|
let should_retarget_after_rebuild = matches!(event.kind, PointerEventKind::Move)
|
|
|| matches!(
|
|
event.kind,
|
|
PointerEventKind::Up {
|
|
button: PointerButton::Primary
|
|
}
|
|
);
|
|
if should_retarget_after_rebuild
|
|
&& handle_status_selection_autoscroll(
|
|
current_interaction_tree,
|
|
selection_drag,
|
|
event.position,
|
|
&mut status_scroll_offset,
|
|
)
|
|
{
|
|
pending_selection_retarget =
|
|
selection_drag.map(|drag| PendingSelectionRetarget {
|
|
drag,
|
|
position: event.position,
|
|
finish: matches!(
|
|
event.kind,
|
|
PointerEventKind::Up {
|
|
button: PointerButton::Primary
|
|
}
|
|
),
|
|
});
|
|
needs_input_rebuild = true;
|
|
}
|
|
let selection_outcome = handle_selection_event(
|
|
current_interaction_tree,
|
|
event,
|
|
&mut selection,
|
|
&mut selection_drag,
|
|
&mut click_tracker,
|
|
);
|
|
needs_overlay_present |= selection_outcome.changed;
|
|
if pending_selection_retarget.is_none() && selection_outcome.copied_text.is_some() {
|
|
copied_primary_selection_text = selection_outcome.copied_text;
|
|
}
|
|
match event.kind {
|
|
PointerEventKind::Down {
|
|
button: PointerButton::Primary,
|
|
}
|
|
| PointerEventKind::Move => {
|
|
if selection_drag.is_some() {
|
|
selection_drag_pointer = Some(event.position);
|
|
}
|
|
}
|
|
PointerEventKind::Up {
|
|
button: PointerButton::Primary,
|
|
}
|
|
| PointerEventKind::LeaveWindow => {
|
|
selection_drag_pointer = None;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if selection_drag.is_none() {
|
|
let routed = pointer_router.route(current_interaction_tree, event);
|
|
let next_cursor = pointer_router
|
|
.hovered_targets()
|
|
.last()
|
|
.map(|target| target.cursor)
|
|
.unwrap_or(CursorIcon::Default);
|
|
if next_cursor != current_cursor {
|
|
current_cursor = next_cursor;
|
|
window.set_cursor_icon(current_cursor)?;
|
|
}
|
|
let next_hover = pointer_router
|
|
.hovered_targets()
|
|
.iter()
|
|
.rev()
|
|
.find_map(|target| target.element_id.filter(|id| is_hoverable_card(*id)));
|
|
if next_hover != hovered_card {
|
|
hovered_card = next_hover;
|
|
needs_hover_rebuild = true;
|
|
}
|
|
if routed.iter().any(|routed| {
|
|
routed.target.element_id == Some(RUST_LINK_ID)
|
|
&& matches!(
|
|
routed.kind,
|
|
RoutedPointerEventKind::Up {
|
|
button: PointerButton::Primary
|
|
}
|
|
)
|
|
}) {
|
|
open_rust_website();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !resize_active
|
|
&& pending_selection_autoscroll
|
|
&& let Some(current_interaction_tree) = interaction_tree.as_ref()
|
|
&& let Some(position) = selection_drag_pointer
|
|
&& handle_status_selection_autoscroll(
|
|
current_interaction_tree,
|
|
selection_drag,
|
|
position,
|
|
&mut status_scroll_offset,
|
|
)
|
|
{
|
|
pending_selection_retarget = selection_drag.map(|drag| PendingSelectionRetarget {
|
|
drag,
|
|
position,
|
|
finish: false,
|
|
});
|
|
needs_input_rebuild = true;
|
|
}
|
|
if let Some(current_interaction_tree) = interaction_tree.as_ref() {
|
|
for event in keyboard_events {
|
|
if handle_status_scroll_keyboard_event(
|
|
current_interaction_tree,
|
|
event.clone(),
|
|
focused_element,
|
|
&mut status_scroll_offset,
|
|
) {
|
|
needs_input_rebuild = true;
|
|
continue;
|
|
}
|
|
let input_outcome = handle_keyboard_input_event(
|
|
current_interaction_tree,
|
|
event,
|
|
&mut focused_element,
|
|
&mut input_fields,
|
|
&mut selection,
|
|
);
|
|
needs_input_rebuild |= input_outcome.focus_changed || input_outcome.text_changed;
|
|
needs_overlay_present |=
|
|
input_outcome.caret_changed || input_outcome.selection_changed;
|
|
request_primary_paste |= input_outcome.request_primary_paste;
|
|
request_clipboard_paste |= input_outcome.request_clipboard_paste;
|
|
if input_outcome.copied_text.is_some() {
|
|
copied_clipboard_text = input_outcome.copied_text;
|
|
}
|
|
}
|
|
}
|
|
if let Some(text) = pending_clipboard_text {
|
|
let input_outcome = insert_text_into_focused_input(
|
|
focused_element,
|
|
&mut input_fields,
|
|
&mut selection,
|
|
&text,
|
|
);
|
|
needs_input_rebuild |= input_outcome.text_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(
|
|
focused_element,
|
|
&mut input_fields,
|
|
&mut selection,
|
|
&text,
|
|
);
|
|
needs_input_rebuild |= input_outcome.text_changed;
|
|
needs_overlay_present |= input_outcome.caret_changed || input_outcome.selection_changed;
|
|
}
|
|
if needs_input_rebuild || needs_overlay_present {
|
|
reset_caret_blink(
|
|
&window,
|
|
focused_element,
|
|
&input_fields,
|
|
&mut caret_visible,
|
|
&mut caret_blink_timer,
|
|
&mut caret_blink_token,
|
|
);
|
|
}
|
|
if pending_blink && focused_input(focused_element, &input_fields).is_some() {
|
|
schedule_caret_blink(&window, &mut caret_blink_timer, caret_blink_token);
|
|
if in_flight_resize.is_none() {
|
|
caret_visible = !caret_visible;
|
|
needs_overlay_present = true;
|
|
}
|
|
}
|
|
if let Some(copied_text) = copied_primary_selection_text {
|
|
window.set_primary_selection_text(copied_text)?;
|
|
}
|
|
if let Some(copied_text) = copied_clipboard_text {
|
|
window.set_clipboard_text(copied_text)?;
|
|
}
|
|
if request_primary_paste {
|
|
window.request_primary_selection_text()?;
|
|
}
|
|
if request_clipboard_paste {
|
|
window.request_clipboard_text()?;
|
|
}
|
|
if (needs_hover_rebuild || needs_input_rebuild) && in_flight_resize.is_none() {
|
|
version = version.wrapping_add(1);
|
|
tracing::trace!(
|
|
target: "ruin_ui_text_paragraph_demo::hover",
|
|
scene_version = version,
|
|
hovered = hovered_card.map(ElementId::raw),
|
|
"rebuilding snapshot for hover change"
|
|
);
|
|
let LayoutSnapshot {
|
|
scene,
|
|
interaction_tree: next_interaction_tree,
|
|
} = build_snapshot(
|
|
viewport,
|
|
version,
|
|
hovered_card,
|
|
&hero_image,
|
|
status_scroll_offset,
|
|
&input_fields,
|
|
focused_element,
|
|
&mut text_system,
|
|
);
|
|
if selection.is_some_and(|selection: TextSelection| {
|
|
next_interaction_tree
|
|
.text_for_element(selection.focus.element_id)
|
|
.is_none_or(|prepared_text| !prepared_text.selectable)
|
|
}) {
|
|
selection = None;
|
|
selection_drag = None;
|
|
}
|
|
if let Some(copied_text) = retarget_selection_after_rebuild(
|
|
&next_interaction_tree,
|
|
&mut selection,
|
|
&mut selection_drag,
|
|
&mut pending_selection_retarget,
|
|
) {
|
|
window.set_primary_selection_text(copied_text)?;
|
|
}
|
|
window.replace_scene(scene_with_overlays(
|
|
&scene,
|
|
&next_interaction_tree,
|
|
selection,
|
|
focused_element,
|
|
&input_fields,
|
|
caret_visible,
|
|
version,
|
|
))?;
|
|
base_scene = Some(scene);
|
|
interaction_tree = Some(next_interaction_tree);
|
|
} else if needs_overlay_present
|
|
&& in_flight_resize.is_none()
|
|
&& let Some(base_scene) = base_scene.as_ref()
|
|
&& let Some(current_interaction_tree) = interaction_tree.as_ref()
|
|
{
|
|
version = version.wrapping_add(1);
|
|
window.replace_scene(scene_with_overlays(
|
|
base_scene,
|
|
current_interaction_tree,
|
|
selection,
|
|
focused_element,
|
|
&input_fields,
|
|
caret_visible,
|
|
version,
|
|
))?;
|
|
}
|
|
if let Some(current_interaction_tree) = interaction_tree.as_ref() {
|
|
sync_selection_autoscroll(
|
|
&window,
|
|
current_interaction_tree,
|
|
selection_drag,
|
|
selection_drag_pointer,
|
|
&mut selection_autoscroll_timer,
|
|
&mut selection_autoscroll_token,
|
|
);
|
|
} else if let Some(handle) = selection_autoscroll_timer.take() {
|
|
selection_autoscroll_token = selection_autoscroll_token.wrapping_add(1);
|
|
clear_interval(&handle);
|
|
}
|
|
|
|
if close_requested {
|
|
let _ = window.update(WindowUpdate::new().open(false));
|
|
}
|
|
if closed {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let _ = ui.shutdown();
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_snapshot(
|
|
viewport: UiSize,
|
|
version: u64,
|
|
hovered_card: Option<ElementId>,
|
|
hero_image: &ImageResource,
|
|
status_scroll_offset: f32,
|
|
input_fields: &[InputFieldState],
|
|
focused_element: Option<ElementId>,
|
|
text_system: &mut TextSystem,
|
|
) -> LayoutSnapshot {
|
|
let tree = build_document_tree(
|
|
viewport,
|
|
hovered_card,
|
|
hero_image,
|
|
status_scroll_offset,
|
|
input_fields,
|
|
focused_element,
|
|
);
|
|
layout_snapshot_with_text_system(version, viewport, &tree, text_system)
|
|
}
|
|
|
|
fn scrollbox_target_at_position(
|
|
interaction_tree: &InteractionTree,
|
|
position: ruin_ui::Point,
|
|
) -> Option<ElementId> {
|
|
interaction_tree
|
|
.hit_path(position)
|
|
.iter()
|
|
.rev()
|
|
.find_map(|target| {
|
|
let element_id = target.element_id?;
|
|
interaction_tree
|
|
.scroll_metrics_for_element(element_id)
|
|
.map(|_| element_id)
|
|
})
|
|
}
|
|
|
|
fn clamp_status_scroll_offset(
|
|
interaction_tree: &InteractionTree,
|
|
offset_y: f32,
|
|
current_offset_y: f32,
|
|
) -> f32 {
|
|
interaction_tree
|
|
.scroll_metrics_for_element(STATUS_SCROLLBOX_ID)
|
|
.map(|metrics| offset_y.clamp(0.0, metrics.max_offset_y))
|
|
.unwrap_or(current_offset_y)
|
|
}
|
|
|
|
fn adjust_status_scroll_offset(
|
|
interaction_tree: &InteractionTree,
|
|
status_scroll_offset: &mut f32,
|
|
delta_y: f32,
|
|
) -> bool {
|
|
let next_offset = clamp_status_scroll_offset(
|
|
interaction_tree,
|
|
*status_scroll_offset + delta_y,
|
|
*status_scroll_offset,
|
|
);
|
|
if (*status_scroll_offset - next_offset).abs() <= f32::EPSILON {
|
|
return false;
|
|
}
|
|
*status_scroll_offset = next_offset;
|
|
true
|
|
}
|
|
|
|
fn handle_status_scroll_pointer_event(
|
|
interaction_tree: &InteractionTree,
|
|
event: PointerEvent,
|
|
status_scroll_offset: &mut f32,
|
|
) -> bool {
|
|
let PointerEventKind::Scroll { delta } = event.kind else {
|
|
return false;
|
|
};
|
|
if scrollbox_target_at_position(interaction_tree, event.position) != Some(STATUS_SCROLLBOX_ID) {
|
|
return false;
|
|
}
|
|
adjust_status_scroll_offset(interaction_tree, status_scroll_offset, delta.y)
|
|
}
|
|
|
|
fn handle_status_scrollbar_drag_event(
|
|
interaction_tree: &InteractionTree,
|
|
event: PointerEvent,
|
|
focused_element: &mut Option<ElementId>,
|
|
scrollbar_drag: &mut Option<ScrollbarDrag>,
|
|
status_scroll_offset: &mut f32,
|
|
) -> bool {
|
|
let Some(metrics) = interaction_tree.scroll_metrics_for_element(STATUS_SCROLLBOX_ID) else {
|
|
return false;
|
|
};
|
|
|
|
match event.kind {
|
|
PointerEventKind::Down {
|
|
button: PointerButton::Primary,
|
|
} => {
|
|
let Some(thumb_rect) = metrics.scrollbar_thumb else {
|
|
return false;
|
|
};
|
|
if !thumb_rect.contains(event.position) {
|
|
return false;
|
|
}
|
|
*focused_element = Some(STATUS_SCROLLBOX_ID);
|
|
*scrollbar_drag = Some(ScrollbarDrag {
|
|
start_pointer_y: event.position.y,
|
|
start_offset_y: *status_scroll_offset,
|
|
});
|
|
true
|
|
}
|
|
PointerEventKind::Move => {
|
|
let Some(drag) = *scrollbar_drag else {
|
|
return false;
|
|
};
|
|
let Some(track_rect) = metrics.scrollbar_track else {
|
|
return false;
|
|
};
|
|
let Some(thumb_rect) = metrics.scrollbar_thumb else {
|
|
return false;
|
|
};
|
|
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 false;
|
|
}
|
|
let pointer_delta = event.position.y - drag.start_pointer_y;
|
|
let next_offset =
|
|
drag.start_offset_y + pointer_delta * (metrics.max_offset_y / thumb_travel);
|
|
let next_offset =
|
|
clamp_status_scroll_offset(interaction_tree, next_offset, *status_scroll_offset);
|
|
if (*status_scroll_offset - next_offset).abs() <= f32::EPSILON {
|
|
return true;
|
|
}
|
|
*status_scroll_offset = next_offset;
|
|
true
|
|
}
|
|
PointerEventKind::Up {
|
|
button: PointerButton::Primary,
|
|
} => scrollbar_drag.take().is_some(),
|
|
PointerEventKind::LeaveWindow => scrollbar_drag.take().is_some(),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn handle_status_scroll_keyboard_event(
|
|
interaction_tree: &InteractionTree,
|
|
event: KeyboardEvent,
|
|
focused_element: Option<ElementId>,
|
|
status_scroll_offset: &mut f32,
|
|
) -> bool {
|
|
if focused_element != Some(STATUS_SCROLLBOX_ID)
|
|
|| 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(STATUS_SCROLLBOX_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 {
|
|
KeyboardKey::ArrowUp => *status_scroll_offset - line_step,
|
|
KeyboardKey::ArrowDown => *status_scroll_offset + line_step,
|
|
KeyboardKey::Home => 0.0,
|
|
KeyboardKey::End => metrics.max_offset_y,
|
|
_ => return false,
|
|
};
|
|
let next_offset =
|
|
clamp_status_scroll_offset(interaction_tree, next_offset, *status_scroll_offset);
|
|
if (*status_scroll_offset - next_offset).abs() <= f32::EPSILON {
|
|
return false;
|
|
}
|
|
*status_scroll_offset = next_offset;
|
|
true
|
|
}
|
|
|
|
fn status_selection_autoscroll_delta(
|
|
interaction_tree: &InteractionTree,
|
|
drag: Option<SelectionDrag>,
|
|
position: ruin_ui::Point,
|
|
) -> Option<f32> {
|
|
if drag.is_none_or(|drag| drag.group_id != STATUS_SCROLLBOX_ID) {
|
|
return None;
|
|
}
|
|
let metrics = interaction_tree.scroll_metrics_for_element(STATUS_SCROLLBOX_ID)?;
|
|
if metrics.max_offset_y <= 0.0 {
|
|
return None;
|
|
}
|
|
|
|
let viewport = metrics.viewport_rect;
|
|
let top = viewport.origin.y;
|
|
let bottom = viewport.origin.y + viewport.size.height;
|
|
if position.y < top && metrics.offset_y > 0.0 {
|
|
Some(-((top - position.y) * 0.6).clamp(12.0, 56.0))
|
|
} else if position.y > bottom && metrics.offset_y < metrics.max_offset_y {
|
|
Some(((position.y - bottom) * 0.6).clamp(12.0, 56.0))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn handle_status_selection_autoscroll(
|
|
interaction_tree: &InteractionTree,
|
|
drag: Option<SelectionDrag>,
|
|
position: ruin_ui::Point,
|
|
status_scroll_offset: &mut f32,
|
|
) -> bool {
|
|
let Some(delta_y) = status_selection_autoscroll_delta(interaction_tree, drag, position) else {
|
|
return false;
|
|
};
|
|
adjust_status_scroll_offset(interaction_tree, status_scroll_offset, delta_y)
|
|
}
|
|
|
|
fn retarget_selection_after_rebuild(
|
|
interaction_tree: &InteractionTree,
|
|
selection: &mut Option<TextSelection>,
|
|
selection_drag: &mut Option<SelectionDrag>,
|
|
pending_selection_retarget: &mut Option<PendingSelectionRetarget>,
|
|
) -> Option<String> {
|
|
let pending = pending_selection_retarget.take()?;
|
|
let focus = selection_endpoint_for_group_position(
|
|
interaction_tree,
|
|
pending.drag.group_id,
|
|
pending.position,
|
|
)?;
|
|
let next_selection = TextSelection {
|
|
group_id: pending.drag.group_id,
|
|
anchor: pending.drag.anchor,
|
|
focus,
|
|
};
|
|
*selection = Some(next_selection);
|
|
if pending.finish {
|
|
*selection_drag = None;
|
|
return selection_text(interaction_tree, Some(next_selection));
|
|
}
|
|
*selection_drag = Some(pending.drag);
|
|
None
|
|
}
|
|
|
|
fn schedule_selection_autoscroll(
|
|
window: &WindowController,
|
|
selection_autoscroll_timer: &mut Option<IntervalHandle>,
|
|
selection_autoscroll_token: u64,
|
|
) {
|
|
if selection_autoscroll_timer.is_some() {
|
|
return;
|
|
}
|
|
let window = window.clone();
|
|
*selection_autoscroll_timer = Some(set_interval(SELECTION_AUTOSCROLL_INTERVAL, move || {
|
|
let _ = window.emit_wake(selection_autoscroll_token);
|
|
}));
|
|
}
|
|
|
|
fn sync_selection_autoscroll(
|
|
window: &WindowController,
|
|
interaction_tree: &InteractionTree,
|
|
selection_drag: Option<SelectionDrag>,
|
|
selection_drag_pointer: Option<ruin_ui::Point>,
|
|
selection_autoscroll_timer: &mut Option<IntervalHandle>,
|
|
selection_autoscroll_token: &mut u64,
|
|
) {
|
|
let should_run = selection_drag_pointer
|
|
.and_then(|position| {
|
|
status_selection_autoscroll_delta(interaction_tree, selection_drag, position)
|
|
})
|
|
.is_some();
|
|
if should_run {
|
|
schedule_selection_autoscroll(
|
|
window,
|
|
selection_autoscroll_timer,
|
|
*selection_autoscroll_token,
|
|
);
|
|
} else if let Some(handle) = selection_autoscroll_timer.take() {
|
|
*selection_autoscroll_token = selection_autoscroll_token.wrapping_add(1);
|
|
clear_interval(&handle);
|
|
}
|
|
}
|
|
|
|
fn handle_selection_event(
|
|
interaction_tree: &InteractionTree,
|
|
event: PointerEvent,
|
|
selection: &mut Option<TextSelection>,
|
|
selection_drag: &mut Option<SelectionDrag>,
|
|
click_tracker: &mut Option<ClickTracker>,
|
|
) -> SelectionOutcome {
|
|
let mut outcome = SelectionOutcome::default();
|
|
match event.kind {
|
|
PointerEventKind::Down {
|
|
button: PointerButton::Primary,
|
|
} => {
|
|
let next_selection = selection_at_position(interaction_tree, event.position).and_then(
|
|
|(group_id, endpoint)| {
|
|
let prepared_text = interaction_tree.text_for_element(endpoint.element_id)?;
|
|
let click_count =
|
|
update_click_tracker(click_tracker, endpoint.element_id, event.position);
|
|
Some(match click_count {
|
|
2 => {
|
|
let range = prepared_text.word_range_for_offset(endpoint.offset);
|
|
TextSelection {
|
|
group_id,
|
|
anchor: SelectionEndpoint {
|
|
element_id: endpoint.element_id,
|
|
offset: range.start,
|
|
},
|
|
focus: SelectionEndpoint {
|
|
element_id: endpoint.element_id,
|
|
offset: range.end,
|
|
},
|
|
}
|
|
}
|
|
3 => {
|
|
single_text_selection(endpoint.element_id, 0, prepared_text.text.len())
|
|
}
|
|
_ => TextSelection {
|
|
group_id,
|
|
anchor: endpoint,
|
|
focus: endpoint,
|
|
},
|
|
})
|
|
},
|
|
);
|
|
if let Some(next_selection) = next_selection {
|
|
*selection_drag = next_selection.is_collapsed().then_some(SelectionDrag {
|
|
group_id: next_selection.group_id,
|
|
anchor: next_selection.anchor,
|
|
});
|
|
outcome.changed = *selection != Some(next_selection);
|
|
if !next_selection.is_collapsed() {
|
|
outcome.copied_text = selection_text(interaction_tree, Some(next_selection));
|
|
}
|
|
*selection = Some(next_selection);
|
|
} else {
|
|
*click_tracker = None;
|
|
outcome.changed = selection.take().is_some();
|
|
*selection_drag = None;
|
|
}
|
|
}
|
|
PointerEventKind::Move => {
|
|
if let Some(drag) = *selection_drag
|
|
&& let Some(focus) = selection_endpoint_for_group_position(
|
|
interaction_tree,
|
|
drag.group_id,
|
|
event.position,
|
|
)
|
|
{
|
|
let next_selection = TextSelection {
|
|
group_id: drag.group_id,
|
|
anchor: drag.anchor,
|
|
focus,
|
|
};
|
|
outcome.changed = *selection != Some(next_selection);
|
|
*selection = Some(next_selection);
|
|
}
|
|
}
|
|
PointerEventKind::Up {
|
|
button: PointerButton::Primary,
|
|
} => {
|
|
let Some(drag) = selection_drag.take() else {
|
|
return outcome;
|
|
};
|
|
let Some(focus) = selection_endpoint_for_group_position(
|
|
interaction_tree,
|
|
drag.group_id,
|
|
event.position,
|
|
) else {
|
|
return outcome;
|
|
};
|
|
let next_selection = TextSelection {
|
|
group_id: drag.group_id,
|
|
anchor: drag.anchor,
|
|
focus,
|
|
};
|
|
outcome.changed = *selection != Some(next_selection);
|
|
outcome.copied_text = selection_text(interaction_tree, Some(next_selection));
|
|
*selection = Some(next_selection);
|
|
}
|
|
PointerEventKind::Down { .. } | PointerEventKind::Up { .. } => {}
|
|
PointerEventKind::Scroll { .. } | PointerEventKind::LeaveWindow => {}
|
|
}
|
|
outcome
|
|
}
|
|
|
|
fn handle_input_focus_event(
|
|
interaction_tree: &InteractionTree,
|
|
event: PointerEvent,
|
|
focused_element: &mut Option<ElementId>,
|
|
input_fields: &mut [InputFieldState],
|
|
selection: &mut Option<TextSelection>,
|
|
) -> InputOutcome {
|
|
let mut outcome = InputOutcome::default();
|
|
let is_middle_click = matches!(
|
|
event.kind,
|
|
PointerEventKind::Down {
|
|
button: PointerButton::Middle
|
|
}
|
|
);
|
|
let is_primary_click = matches!(
|
|
event.kind,
|
|
PointerEventKind::Down {
|
|
button: PointerButton::Primary
|
|
}
|
|
);
|
|
if !is_primary_click && !is_middle_click {
|
|
return outcome;
|
|
}
|
|
|
|
let next_focus = interaction_tree
|
|
.hit_path(event.position)
|
|
.iter()
|
|
.rev()
|
|
.find_map(|target| target.focusable.then_some(target.element_id).flatten());
|
|
if next_focus != *focused_element {
|
|
*focused_element = next_focus;
|
|
outcome.focus_changed = true;
|
|
}
|
|
|
|
if let Some(input_field) = focused_input_mut(next_focus, input_fields) {
|
|
let next_caret = interaction_tree
|
|
.text_for_element(input_field.text_id)
|
|
.map(|prepared_text| prepared_text.byte_offset_for_position(event.position))
|
|
.unwrap_or(input_field.text.len());
|
|
if next_caret != input_field.caret {
|
|
input_field.caret = next_caret;
|
|
outcome.caret_changed = true;
|
|
}
|
|
outcome.selection_changed |= clear_input_selection_for(selection, input_field);
|
|
outcome.request_primary_paste = is_middle_click;
|
|
}
|
|
|
|
if outcome.focus_changed || outcome.caret_changed || outcome.request_primary_paste {
|
|
tracing::trace!(
|
|
target: "ruin_ui_text_paragraph_demo::input",
|
|
pointer_x = event.position.x,
|
|
pointer_y = event.position.y,
|
|
focused = focused_element.map(|id| id.raw()),
|
|
caret = focused_input(*focused_element, input_fields)
|
|
.map(|input_field| input_field.caret)
|
|
.unwrap_or(0),
|
|
focus_changed = outcome.focus_changed,
|
|
caret_changed = outcome.caret_changed,
|
|
request_primary_paste = outcome.request_primary_paste,
|
|
"processed pointer focus event"
|
|
);
|
|
}
|
|
|
|
outcome
|
|
}
|
|
|
|
fn handle_keyboard_input_event(
|
|
interaction_tree: &InteractionTree,
|
|
event: KeyboardEvent,
|
|
focused_element: &mut Option<ElementId>,
|
|
input_fields: &mut [InputFieldState],
|
|
selection: &mut Option<TextSelection>,
|
|
) -> InputOutcome {
|
|
let mut outcome = InputOutcome::default();
|
|
let focus_before = *focused_element;
|
|
let text_len_before = focused_input(*focused_element, input_fields)
|
|
.map(|input_field| input_field.text.len())
|
|
.unwrap_or(0);
|
|
let caret_before = focused_input(*focused_element, input_fields)
|
|
.map(|input_field| input_field.caret)
|
|
.unwrap_or(0);
|
|
if event.kind != KeyboardEventKind::Pressed {
|
|
return outcome;
|
|
}
|
|
|
|
if matches!(&event.key, KeyboardKey::Tab) {
|
|
*focused_element = Some(next_input_focus(*focused_element, input_fields));
|
|
if let Some(input_field) = focused_input_mut(*focused_element, input_fields) {
|
|
outcome.selection_changed |= clear_input_selection_for(selection, input_field);
|
|
}
|
|
outcome.focus_changed = true;
|
|
tracing::trace!(
|
|
target: "ruin_ui_text_paragraph_demo::input",
|
|
keycode = event.keycode,
|
|
?event.key,
|
|
text = event.text.as_deref().unwrap_or(""),
|
|
focus_before = focus_before.map(|id| id.raw()),
|
|
focus_after = focused_element.map(|id| id.raw()),
|
|
"focused input from keyboard"
|
|
);
|
|
return outcome;
|
|
}
|
|
|
|
if event.modifiers.control {
|
|
if shortcut_matches(&event, 'a') {
|
|
if let Some(input_field) = focused_input_mut(*focused_element, input_fields) {
|
|
let end = input_field.text.len();
|
|
let next_selection = single_text_selection(input_field.text_id, 0, end);
|
|
outcome.selection_changed = *selection != Some(next_selection);
|
|
*selection = Some(next_selection);
|
|
if input_field.caret != end {
|
|
input_field.caret = end;
|
|
outcome.caret_changed = true;
|
|
}
|
|
} else if let Some((current_selection, prepared_text)) =
|
|
selection_target_prepared_text(interaction_tree, *selection)
|
|
{
|
|
let next_selection =
|
|
full_group_selection(interaction_tree, current_selection.group_id)
|
|
.unwrap_or_else(|| {
|
|
single_text_selection(
|
|
current_selection.focus.element_id,
|
|
0,
|
|
prepared_text.text.len(),
|
|
)
|
|
});
|
|
outcome.selection_changed = *selection != Some(next_selection);
|
|
*selection = Some(next_selection);
|
|
}
|
|
} else if shortcut_matches(&event, 'c') {
|
|
if let Some(input_field) = focused_input(*focused_element, input_fields) {
|
|
outcome.copied_text = active_input_selection_bounds(*selection, input_field)
|
|
.and_then(|(start, end)| {
|
|
selection_text(
|
|
interaction_tree,
|
|
Some(single_text_selection(input_field.text_id, start, end)),
|
|
)
|
|
});
|
|
} else if let Some(current_selection) = *selection {
|
|
outcome.copied_text = selection_text(interaction_tree, Some(current_selection));
|
|
}
|
|
} else if shortcut_matches(&event, 'x') {
|
|
if let Some(input_index) = focused_input_index(*focused_element, input_fields) {
|
|
outcome.copied_text = selection_text(interaction_tree, *selection);
|
|
if let Some(range) =
|
|
active_input_selection_bounds(*selection, &input_fields[input_index])
|
|
{
|
|
let mut replacement =
|
|
replace_input_range(&mut input_fields[input_index], selection, range, "");
|
|
replacement.copied_text = outcome.copied_text.take();
|
|
outcome = replacement;
|
|
}
|
|
}
|
|
} else if shortcut_matches(&event, 'v')
|
|
&& focused_input(*focused_element, input_fields).is_some()
|
|
{
|
|
outcome.request_clipboard_paste = true;
|
|
}
|
|
} else if let Some(input_index) = focused_input_index(*focused_element, input_fields) {
|
|
let text_id = input_fields[input_index].text_id;
|
|
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.group_id == text_id
|
|
&& current.anchor.element_id == text_id
|
|
&& current.focus.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.group_id == text_id
|
|
&& current.anchor.element_id == text_id
|
|
&& current.focus.element_id == text_id
|
|
})
|
|
.map_or(input_fields[input_index].caret, |current| {
|
|
selection_navigation_anchor_and_focus(current, &event.key).0
|
|
});
|
|
let next_selection = single_text_selection(text_id, anchor, next_caret);
|
|
outcome.selection_changed = *selection != Some(next_selection);
|
|
*selection = Some(next_selection);
|
|
if input_fields[input_index].caret != next_caret {
|
|
input_fields[input_index].caret = next_caret;
|
|
outcome.caret_changed = true;
|
|
}
|
|
} else {
|
|
let input_field = &mut input_fields[input_index];
|
|
match &event.key {
|
|
KeyboardKey::Escape => {
|
|
outcome.selection_changed |= clear_input_selection_for(selection, input_field);
|
|
*focused_element = None;
|
|
outcome.focus_changed = true;
|
|
}
|
|
KeyboardKey::ArrowLeft | KeyboardKey::ArrowRight => {
|
|
if let Some((start, end)) =
|
|
active_input_selection_bounds(*selection, input_field)
|
|
{
|
|
input_field.caret = if matches!(&event.key, KeyboardKey::ArrowLeft) {
|
|
start
|
|
} else {
|
|
end
|
|
};
|
|
*selection = None;
|
|
outcome.caret_changed = true;
|
|
outcome.selection_changed = true;
|
|
} else if let Some(prepared_text) = prepared_text
|
|
&& let Some(next_caret) =
|
|
navigation_target_offset(prepared_text, input_field.caret, &event.key)
|
|
&& next_caret != input_field.caret
|
|
{
|
|
input_field.caret = next_caret;
|
|
outcome.caret_changed = true;
|
|
}
|
|
}
|
|
KeyboardKey::ArrowUp
|
|
| KeyboardKey::ArrowDown
|
|
| KeyboardKey::Home
|
|
| KeyboardKey::End => {
|
|
if let Some(prepared_text) = prepared_text
|
|
&& let Some(next_caret) =
|
|
navigation_target_offset(prepared_text, input_field.caret, &event.key)
|
|
&& next_caret != input_field.caret
|
|
{
|
|
input_field.caret = next_caret;
|
|
outcome.caret_changed = true;
|
|
}
|
|
outcome.selection_changed |= clear_input_selection_for(selection, input_field);
|
|
}
|
|
KeyboardKey::Backspace => {
|
|
if let Some(range) = active_input_selection_bounds(*selection, input_field) {
|
|
outcome = replace_input_range(input_field, selection, range, "");
|
|
} else if let Some(prepared_text) = prepared_text {
|
|
let previous = prepared_text.previous_char_boundary(input_field.caret);
|
|
if previous != input_field.caret {
|
|
input_field
|
|
.text
|
|
.replace_range(previous..input_field.caret, "");
|
|
input_field.caret = previous;
|
|
outcome.text_changed = true;
|
|
outcome.caret_changed = true;
|
|
}
|
|
}
|
|
}
|
|
KeyboardKey::Delete => {
|
|
if let Some(range) = active_input_selection_bounds(*selection, input_field) {
|
|
outcome = replace_input_range(input_field, selection, range, "");
|
|
} else if let Some(prepared_text) = prepared_text {
|
|
let next = prepared_text.next_char_boundary(input_field.caret);
|
|
if next != input_field.caret {
|
|
input_field.text.replace_range(input_field.caret..next, "");
|
|
outcome.text_changed = true;
|
|
}
|
|
}
|
|
}
|
|
KeyboardKey::Enter | KeyboardKey::Tab => {}
|
|
_ => {
|
|
let inserted_text =
|
|
event
|
|
.text
|
|
.clone()
|
|
.filter(|text| !text.is_empty())
|
|
.or_else(|| match &event.key {
|
|
KeyboardKey::Character(text) if !text.is_empty() => {
|
|
Some(text.clone())
|
|
}
|
|
_ => None,
|
|
});
|
|
if !event.modifiers.alt
|
|
&& !event.modifiers.super_key
|
|
&& let Some(text) = inserted_text
|
|
{
|
|
outcome = insert_text_into_focused_input(
|
|
*focused_element,
|
|
input_fields,
|
|
selection,
|
|
&text,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if event.modifiers.shift
|
|
&& let Some((current_selection, prepared_text)) =
|
|
selection_target_prepared_text(interaction_tree, *selection)
|
|
&& let Some(next_focus) = navigation_target_offset(
|
|
prepared_text,
|
|
selection_navigation_anchor_and_focus(current_selection, &event.key).1,
|
|
&event.key,
|
|
)
|
|
{
|
|
let next_selection = single_text_selection(
|
|
current_selection.focus.element_id,
|
|
selection_navigation_anchor_and_focus(current_selection, &event.key).0,
|
|
next_focus,
|
|
);
|
|
outcome.selection_changed = *selection != Some(next_selection);
|
|
*selection = Some(next_selection);
|
|
} else {
|
|
tracing::trace!(
|
|
target: "ruin_ui_text_paragraph_demo::input",
|
|
keycode = event.keycode,
|
|
?event.key,
|
|
text = event.text.as_deref().unwrap_or(""),
|
|
focus_before = focus_before.map(|id| id.raw()),
|
|
"ignored keyboard event because input is not focused"
|
|
);
|
|
return outcome;
|
|
}
|
|
|
|
tracing::trace!(
|
|
target: "ruin_ui_text_paragraph_demo::input",
|
|
keycode = event.keycode,
|
|
?event.key,
|
|
text = event.text.as_deref().unwrap_or(""),
|
|
focus_before = focus_before.map(|id| id.raw()),
|
|
focus_after = focused_element.map(|id| id.raw()),
|
|
text_len_before,
|
|
text_len_after = focused_input(*focused_element, input_fields)
|
|
.map(|input_field| input_field.text.len())
|
|
.unwrap_or(0),
|
|
caret_before,
|
|
caret_after = focused_input(*focused_element, input_fields)
|
|
.map(|input_field| input_field.caret)
|
|
.unwrap_or(0),
|
|
text_changed = outcome.text_changed,
|
|
caret_changed = outcome.caret_changed,
|
|
focus_changed = outcome.focus_changed,
|
|
selection_changed = outcome.selection_changed,
|
|
"processed keyboard input event"
|
|
);
|
|
|
|
outcome
|
|
}
|
|
|
|
fn schedule_caret_blink(
|
|
window: &WindowController,
|
|
caret_blink_timer: &mut Option<TimeoutHandle>,
|
|
caret_blink_token: u64,
|
|
) {
|
|
if let Some(handle) = caret_blink_timer.take() {
|
|
clear_timeout(&handle);
|
|
}
|
|
let window = window.clone();
|
|
*caret_blink_timer = Some(set_timeout(INPUT_CARET_BLINK_INTERVAL, move || {
|
|
let _ = window.emit_wake(caret_blink_token);
|
|
}));
|
|
}
|
|
|
|
fn reset_caret_blink(
|
|
window: &WindowController,
|
|
focused_element: Option<ElementId>,
|
|
input_fields: &[InputFieldState],
|
|
caret_visible: &mut bool,
|
|
caret_blink_timer: &mut Option<TimeoutHandle>,
|
|
caret_blink_token: &mut u64,
|
|
) {
|
|
*caret_blink_token = caret_blink_token.wrapping_add(1);
|
|
if let Some(handle) = caret_blink_timer.take() {
|
|
clear_timeout(&handle);
|
|
}
|
|
if focused_input(focused_element, input_fields).is_some() {
|
|
*caret_visible = true;
|
|
schedule_caret_blink(window, caret_blink_timer, *caret_blink_token);
|
|
} else {
|
|
*caret_visible = false;
|
|
}
|
|
}
|
|
|
|
fn scene_with_overlays(
|
|
base_scene: &SceneSnapshot,
|
|
interaction_tree: &InteractionTree,
|
|
selection: Option<TextSelection>,
|
|
focused_element: Option<ElementId>,
|
|
input_fields: &[InputFieldState],
|
|
caret_visible: bool,
|
|
version: u64,
|
|
) -> SceneSnapshot {
|
|
let mut scene = base_scene.clone();
|
|
scene.version = version;
|
|
let mut items = Vec::with_capacity(scene.items.len() + 8);
|
|
let selection = selection.filter(|selection| !selection.is_collapsed());
|
|
for item in scene.items.drain(..) {
|
|
if let Some(selection) = selection
|
|
&& let DisplayItem::Text(prepared_text) = &item
|
|
&& let Some(element_id) = prepared_text.element_id
|
|
&& let Some(range) =
|
|
selection_range_for_prepared_text(interaction_tree, selection, element_id)
|
|
{
|
|
for rect in prepared_text.selection_rects(range.start, range.end) {
|
|
items.push(DisplayItem::Quad(Quad::new(
|
|
rect,
|
|
prepared_text.selection_style.highlight_color,
|
|
)));
|
|
}
|
|
let mut selected_text = prepared_text.clone();
|
|
selected_text.apply_selected_text_color(range.start, range.end);
|
|
items.push(DisplayItem::Text(selected_text));
|
|
continue;
|
|
}
|
|
items.push(item);
|
|
}
|
|
if caret_visible
|
|
&& let Some(input_field) = focused_input(focused_element, input_fields)
|
|
&& !has_active_input_selection(selection, input_field)
|
|
&& let Some(prepared_text) = interaction_tree.text_for_element(input_field.text_id)
|
|
&& let Some(caret_rect) = prepared_text.caret_rect(input_field.caret, 2.0)
|
|
{
|
|
items.push(DisplayItem::Quad(Quad::new(
|
|
caret_rect,
|
|
Color::rgb(0xF5, 0xF7, 0xFB),
|
|
)));
|
|
}
|
|
scene.items = items;
|
|
scene
|
|
}
|
|
|
|
fn demo_body_style(font_size: f32, color: Color) -> TextStyle {
|
|
TextStyle::new(font_size, color).with_selection_style(DEMO_SELECTION_STYLE)
|
|
}
|
|
|
|
fn open_rust_website() {
|
|
if let Err(error) = Command::new("xdg-open")
|
|
.arg("https://www.rust-lang.org")
|
|
.spawn()
|
|
{
|
|
tracing::debug!(
|
|
target: "ruin_ui_text_paragraph_demo::link",
|
|
error = %error,
|
|
"failed to open Rust website"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn input_field_element(input_field: &InputFieldState, focused: bool) -> Element {
|
|
let field_background = if focused {
|
|
Color::rgb(0x10, 0x1A, 0x2A)
|
|
} else {
|
|
Color::rgb(0x12, 0x18, 0x24)
|
|
};
|
|
let text_element = if !focused && input_field.text.is_empty() {
|
|
Element::text(
|
|
input_field.placeholder,
|
|
TextStyle::new(18.0, Color::rgb(0x7D, 0x89, 0x9E))
|
|
.with_line_height(24.0)
|
|
.with_wrap(TextWrap::None)
|
|
.with_selectable(false),
|
|
)
|
|
} else {
|
|
Element::text(
|
|
input_field.text.as_str(),
|
|
TextStyle::new(18.0, Color::rgb(0xF5, 0xF7, 0xFB))
|
|
.with_line_height(24.0)
|
|
.with_wrap(TextWrap::None)
|
|
.with_selectable(true),
|
|
)
|
|
.id(input_field.text_id)
|
|
};
|
|
|
|
Element::column().gap(10.0).children([
|
|
Element::paragraph(
|
|
input_field.label,
|
|
TextStyle::new(16.0, Color::rgb(0xA7, 0xF3, 0xD0))
|
|
.with_line_height(22.0)
|
|
.with_selectable(false),
|
|
),
|
|
Element::column()
|
|
.id(input_field.field_id)
|
|
.focusable(true)
|
|
.cursor(CursorIcon::Text)
|
|
.padding(Edges::symmetric(14.0, 12.0))
|
|
.background(field_background)
|
|
.child(text_element.cursor(CursorIcon::Text)),
|
|
])
|
|
}
|
|
|
|
fn build_document_tree(
|
|
viewport: UiSize,
|
|
hovered_card: Option<ElementId>,
|
|
hero_image: &ImageResource,
|
|
status_scroll_offset: f32,
|
|
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::row()
|
|
.padding(Edges::all(gutter))
|
|
.gap(gutter)
|
|
.background(Color::rgb(0x16, 0x1D, 0x2B))
|
|
.children([
|
|
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)
|
|
.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()
|
|
.flex(1.0)
|
|
.gap(gutter)
|
|
.children([
|
|
text_card(
|
|
WHY_CARD_ID,
|
|
hovered_card,
|
|
"Why paragraphs matter",
|
|
BODY_ONE,
|
|
gutter,
|
|
),
|
|
text_card(
|
|
CALM_CARD_ID,
|
|
hovered_card,
|
|
"Calmer inspection surface",
|
|
BODY_TWO,
|
|
gutter,
|
|
),
|
|
Element::column()
|
|
.id(NEXT_CARD_ID)
|
|
.padding(Edges::all(gutter))
|
|
.gap(gutter * 0.45)
|
|
.background(card_background(NEXT_CARD_ID, hovered_card))
|
|
.border(2.0, card_border_color(NEXT_CARD_ID, hovered_card))
|
|
.corner_radius(gutter * 0.6)
|
|
.shadow(card_key_shadow(NEXT_CARD_ID, hovered_card))
|
|
.shadow(card_ambient_shadow(NEXT_CARD_ID, hovered_card))
|
|
.children([
|
|
Element::paragraph(
|
|
"Next direction",
|
|
TextStyle::new(
|
|
20.0,
|
|
card_title_color(NEXT_CARD_ID, hovered_card),
|
|
)
|
|
.with_line_height(26.0),
|
|
)
|
|
.id(card_text_id(NEXT_CARD_ID, 1)),
|
|
Element::rich_paragraph(
|
|
[
|
|
TextSpan::new("Next up after this slice: "),
|
|
TextSpan::new("paragraph/block")
|
|
.weight(TextSpanWeight::Semibold)
|
|
.color(Color::rgb(0xF5, 0xD0, 0x74)),
|
|
TextSpan::new(" rules, then "),
|
|
TextSpan::new("inline")
|
|
.weight(TextSpanWeight::Semibold)
|
|
.color(Color::rgb(0xA7, 0xF3, 0xD0)),
|
|
TextSpan::new(" style runs, then "),
|
|
TextSpan::new("interactive")
|
|
.weight(TextSpanWeight::Bold)
|
|
.color(Color::rgb(0x7D, 0xD3, 0xFC)),
|
|
TextSpan::new(" text editing and selection."),
|
|
],
|
|
demo_body_style(17.0, Color::rgb(0xD8, 0xDF, 0xED))
|
|
.with_line_height(26.0),
|
|
)
|
|
.id(card_text_id(NEXT_CARD_ID, 2)),
|
|
]),
|
|
]),
|
|
Element::column()
|
|
.width(sidebar_width)
|
|
.gap(gutter)
|
|
.children([
|
|
sidebar_card(
|
|
QUOTE_CARD_ID,
|
|
hovered_card,
|
|
"Centered pull quote",
|
|
"“A retained layout tree is only really convincing once text can participate in it naturally.”",
|
|
gutter,
|
|
SidebarCardOptions {
|
|
align: Some(TextAlign::Center),
|
|
font_family: Some(TextFontFamily::Serif),
|
|
max_lines: None,
|
|
},
|
|
),
|
|
rich_sidebar_card(hovered_card, gutter),
|
|
status_scroll_card(
|
|
hovered_card,
|
|
focused_element,
|
|
gutter,
|
|
status_scroll_offset,
|
|
),
|
|
]),
|
|
]),
|
|
])
|
|
}
|
|
|
|
fn text_card(
|
|
id: ElementId,
|
|
hovered_card: Option<ElementId>,
|
|
title: &str,
|
|
body: &str,
|
|
gutter: f32,
|
|
) -> Element {
|
|
Element::column()
|
|
.id(id)
|
|
.padding(Edges::all(gutter))
|
|
.gap(gutter * 0.45)
|
|
.background(card_background(id, hovered_card))
|
|
.children([
|
|
Element::paragraph(
|
|
title,
|
|
TextStyle::new(24.0, card_title_color(id, hovered_card)).with_line_height(30.0),
|
|
)
|
|
.id(card_text_id(id, 1)),
|
|
Element::paragraph(
|
|
body,
|
|
demo_body_style(18.0, Color::rgb(0xD9, 0xE0, 0xEE)).with_line_height(29.0),
|
|
)
|
|
.id(card_text_id(id, 2)),
|
|
])
|
|
}
|
|
|
|
fn sidebar_card(
|
|
id: ElementId,
|
|
hovered_card: Option<ElementId>,
|
|
title: &str,
|
|
body: &str,
|
|
gutter: f32,
|
|
options: SidebarCardOptions,
|
|
) -> Element {
|
|
let mut body_style = demo_body_style(16.0, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(25.0);
|
|
if let Some(align) = options.align {
|
|
body_style = body_style.with_align(align);
|
|
}
|
|
if let Some(font_family) = options.font_family {
|
|
body_style = body_style.with_font_family(font_family);
|
|
}
|
|
if let Some(max_lines) = options.max_lines {
|
|
body_style = body_style.with_max_lines(max_lines);
|
|
}
|
|
|
|
Element::column()
|
|
.id(id)
|
|
.padding(Edges::all(gutter * 0.9))
|
|
.gap(gutter * 0.35)
|
|
.background(card_background(id, hovered_card))
|
|
.children([
|
|
Element::paragraph(
|
|
title,
|
|
TextStyle::new(18.0, card_title_color(id, hovered_card)).with_line_height(24.0),
|
|
)
|
|
.id(card_text_id(id, 1)),
|
|
Element::paragraph(body, body_style).id(card_text_id(id, 2)),
|
|
])
|
|
}
|
|
|
|
fn status_scrollbar_style(active: bool) -> ScrollbarStyle {
|
|
ScrollbarStyle::new(
|
|
15.0,
|
|
if active {
|
|
Color::rgba(0xF5, 0xD0, 0x74, 0x24)
|
|
} else {
|
|
Color::rgba(0xFF, 0xFF, 0xFF, 0x12)
|
|
},
|
|
if active {
|
|
Color::rgba(0xF5, 0xD0, 0x74, 0xC8)
|
|
} else {
|
|
Color::rgba(0xC8, 0xD1, 0xE3, 0x90)
|
|
},
|
|
)
|
|
.with_corner_radius(999.0)
|
|
.with_min_thumb_size(36.0)
|
|
}
|
|
|
|
fn status_scroll_card(
|
|
hovered_card: Option<ElementId>,
|
|
focused_element: Option<ElementId>,
|
|
gutter: f32,
|
|
status_scroll_offset: f32,
|
|
) -> Element {
|
|
let scrollbox_focused = focused_element == Some(STATUS_SCROLLBOX_ID);
|
|
let scrollbox_active = scrollbox_focused || hovered_card == Some(STATUS_CARD_ID);
|
|
|
|
Element::column()
|
|
.id(STATUS_CARD_ID)
|
|
.padding(Edges::all(gutter * 0.9))
|
|
.gap(gutter * 0.35)
|
|
.background(card_background(STATUS_CARD_ID, hovered_card))
|
|
.children([
|
|
Element::paragraph(
|
|
"Scrollable status",
|
|
TextStyle::new(18.0, card_title_color(STATUS_CARD_ID, hovered_card))
|
|
.with_line_height(24.0),
|
|
)
|
|
.id(card_text_id(STATUS_CARD_ID, 1)),
|
|
Element::scroll_box(status_scroll_offset)
|
|
.id(STATUS_SCROLLBOX_ID)
|
|
.height((gutter * 8.8).clamp(170.0, 230.0))
|
|
.padding(Edges {
|
|
top: 0.0,
|
|
right: 0.0,
|
|
bottom: 0.0,
|
|
left: gutter * 0.45,
|
|
})
|
|
.gap(gutter * 0.45)
|
|
.background(Color::rgb(0x12, 0x1A, 0x27))
|
|
.border(
|
|
1.0,
|
|
if scrollbox_active {
|
|
Color::rgba(0xF5, 0xD0, 0x74, 0xA8)
|
|
} else {
|
|
Color::rgba(0x8B, 0x99, 0xAD, 0x52)
|
|
},
|
|
)
|
|
// .corner_radius(gutter * 0.45)
|
|
.corner_radius(7.5)
|
|
.scrollbar_style(status_scrollbar_style(scrollbox_active))
|
|
.children([
|
|
Element::rich_paragraph(
|
|
[
|
|
TextSpan::new("This box is clipped and "),
|
|
TextSpan::new("wheel-scrollable")
|
|
.weight(TextSpanWeight::Semibold)
|
|
.color(Color::rgb(0xF5, 0xD0, 0x74)),
|
|
TextSpan::new(
|
|
". Click it to focus, then use the arrow keys or Home/End to move through the content.",
|
|
),
|
|
],
|
|
demo_body_style(15.5, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(24.0),
|
|
)
|
|
.id(status_scroll_text_id(1)),
|
|
Element::paragraph(
|
|
"Layout notes: the viewport reserves scrollbar gutter width up front, children are laid out in their own content space, and the renderer clips the scrolled content before painting the track and thumb.",
|
|
demo_body_style(15.5, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(24.0),
|
|
)
|
|
.id(status_scroll_text_id(2)),
|
|
Element::paragraph(
|
|
"Interaction notes: wheel input targets the nearest scrollbox ancestor under the pointer, while keyboard scrolling follows focus. That keeps text inputs and scroll regions from fighting each other.",
|
|
demo_body_style(15.5, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(24.0),
|
|
)
|
|
.id(status_scroll_text_id(3)),
|
|
Element::paragraph(
|
|
"Why this cut: reserved gutters make sizing stable, rounded clipping keeps the scrolled content inside the container shape, and the same decorated-container primitives can draw both the viewport shell and the scrollbar itself.",
|
|
demo_body_style(15.5, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(24.0),
|
|
)
|
|
.id(status_scroll_text_id(4)),
|
|
Element::paragraph(
|
|
"Next up after this should be richer scrollbar behavior and more generalized scroll state, but this baseline is enough to prove layout, clipping, focus, wheel input, and keyboard input all cooperate.",
|
|
demo_body_style(15.5, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(24.0),
|
|
)
|
|
.id(status_scroll_text_id(5)),
|
|
]),
|
|
])
|
|
}
|
|
|
|
fn rich_sidebar_card(hovered_card: Option<ElementId>, gutter: f32) -> Element {
|
|
Element::column()
|
|
.id(NOTE_CARD_ID)
|
|
.padding(Edges::all(gutter * 0.9))
|
|
.gap(gutter * 0.35)
|
|
.background(card_background(NOTE_CARD_ID, hovered_card))
|
|
.children([
|
|
Element::paragraph(
|
|
"Clamped note",
|
|
TextStyle::new(18.0, card_title_color(NOTE_CARD_ID, hovered_card))
|
|
.with_line_height(24.0),
|
|
)
|
|
.id(card_text_id(NOTE_CARD_ID, 1)),
|
|
Element::rich_paragraph(
|
|
[
|
|
TextSpan::new("Clamped note: the renderer now uses a "),
|
|
TextSpan::new("shared")
|
|
.weight(TextSpanWeight::Bold)
|
|
.color(Color::rgb(0xA7, 0xF3, 0xD0)),
|
|
TextSpan::new(" glyph atlas for prepared text, so the remaining "),
|
|
TextSpan::new("debug-build")
|
|
.weight(TextSpanWeight::Semibold)
|
|
.color(Color::rgb(0xF5, 0xD0, 0x74)),
|
|
TextSpan::new(" cost mostly comes from "),
|
|
TextSpan::new("paragraph")
|
|
.weight(TextSpanWeight::Semibold)
|
|
.color(Color::rgb(0x7D, 0xD3, 0xFC)),
|
|
TextSpan::new(
|
|
" layout and scene building rather than per-frame CPU text compositing.",
|
|
),
|
|
],
|
|
demo_body_style(16.0, Color::rgb(0xD4, 0xDB, 0xEA))
|
|
.with_line_height(25.0)
|
|
.with_max_lines(4),
|
|
)
|
|
.id(card_text_id(NOTE_CARD_ID, 2)),
|
|
])
|
|
}
|
|
|
|
fn card_text_id(card_id: ElementId, slot: u64) -> ElementId {
|
|
ElementId::new(card_id.raw() * 10 + slot)
|
|
}
|
|
|
|
fn is_hoverable_card(id: ElementId) -> bool {
|
|
matches!(
|
|
id,
|
|
WHY_CARD_ID | CALM_CARD_ID | NEXT_CARD_ID | QUOTE_CARD_ID | NOTE_CARD_ID | STATUS_CARD_ID
|
|
)
|
|
}
|
|
|
|
fn card_background(id: ElementId, hovered_card: Option<ElementId>) -> Color {
|
|
if hovered_card == Some(id) {
|
|
return Color::rgb(0x24, 0x31, 0x46);
|
|
}
|
|
|
|
match id {
|
|
WHY_CARD_ID | CALM_CARD_ID => Color::rgb(0x18, 0x20, 0x2F),
|
|
NEXT_CARD_ID => Color::rgb(0x1A, 0x22, 0x31),
|
|
QUOTE_CARD_ID | NOTE_CARD_ID | STATUS_CARD_ID => Color::rgb(0x1C, 0x24, 0x34),
|
|
_ => Color::rgb(0x18, 0x20, 0x2F),
|
|
}
|
|
}
|
|
|
|
fn card_title_color(id: ElementId, hovered_card: Option<ElementId>) -> Color {
|
|
if hovered_card == Some(id) {
|
|
return Color::rgb(0xFF, 0xE4, 0x9A);
|
|
}
|
|
Color::rgb(0xF4, 0xF7, 0xFF)
|
|
}
|
|
|
|
fn card_border_color(id: ElementId, hovered_card: Option<ElementId>) -> Color {
|
|
if hovered_card == Some(id) {
|
|
return Color::rgb(0xF5, 0xD0, 0x74);
|
|
}
|
|
|
|
match id {
|
|
NEXT_CARD_ID => Color::rgba(0x8B, 0x99, 0xAD, 0x78),
|
|
_ => Color::rgba(0x00, 0x00, 0x00, 0x00),
|
|
}
|
|
}
|
|
|
|
fn card_key_shadow(id: ElementId, hovered_card: Option<ElementId>) -> BoxShadow {
|
|
let hovered = hovered_card == Some(id);
|
|
BoxShadow::new(
|
|
ruin_ui::Point::new(0.0, if hovered { 16.0 } else { 12.0 }),
|
|
if hovered { 28.0 } else { 22.0 },
|
|
if hovered { -4.0 } else { -6.0 },
|
|
if hovered {
|
|
Color::rgba(0x04, 0x07, 0x0D, 0xC4)
|
|
} else {
|
|
Color::rgba(0x04, 0x07, 0x0D, 0x96)
|
|
},
|
|
BoxShadowKind::Outer,
|
|
)
|
|
}
|
|
|
|
fn card_ambient_shadow(id: ElementId, hovered_card: Option<ElementId>) -> BoxShadow {
|
|
let hovered = hovered_card == Some(id);
|
|
BoxShadow::new(
|
|
ruin_ui::Point::new(0.0, if hovered { 4.0 } else { 2.0 }),
|
|
if hovered { 10.0 } else { 8.0 },
|
|
0.0,
|
|
if hovered {
|
|
Color::rgba(0x0C, 0x16, 0x27, 0x88)
|
|
} else {
|
|
Color::rgba(0x0C, 0x16, 0x27, 0x62)
|
|
},
|
|
BoxShadowKind::Outer,
|
|
)
|
|
}
|