use super::executor::{DrainExecutorOnDrop, HeadlessMainThreadExecutor};
use super::*;
use crate::renderer::SemanticCore;
#[cfg(target_arch = "wasm32")]
use std::sync::Arc;
#[derive(Debug)]
pub struct SemanticPumpResult {
pub rebuilt: bool,
pub profile: FrameProfile,
#[cfg(feature = "accessibility")]
pub tree_update: Option<AccessibilityTreeUpdate>,
#[cfg(feature = "accessibility")]
pub ui_focus: Option<accesskit::NodeId>,
}
struct SemanticWindow {
window: Window,
core: SemanticCore,
pending_events: VecDeque<InputEvent>,
refresh_requested: bool,
}
impl SemanticWindow {
fn new(window: Window, fonts: &FontCollection) -> Self {
let mut core = SemanticCore::new(Instant::now());
seed_core(&mut core, fonts);
#[cfg(feature = "accessibility")]
core.use_semantic_keyboard_activation();
Self {
window,
core,
pending_events: VecDeque::new(),
refresh_requested: true,
}
}
fn push_event(&mut self, event: InputEvent) {
self.pending_events.push_back(event);
}
fn has_pending_events(&self) -> bool {
!self.pending_events.is_empty()
}
}
pub struct SemanticRuntime {
env: Environment,
window: SemanticWindow,
pending_window_queue: Rc<RefCell<Vec<Window>>>,
popup_windows: Vec<SemanticWindow>,
fonts: FontCollection,
local_executor: HeadlessMainThreadExecutor,
_executor_teardown: DrainExecutorOnDrop,
}
impl SemanticRuntime {
#[must_use]
pub fn new(
env: Environment,
content: AnyViewBuilder<AnyView>,
width: u32,
height: u32,
) -> Self {
#[cfg(not(target_arch = "wasm32"))]
return Self::on_env(env, content, width, height, native_resource_fonts);
#[cfg(target_arch = "wasm32")]
Self::on_env(env, content, width, height, parley::FontContext::new)
}
#[cfg(any(test, feature = "testing"))]
#[must_use]
pub fn new_for_tests(
env: Environment,
content: AnyViewBuilder<AnyView>,
width: u32,
height: u32,
) -> Self {
Self::on_env(
env,
content,
width,
height,
super::fonts::deterministic_test_fonts,
)
}
fn on_env(
env: Environment,
content: AnyViewBuilder<AnyView>,
width: u32,
height: u32,
build_fonts: fn() -> parley::FontContext,
) -> Self {
#[cfg(not(target_arch = "wasm32"))]
let inspector = init_main_thread_executors();
#[cfg(not(target_arch = "wasm32"))]
let inspector_probe = inspector
.as_ref()
.map(waterui::inspector::InspectorRuntime::runtime_probe);
#[cfg(target_arch = "wasm32")]
let inspector_probe: Option<Arc<dyn waterui::task::RuntimeProbe>> = {
init_global_executor();
None
};
let mut env = env.extending(waterui_graphics::SceneViewMergeToParent);
#[cfg(not(target_arch = "wasm32"))]
waterui::inspector::install(&mut env, inspector);
let pending_window_queue = Rc::new(RefCell::new(Vec::new()));
install_native_component_hooks(&mut env);
install_headless_window_managers(&mut env, Rc::clone(&pending_window_queue));
env.insert(HydrolysisTextContextMenuMode::Overlay);
crate::theme::install_default_tokens(&mut env);
let fonts = FontCollection::new(build_fonts());
fonts.clone().install(&mut env);
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.try_init();
let local_executor = HeadlessMainThreadExecutor::thread_shared();
let _ = try_init_local_executor(waterui::task::monitored_local_executor_with_probes(
local_executor.clone(),
inspector_probe,
));
let content_builder = content.clone();
let window = Window::new(
"",
waterui_core::binding(waterui::window::WindowState::Normal),
move || content_builder.build(),
);
window.frame.set(waterui_core::layout::Rect::new(
waterui_core::layout::Point::zero(),
waterui_core::layout::Size::new(width.max(1) as f32, height.max(1) as f32),
));
Self {
env,
window: SemanticWindow::new(window, &fonts),
pending_window_queue,
popup_windows: Vec::new(),
fonts,
_executor_teardown: DrainExecutorOnDrop(local_executor.clone()),
local_executor,
}
}
pub fn push_input_event(&mut self, event: InputEvent) {
self.window.push_event(event);
}
pub fn request_redraw(&mut self) {
self.window.refresh_requested = true;
}
#[cfg(feature = "accessibility")]
pub fn perform_accessibility_action(&mut self, request: AccessibilityActionRequest) -> bool {
const WINDOW_ID_STRIDE: u64 = 1 << 32;
let target = request.target_node.0;
let (window, request) = if target >= WINDOW_ID_STRIDE {
let index = target / WINDOW_ID_STRIDE - 1;
let popup = self
.popup_windows
.get_mut(index as usize)
.unwrap_or_else(|| {
panic!(
"hydrolysis semantic runtime: accessibility action {:?} targets closed popup \
node {target}",
request.action
)
});
let mut request = request;
request.target_node = accesskit::NodeId(target % WINDOW_ID_STRIDE);
(popup, request)
} else {
(&mut self.window, request)
};
let action_env = self.env.extending(semantic_window_origin(window));
let changed = window
.core
.handle_accessibility_action(request, &action_env);
if changed {
window.refresh_requested = true;
}
changed
}
#[must_use]
pub fn is_settled(&self) -> bool {
!self.window.has_pending_events()
&& !self.window.refresh_requested
&& !self.local_executor.has_pending()
&& self.pending_window_queue.borrow().is_empty()
&& !self.window.core.has_scheduled_semantic_work()
&& self.popup_windows.iter().all(|popup| {
!popup.has_pending_events()
&& !popup.refresh_requested
&& !popup.core.has_scheduled_semantic_work()
})
}
#[must_use]
pub fn has_pending_semantic_update(&self) -> bool {
self.window.refresh_requested
|| self.window.core.has_pending_semantic_update()
|| self
.popup_windows
.iter()
.any(|popup| popup.refresh_requested || popup.core.has_pending_semantic_update())
}
#[cfg(feature = "accessibility")]
pub fn clear_ui_focus(&mut self) -> bool {
let changed = self.window.core.clear_ui_focus();
if changed {
self.window.refresh_requested = true;
}
changed
}
#[cfg(feature = "accessibility")]
#[must_use]
pub fn focused_ui_node(&self) -> Option<accesskit::NodeId> {
self.window.core.focused_ui_node()
}
pub fn pump(&mut self) -> SemanticPumpResult {
self.pump_at(Instant::now())
}
pub fn pump_at(&mut self, at: Instant) -> SemanticPumpResult {
let frame_started_at = Instant::now();
let executor_before_started_at = Instant::now();
let drained_before = self.local_executor.drain();
let executor_before = executor_before_started_at.elapsed();
let input_started_at = Instant::now();
let _ = handle_semantic_input_events(&mut self.window, &self.env);
let input = input_started_at.elapsed();
let animation_started_at = Instant::now();
advance_semantic_window(&mut self.window, &self.env, at);
let animation = animation_started_at.elapsed();
self.mount_pending_popup_windows();
for popup in &mut self.popup_windows {
let _ = handle_semantic_input_events(popup, &self.env);
advance_semantic_window(popup, &self.env, at);
}
if self
.popup_windows
.iter()
.any(|popup| popup.window.state.get() == waterui::window::WindowState::Closed)
{
self.popup_windows
.retain(|popup| popup.window.state.get() != waterui::window::WindowState::Closed);
self.window.refresh_requested = true;
}
let rebuilt = pump_semantic_window(&mut self.window, &self.env);
for popup in &mut self.popup_windows {
let _ = pump_semantic_window(popup, &self.env);
}
let executor_after_started_at = Instant::now();
let drained_after = self.local_executor.drain();
let executor_after = executor_after_started_at.elapsed();
SemanticPumpResult {
rebuilt: rebuilt || drained_before || drained_after,
profile: FrameProfile {
phases: FramePhases {
executor_before,
input,
animation,
executor_after,
..FramePhases::default()
},
..FrameProfile::default()
}
.with_total(frame_started_at.elapsed()),
#[cfg(feature = "accessibility")]
tree_update: self.take_merged_accessibility_tree_update(),
#[cfg(feature = "accessibility")]
ui_focus: self.window.core.focused_ui_node(),
}
}
fn mount_pending_popup_windows(&mut self) {
let pending = self
.pending_window_queue
.borrow_mut()
.drain(..)
.collect::<Vec<_>>();
for window in pending {
let fonts = self.fonts.clone();
self.popup_windows.push(SemanticWindow::new(window, &fonts));
}
}
#[cfg(feature = "accessibility")]
fn take_merged_accessibility_tree_update(&mut self) -> Option<AccessibilityTreeUpdate> {
self.window.core.take_merged_accessibility_tree_update(
self.popup_windows.iter_mut().map(|popup| &mut popup.core),
)
}
}
fn semantic_window_origin(window: &SemanticWindow) -> HydrolysisWindowOrigin {
HydrolysisWindowOrigin {
x: window.window.frame.get().x(),
y: window.window.frame.get().y(),
}
}
fn handle_semantic_input_events(window: &mut SemanticWindow, env: &Environment) -> bool {
let mut should_close = window.window.state.get() == waterui::window::WindowState::Closed;
let events: Vec<InputEvent> = window.pending_events.drain(..).collect();
for event in events {
let changed = match event {
InputEvent::CloseRequested => {
window
.window
.state
.set(waterui::window::WindowState::Closed);
should_close = true;
true
}
InputEvent::Moved { x, y } => {
let frame = window.window.frame.get();
window.window.frame.set(waterui_core::layout::Rect::new(
waterui_core::layout::Point::new(x, y),
*frame.size(),
));
false
}
InputEvent::Resize { width, height } => {
let frame = window.window.frame.get();
window.window.frame.set(waterui_core::layout::Rect::new(
frame.origin(),
waterui_core::layout::Size::new(width as f32, height as f32),
));
false
}
InputEvent::TextInput { text } => window.core.handle_text_input(text.as_str()),
InputEvent::Key {
key,
state: KeyState::Pressed,
modifiers,
..
} => {
let key_env = env.extending(semantic_window_origin(window));
window.core.handle_key_with_env(&key, modifiers, &key_env)
}
InputEvent::Key {
key,
state: KeyState::Released,
..
} => {
let key_env = env.extending(semantic_window_origin(window));
window.core.handle_key_release_with_env(&key, &key_env)
}
InputEvent::ImePreedit { text, .. } => window.core.handle_ime_preedit(text.as_str()),
InputEvent::ImeCommit { text } => window.core.handle_ime_commit(text.as_str()),
InputEvent::ImeDisabled => window.core.handle_ime_disabled(),
geometric => {
tracing::trace!(
target: "waterui::hydrolysis::input",
event = ?geometric,
"semantic pump dropped geometry-routed input event"
);
false
}
};
if changed {
window.refresh_requested = true;
}
}
should_close
}
fn advance_semantic_window(window: &mut SemanticWindow, env: &Environment, now: Instant) {
window.core.set_frame_instant(now);
if window.core.handle_gesture_tick(now, env) {
window.refresh_requested = true;
}
if window.core.tick_smooth_scrolls(now) {
window.refresh_requested = true;
}
let _animations_active = window.core.advance_animations();
if window.core.take_patch_request() {
window.refresh_requested = true;
}
if window.core.take_rebuild_request() || window.core.take_next_frame_rebuild_request() {
window.refresh_requested = true;
}
}
fn pump_semantic_window(window: &mut SemanticWindow, env: &Environment) -> bool {
#[cfg(feature = "accessibility")]
window
.core
.set_accessibility_root_label(window.window.title.get().as_str());
if window.core.take_rebuild_request() {
window.refresh_requested = true;
}
let work_pending = window.refresh_requested
|| window.core.has_patch_request()
|| window.core.take_redraw_request();
if !work_pending {
return false;
}
if window.core.has_render_tree() {
assert!(
window.core.flush_window_semantics(env),
"hydrolysis semantic runtime: retained window tree vanished during pump"
);
window.refresh_requested = false;
return true;
}
let content = window.window.build_content();
window.core.capture_window_semantics(content, env);
window.refresh_requested = false;
true
}
#[cfg(all(test, feature = "accessibility"))]
mod tests {
use super::*;
use accesskit::{Action, ActionRequest, Node as AccessibilityNode, NodeId, Role, TreeId};
use nami::Binding;
use waterui_controls::button::button;
use waterui_controls::menu::{CommandExt, Menu, MenuItem};
use waterui_core::handler::AnyViewBuilder;
use waterui_layout::stack::vstack;
use waterui_text::text;
fn semantic_environment() -> Environment {
let mut env = Environment::new();
crate::testing::install_theme(&mut env);
crate::localization::install(&mut env);
env
}
fn find_by_label<'a>(
update: &'a AccessibilityTreeUpdate,
role: Role,
label: &str,
) -> Option<(NodeId, &'a AccessibilityNode)> {
update.nodes.iter().find_map(|(id, node)| {
(node.role() == role && node.label().is_some_and(|text| text == label))
.then_some((*id, node))
})
}
fn click(runtime: &mut SemanticRuntime, target: NodeId) -> bool {
runtime.perform_accessibility_action(ActionRequest {
action: Action::Click,
target_node: target,
target_tree: TreeId::ROOT,
data: None,
})
}
fn pump_until_settled(runtime: &mut SemanticRuntime) -> Option<AccessibilityTreeUpdate> {
let mut last = None;
for _ in 0..64 {
let result = runtime.pump();
if let Some(update) = result.tree_update {
last = Some(update);
}
if runtime.is_settled() {
break;
}
}
assert!(
!runtime.has_pending_semantic_update(),
"semantic runtime never settled"
);
last
}
#[test]
fn semantic_pump_emits_widgets_and_click_activates() {
let fired = Binding::container(false);
let fired_for_button = fired.clone();
let builder = AnyViewBuilder::<AnyView>::new(move || {
let fired = fired_for_button.clone();
AnyView::new(vstack((
text("hello semantic"),
button("Tap").action(move || fired.set(true)),
)))
});
let mut runtime = SemanticRuntime::new(semantic_environment(), builder, 800, 600);
let update =
pump_until_settled(&mut runtime).expect("the initial pump emitted no tree update");
let (tap, tap_node) = find_by_label(&update, Role::Button, "Tap")
.expect("Tap button missing from the semantic tree");
assert!(
tap_node.supports_action(Action::Click),
"the Tap button must advertise Click"
);
assert!(
update.nodes.iter().any(|(_, node)| {
node.role() == Role::Label && node.label().is_some_and(|v| v == "hello semantic")
}),
"the text view must emit a Label node"
);
assert!(click(&mut runtime, tap), "Tap click changed nothing");
assert!(fired.get(), "the button action did not fire");
}
#[test]
fn semantic_menu_popup_emits_items_and_dismisses() {
let fired = Binding::container(String::new());
let fired_for_menu = fired.clone();
let builder = AnyViewBuilder::<AnyView>::new(move || {
let save = fired_for_menu.clone();
AnyView::new(vstack((Menu::new(
"File",
vec![
MenuItem::Command("Save".action(move || save.set("save".to_string()))),
MenuItem::Divider,
MenuItem::Command("Quit".action(|| {})),
],
),)))
});
let mut runtime = SemanticRuntime::new(semantic_environment(), builder, 800, 600);
let update =
pump_until_settled(&mut runtime).expect("the initial pump emitted no tree update");
let (menu, menu_node) = find_by_label(&update, Role::Button, "File")
.expect("the File menu trigger is missing from the semantic tree");
assert!(menu_node.supports_action(Action::Click));
assert!(
find_by_label(&update, Role::Button, "Save").is_none(),
"menu items must not appear before the menu opens"
);
assert!(click(&mut runtime, menu), "menu activation changed nothing");
let update =
pump_until_settled(&mut runtime).expect("opening the menu emitted no tree update");
let (save, _) = find_by_label(&update, Role::Button, "Save")
.expect("Save menu item missing after the menu opened");
assert!(
find_by_label(&update, Role::Button, "Quit").is_some(),
"Quit menu item missing after the menu opened"
);
assert!(click(&mut runtime, save), "Save click changed nothing");
assert_eq!(
fired.get().as_str(),
"save",
"menu item action did not fire"
);
let update =
pump_until_settled(&mut runtime).expect("dismissing the menu emitted no tree update");
assert!(
find_by_label(&update, Role::Button, "Save").is_none(),
"a dismissed menu must leave the merged tree"
);
}
#[test]
fn semantic_text_field_focuses_and_edits() {
let value = Binding::container(waterui_core::Str::default());
let value_for_field = value.clone();
let builder = AnyViewBuilder::<AnyView>::new(move || {
AnyView::new(vstack((waterui_controls::text_field::field(
"Name",
&value_for_field,
),)))
});
let mut runtime = SemanticRuntime::new(semantic_environment(), builder, 800, 600);
let update =
pump_until_settled(&mut runtime).expect("the initial pump emitted no tree update");
let (field, _) = update
.nodes
.iter()
.find(|(_, node)| node.role() == Role::TextInput)
.map(|(id, node)| (*id, node))
.expect("the text field is missing from the semantic tree");
assert!(
runtime.perform_accessibility_action(ActionRequest {
action: Action::Focus,
target_node: field,
target_tree: TreeId::ROOT,
data: None,
}),
"focusing the text field changed nothing"
);
let _ = pump_until_settled(&mut runtime).expect("focusing emitted no tree update");
assert_eq!(
runtime.focused_ui_node(),
Some(field),
"the text field did not take UI focus"
);
for ch in "Jo".chars() {
runtime.push_input_event(InputEvent::TextInput {
text: ch.to_string(),
});
}
let _ = pump_until_settled(&mut runtime).expect("text input emitted no tree update");
assert_eq!(
value.get().to_string().as_str(),
"Jo",
"text input did not edit the field"
);
}
}