diff --git a/Cargo.toml b/Cargo.toml index 57636d47..362efd85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "euv" -version = "0.16.1" +version = "0.17.0" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] @@ -19,13 +19,13 @@ members = ["cli", "core", "engine", "example", "macros", "ui"] resolver = "3" [workspace.dependencies] -euv = { path = ".", version = "0.16.1" } -euv-ui = { path = "ui", version = "0.16.1" } -euv-cli = { path = "cli", version = "0.16.1" } -euv-core = { path = "core", version = "0.16.1" } -euv-engine = { path = "engine", version = "0.16.1" } -euv-macros = { path = "macros", version = "0.16.1" } -euv-example = { path = "example", version = "0.16.1" } +euv = { path = ".", version = "0.17.0" } +euv-ui = { path = "ui", version = "0.17.0" } +euv-cli = { path = "cli", version = "0.17.0" } +euv-core = { path = "core", version = "0.17.0" } +euv-engine = { path = "engine", version = "0.17.0" } +euv-macros = { path = "macros", version = "0.17.0" } +euv-example = { path = "example", version = "0.17.0" } log = "0.4.33" toml = "0.9.12" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index a2bc47ac..be04492e 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "euv-cli" -version = "0.16.1" +version = "0.17.0" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] diff --git a/core/Cargo.toml b/core/Cargo.toml index a3a5784d..9b953155 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "euv-core" -version = "0.16.1" +version = "0.17.0" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 4391cb7d..778109b4 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "euv-engine" -version = "0.16.1" +version = "0.17.0" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] diff --git a/example/Cargo.toml b/example/Cargo.toml index 4f057f97..ccb50cde 100644 --- a/example/Cargo.toml +++ b/example/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "euv-example" -version = "0.16.1" +version = "0.17.0" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] diff --git a/example/src/component/nav/view/const.rs b/example/src/component/nav/view/const.rs index 6caaa335..2d057d67 100644 --- a/example/src/component/nav/view/const.rs +++ b/example/src/component/nav/view/const.rs @@ -1,4 +1,5 @@ -/// Navigation item tuple: (icon, label, target). +/// Static navigation route targets โ€” every page in the +/// demo registers here so the sidebar can iterate. pub(crate) const NAV_ITEMS: &[(&str, &str, &str)] = &[ ("โ„น๏ธ", "About", "/"), ("๐ŸŽฌ", "Animation", "/animation"), @@ -14,6 +15,10 @@ pub(crate) const NAV_ITEMS: &[(&str, &str, &str)] = &[ ("๐Ÿท๏ธ", "DynTag", "/dynamic-component"), ("๐ŸŽฏ", "Event", "/event"), ("๐Ÿ“„", "Form", "/form"), + ("โฒ๏ธ", "Timing", "/hooks-timing"), + ("๐ŸŒ", "Async", "/hooks-async"), + ("๐Ÿ›ก๏ธ", "Protect", "/hooks-protect"), + ("๐ŸŒ", "i18n", "/hooks-i18n"), ("๐ŸŽฎ", "Game2D", "/game-2d"), ("๐ŸŽฒ", "Game3D", "/game-3d"), ("๐Ÿ’š", "KeepAlive", "/keep-alive"), diff --git a/example/src/component/router/view/fn.rs b/example/src/component/router/view/fn.rs index ba365792..019070b1 100644 --- a/example/src/component/router/view/fn.rs +++ b/example/src/component/router/view/fn.rs @@ -62,6 +62,18 @@ pub(crate) fn page_router(node: VirtualNode) -> VirtualNode { "/form" => { page_form {} } + "/hooks-timing" => { + page_hooks_timing {} + } + "/hooks-async" => { + page_hooks_async {} + } + "/hooks-protect" => { + page_hooks_protect {} + } + "/hooks-i18n" => { + page_hooks_i18n {} + } "/game-2d" => { page_game_2d {} } diff --git a/example/src/page/hooks_async/hook/fn.rs b/example/src/page/hooks_async/hook/fn.rs new file mode 100644 index 00000000..b4245523 --- /dev/null +++ b/example/src/page/hooks_async/hook/fn.rs @@ -0,0 +1,85 @@ +use super::*; + +/// The string value the async page resolves into on `Resolve`. +pub(crate) const HOOKS_ASYNC_RESOLVED_VALUE: &str = "hello from use_async"; + +/// The error message the async page fails with on `Fail`. +pub(crate) const HOOKS_ASYNC_FAIL_MESSAGE: &str = "demo failure"; + +/// Returns a click handler that triggers an `Ok("...")` future +/// and writes it into the supplied `UseAsyncHandle`. +pub(crate) fn hooks_async_refetch(handle: UseAsyncHandle) -> Option> { + Some(Rc::new(move |_: Event| { + handle.set_state(AsyncState::::Ok(String::from( + HOOKS_ASYNC_RESOLVED_VALUE, + ))); + })) +} + +/// Reads the current `AsyncState` and shapes it into a readable +/// string for the demo card. +pub(crate) fn hooks_async_state_label(handle: UseAsyncHandle) -> String { + match handle.state() { + AsyncState::::Loading(_) => String::from("Loading"), + AsyncState::::Ok(value) => format!("Ok({value:?})"), + AsyncState::::Err(err) => format!("Err({err:?})"), + } +} + +/// Returns `true` when the underlying `LazyComponent`'s factory +/// has not yet produced a value. +/// +/// Coerces to a `bool` so the call site can use the result +/// directly inside a span / VirtualNode `{}` slot. +pub(crate) fn hooks_async_lazy_is_pending(lazy: &LazyComponent) -> bool { + lazy.get().is_none() +} + +/// Returns the loaded `LazyComponent` value as a `String` for the +/// demo card. Falls back to `"pending"` when the factory has not +/// fired yet. +pub(crate) fn hooks_async_lazy_loaded_label(lazy: &LazyComponent) -> String { + lazy.loaded() + .map(|value: u32| value.to_string()) + .unwrap_or_else(|| String::from("pending")) +} + +/// Reads `SuspenseHandle`'s phase and shapes it into a readable +/// string for the demo card. +pub(crate) fn hooks_async_suspense_phase_label(handle: &SuspenseHandle) -> String { + match handle.get_phase().get() { + SuspensePhase::Pending => String::from("Pending"), + SuspensePhase::Resolved(value) => format!("Resolved({value})"), + SuspensePhase::Failed(message) => format!("Failed({message})"), + } +} + +/// Builds the click handler that flips the suspense handle to +/// `Resolved`. +pub(crate) fn hooks_async_resolve( + handle: SuspenseHandle, + value: String, +) -> Option> { + Some(Rc::new(move |_: Event| { + handle.resolve_sync(value.clone()); + })) +} + +/// Builds the click handler that flips the suspense handle to +/// `Failed`. +pub(crate) fn hooks_async_fail( + handle: SuspenseHandle, + message: String, +) -> Option> { + Some(Rc::new(move |_: Event| { + handle.fail(message.clone()); + })) +} + +/// Builds the click handler that resets the suspense handle +/// back to `Pending`. +pub(crate) fn hooks_async_reset(handle: SuspenseHandle) -> Option> { + Some(Rc::new(move |_: Event| { + handle.reset(); + })) +} diff --git a/example/src/page/hooks_async/hook/mod.rs b/example/src/page/hooks_async/hook/mod.rs new file mode 100644 index 00000000..87593256 --- /dev/null +++ b/example/src/page/hooks_async/hook/mod.rs @@ -0,0 +1,4 @@ +mod r#fn; +pub(crate) use r#fn::*; + +use super::*; diff --git a/example/src/page/hooks_async/mod.rs b/example/src/page/hooks_async/mod.rs new file mode 100644 index 00000000..37979b2a --- /dev/null +++ b/example/src/page/hooks_async/mod.rs @@ -0,0 +1,6 @@ +mod hook; +mod view; + +pub(crate) use {hook::*, view::*}; + +use super::*; diff --git a/example/src/page/hooks_async/view/fn.rs b/example/src/page/hooks_async/view/fn.rs new file mode 100644 index 00000000..c10a5e6c --- /dev/null +++ b/example/src/page/hooks_async/view/fn.rs @@ -0,0 +1,104 @@ +use super::*; + +/// A page demonstrating the async-state primitives +/// ([`UseAsyncHandle`], [`LazyComponent`], [`SuspenseHandle`]). +/// +/// The three rows share a single browser timer so the page can +/// drive transitions without spinning up an HTTP server. +#[component] +pub(crate) fn page_hooks_async(node: VirtualNode) -> VirtualNode { + let PageHooksAsyncProps: PageHooksAsyncProps = node.try_get_props().unwrap_or_default(); + let async_handle: UseAsyncHandle = use_async::(); + let lazy_value: LazyComponent = use_lazy_component::(|| 7); + let suspense: SuspenseHandle = use_suspense::(); + html! { + div { + class: c_page_container() + euv_header { + icon: "๐ŸŒ" + title: "Hooks โ€” Async" + subtitle: "AsyncState (use_async), lazy factory (use_lazy_component), and suspense phases (use_suspense)." + } + euv_card { + title: "use_async" + p { + class: c_render_count_text() + "The handle's state exposes an AsyncState machine. The Loading arm carries () by default; the Ok arm carries the resolved value; the Err arm the failure message." + } + div { + class: c_button_controls() + euv_button { + variant: EuvButtonVariant::Primary + label: "Refetch" + onclick: hooks_async_refetch(async_handle) + } + } + p { + class: c_render_count_text() + "state: " + span { + class: c_counter_value() + hooks_async_state_label(async_handle) + } + } + } + euv_card { + title: "use_lazy_component" + p { + class: c_render_count_text() + "The factory is only invoked on first access. After a reset() the next read triggers it again." + } + div { + class: c_counter_row() + div { + "loaded:" + span { + class: c_counter_value() + hooks_async_lazy_loaded_label(&lazy_value) + } + } + div { + "is_pending:" + span { + class: c_counter_value() + hooks_async_lazy_is_pending(&lazy_value) + } + } + } + } + euv_card { + title: "use_suspense" + p { + class: c_render_count_text() + "resolve_sync and fail flip the phase signal โ€” the rendering code branches on the resulting Pending / Resolved / Failed variant." + } + div { + class: c_button_controls() + euv_button { + variant: EuvButtonVariant::Primary + label: "Resolve" + onclick: hooks_async_resolve(suspense, String::from(HOOKS_ASYNC_RESOLVED_VALUE)) + } + euv_button { + variant: EuvButtonVariant::Outline + label: "Fail" + onclick: hooks_async_fail(suspense, String::from(HOOKS_ASYNC_FAIL_MESSAGE)) + } + euv_button { + variant: EuvButtonVariant::Outline + label: "Reset" + onclick: hooks_async_reset(suspense) + } + } + p { + class: c_render_count_text() + "phase: " + span { + class: c_counter_value() + hooks_async_suspense_phase_label(&suspense) + } + } + } + } + } +} \ No newline at end of file diff --git a/example/src/page/hooks_async/view/mod.rs b/example/src/page/hooks_async/view/mod.rs new file mode 100644 index 00000000..0c6d0215 --- /dev/null +++ b/example/src/page/hooks_async/view/mod.rs @@ -0,0 +1,6 @@ +mod r#fn; +mod r#struct; + +pub(crate) use {r#fn::*, r#struct::*}; + +use super::*; diff --git a/example/src/page/hooks_async/view/struct.rs b/example/src/page/hooks_async/view/struct.rs new file mode 100644 index 00000000..48b120c5 --- /dev/null +++ b/example/src/page/hooks_async/view/struct.rs @@ -0,0 +1,5 @@ +use super::*; + +/// Props for the `page_hooks_async` component. +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) struct PageHooksAsyncProps; diff --git a/example/src/page/hooks_i18n/hook/fn.rs b/example/src/page/hooks_i18n/hook/fn.rs new file mode 100644 index 00000000..7969367c --- /dev/null +++ b/example/src/page/hooks_i18n/hook/fn.rs @@ -0,0 +1,51 @@ +use super::*; + +/// Default locale tag for the i18n demo. +pub(crate) const HOOKS_I18N_DEFAULT_LOCALE: &str = "en"; + +/// Secondary locale tag the demo can switch into. +pub(crate) const HOOKS_I18N_OTHER_LOCALE: &str = "zh-CN"; + +/// English-language button label. +pub(crate) const HOOKS_I18N_LABEL_EN: &str = "English"; + +/// "Other" (Chinese) language button label. +pub(crate) const HOOKS_I18N_LABEL_OTHER: &str = "ไธญๆ–‡"; + +/// Translation key for the greeting message. +pub(crate) const HOOKS_I18N_KEY_GREETING: &str = "greeting"; + +/// Translation key for the farewell message. +pub(crate) const HOOKS_I18N_KEY_FAREWELL: &str = "farewell"; + +/// English (`en`) translation table โ€” `&'static` tuples are the +/// only form `i18n_register` can accept without leaking. The +/// underlying `I18n::add_messages` does the +/// `&str -> String` round-trip on the caller's behalf. +pub(crate) const HOOKS_I18N_EN_MESSAGES: [(&str, &str); 2] = [ + ("greeting", "Hello, world!"), + ("farewell", "Goodbye, world!"), +]; + +/// `zh-CN` translation table. +pub(crate) const HOOKS_I18N_ZH_MESSAGES: [(&str, &str); 2] = [ + ("greeting", "ไฝ ๅฅฝ,ไธ–็•Œ!"), + ("farewell", "ๅ†่ง,ไธ–็•Œ!"), +]; + +/// Builds a click handler that switches the supplied i18n +/// handle to the supplied locale. +pub(crate) fn hooks_i18n_switch( + handle: I18n, + locale: String, +) -> Option> { + Some(Rc::new(move |_: Event| { + handle.change_locale(locale.as_str()); + })) +} + +/// Returns the translated message for `key` on the supplied +/// handle, falling back to the key itself if missing. +pub(crate) fn hooks_i18n_translate(handle: I18n, key: &'static str) -> String { + handle.t(key) +} diff --git a/example/src/page/hooks_i18n/hook/mod.rs b/example/src/page/hooks_i18n/hook/mod.rs new file mode 100644 index 00000000..87593256 --- /dev/null +++ b/example/src/page/hooks_i18n/hook/mod.rs @@ -0,0 +1,4 @@ +mod r#fn; +pub(crate) use r#fn::*; + +use super::*; diff --git a/example/src/page/hooks_i18n/mod.rs b/example/src/page/hooks_i18n/mod.rs new file mode 100644 index 00000000..37979b2a --- /dev/null +++ b/example/src/page/hooks_i18n/mod.rs @@ -0,0 +1,6 @@ +mod hook; +mod view; + +pub(crate) use {hook::*, view::*}; + +use super::*; diff --git a/example/src/page/hooks_i18n/view/fn.rs b/example/src/page/hooks_i18n/view/fn.rs new file mode 100644 index 00000000..2de39182 --- /dev/null +++ b/example/src/page/hooks_i18n/view/fn.rs @@ -0,0 +1,70 @@ +use super::*; + +/// A page demonstrating the i18n hook (handle + locale switching + +/// translation table). +#[component] +pub(crate) fn page_hooks_i18n(node: VirtualNode) -> VirtualNode { + let PageHooksI18nProps: PageHooksI18nProps = node.try_get_props().unwrap_or_default(); + let i18n: I18n = use_i18n(HOOKS_I18N_DEFAULT_LOCALE); + let locale: Signal = *i18n.get_locale(); + let locale_clone: String = String::from(HOOKS_I18N_DEFAULT_LOCALE); + let en_entries_static: [(&'static str, &'static str); 2] = HOOKS_I18N_EN_MESSAGES; + i18n_register(i18n, &locale_clone, &en_entries_static); + let other_clone: String = String::from(HOOKS_I18N_OTHER_LOCALE); + let zh_entries_static: [(&'static str, &'static str); 2] = HOOKS_I18N_ZH_MESSAGES; + i18n_register(i18n, &other_clone, &zh_entries_static); + let en_target: String = String::from(HOOKS_I18N_DEFAULT_LOCALE); + let other_target: String = String::from(HOOKS_I18N_OTHER_LOCALE); + let en_label: &'static str = HOOKS_I18N_LABEL_EN; + let other_label: &'static str = HOOKS_I18N_LABEL_OTHER; + html! { + div { + class: c_page_container() + euv_header { + icon: "๐ŸŒ" + title: "Hooks โ€” i18n" + subtitle: "Switch locales to see the same translation keys resolve to different messages. The handle's `locale` signal drives the reactive read." + } + euv_card { + title: "Translation" + div { + class: c_button_controls() + euv_button { + variant: EuvButtonVariant::Primary + label: en_label + onclick: hooks_i18n_switch(i18n, en_target.clone()) + } + euv_button { + variant: EuvButtonVariant::Outline + label: other_label + onclick: hooks_i18n_switch(i18n, other_target.clone()) + } + } + p { + class: c_render_count_text() + "locale: " + span { + class: c_counter_value() + locale + } + } + p { + class: c_render_count_text() + "greeting: " + span { + class: c_counter_value() + hooks_i18n_translate(i18n, HOOKS_I18N_KEY_GREETING) + } + } + p { + class: c_render_count_text() + "farewell: " + span { + class: c_counter_value() + hooks_i18n_translate(i18n, HOOKS_I18N_KEY_FAREWELL) + } + } + } + } + } +} diff --git a/example/src/page/hooks_i18n/view/mod.rs b/example/src/page/hooks_i18n/view/mod.rs new file mode 100644 index 00000000..0c6d0215 --- /dev/null +++ b/example/src/page/hooks_i18n/view/mod.rs @@ -0,0 +1,6 @@ +mod r#fn; +mod r#struct; + +pub(crate) use {r#fn::*, r#struct::*}; + +use super::*; diff --git a/example/src/page/hooks_i18n/view/struct.rs b/example/src/page/hooks_i18n/view/struct.rs new file mode 100644 index 00000000..b84adbd3 --- /dev/null +++ b/example/src/page/hooks_i18n/view/struct.rs @@ -0,0 +1,5 @@ +use super::*; + +/// Props for the `page_hooks_i18n` component. +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) struct PageHooksI18nProps; diff --git a/example/src/page/hooks_protect/hook/fn.rs b/example/src/page/hooks_protect/hook/fn.rs new file mode 100644 index 00000000..2450b655 --- /dev/null +++ b/example/src/page/hooks_protect/hook/fn.rs @@ -0,0 +1,91 @@ +use super::*; + +/// Label used by the trigger-measurement call on every render. +pub(crate) const HOOKS_PROTECT_PROFILER_LABEL_TRIGGER: &str = "render-trigger"; + +/// A small value-payload returned by the trigger measurement โ€” +/// kept short so the profiler entry row stays readable. +pub(crate) const HOOKS_PROTECT_TRIGGER_RENDER_VALUE: &str = "ok"; + +/// Synthetic error message used by the panic-demo button +/// without actually calling `std::panic!` (see +/// rust-standards R11.3 โ€” demo code must not panic). +pub(crate) const HOOKS_PROTECT_DEMO_ERROR_MESSAGE: &str = "simulated failure"; + +/// Build a click handler that runs a healthy closure under the +/// supplied boundary. The boundary's phase transitions to +/// `Healthy` after the closure returns. +pub(crate) fn hooks_protect_try_healthy(boundary: ErrorBoundary) -> Option> { + Some(Rc::new(move |_: Event| { + let result: Result = boundary.try_with(|| 7_u32); + let _ = result; + boundary.reset(); + })) +} + +/// Build a click handler that triggers a synthetic +/// failure under the boundary, leaving it in `Caught` +/// with the supplied message. +/// +/// The hook's `try_with` API exists for genuine +/// panics; this demo deliberately avoids `panic!` so +/// the page stays inside rust-standards R11.3 (no +/// production panic). The boundary transitions +/// through [`ErrorBoundary::report_error`]. +pub(crate) fn hooks_protect_try_panic(boundary: ErrorBoundary) -> Option> { + Some(Rc::new(move |_: Event| { + let _ = boundary.try_with(|| 7_u32); + boundary.report_error(HOOKS_PROTECT_DEMO_ERROR_MESSAGE); + })) +} + +/// Build a click handler that resets the boundary back to +/// `Healthy`. +pub(crate) fn hooks_protect_reset(boundary: ErrorBoundary) -> Option> { + Some(Rc::new(move |_: Event| { + boundary.reset(); + })) +} + +/// Build a click handler that records a deliberately-slow +/// measurement via the supplied profiler. +pub(crate) fn hooks_protect_profile_slow( + profiler: ProfilerHandle, +) -> Option> { + Some(Rc::new(move |_: Event| { + profiler.measure("slow-op", || { + // Tight loop ~ 1 ms; sufficient to show non-zero + // elapsed time in the entries list. + let mut accumulator: u64 = 0_u64; + for index in 0_u64..1_000_000_u64 { + accumulator = accumulator.wrapping_add(index); + } + let _ = accumulator; + }); + })) +} + +/// Build a click handler that clears every recorded +/// measurement. +pub(crate) fn hooks_protect_profile_clear( + profiler: ProfilerHandle, +) -> Option> { + Some(Rc::new(move |_: Event| { + profiler.clear(); + })) +} + +/// Returns the number of recorded entries, formatted as a +/// string for the demo readout. +pub(crate) fn hooks_protect_entry_count(profiler: ProfilerHandle) -> usize { + profiler.get_entries().get().len() +} + +/// Reads the boundary's current phase and shapes it into a +/// readable string for the demo card. +pub(crate) fn hooks_protect_phase_label(boundary: &ErrorBoundary) -> String { + match boundary.get_phase().get() { + ErrorBoundaryPhase::Healthy => String::from("Healthy"), + ErrorBoundaryPhase::Caught(message) => format!("Caught({message})"), + } +} diff --git a/example/src/page/hooks_protect/hook/mod.rs b/example/src/page/hooks_protect/hook/mod.rs new file mode 100644 index 00000000..87593256 --- /dev/null +++ b/example/src/page/hooks_protect/hook/mod.rs @@ -0,0 +1,4 @@ +mod r#fn; +pub(crate) use r#fn::*; + +use super::*; diff --git a/example/src/page/hooks_protect/mod.rs b/example/src/page/hooks_protect/mod.rs new file mode 100644 index 00000000..37979b2a --- /dev/null +++ b/example/src/page/hooks_protect/mod.rs @@ -0,0 +1,6 @@ +mod hook; +mod view; + +pub(crate) use {hook::*, view::*}; + +use super::*; diff --git a/example/src/page/hooks_protect/view/fn.rs b/example/src/page/hooks_protect/view/fn.rs new file mode 100644 index 00000000..cb0fb20d --- /dev/null +++ b/example/src/page/hooks_protect/view/fn.rs @@ -0,0 +1,96 @@ +use super::*; + +/// A page demonstrating the "protective" hooks +/// ([`ErrorBoundary`] and [`ProfilerHandle`]). +#[component] +pub(crate) fn page_hooks_protect( + node: VirtualNode, +) -> VirtualNode { + let PageHooksProtectProps: PageHooksProtectProps = + node.try_get_props().unwrap_or_default(); + let boundary: ErrorBoundary = use_error_boundary(); + let profiler: ProfilerHandle = use_profiler(); + let trigger_label: String = profiler_measure( + HOOKS_PROTECT_PROFILER_LABEL_TRIGGER, + || String::from(HOOKS_PROTECT_TRIGGER_RENDER_VALUE), + ); + html! { + div { + class: c_page_container() + euv_header { + icon: "๐Ÿ›ก๏ธ" + title: "Hooks โ€” Protect" + subtitle: "ErrorBoundary catches panics inside try_with; ProfilerHandle keeps a list of measurements without a global collector." + } + euv_card { + title: "ErrorBoundary" + p { + class: c_render_count_text() + "try_with invokes the supplied closure inside a catch_unwind shim. If the closure panics, the boundary transitions to Caught and the caller gets an Err carrying the message." + } + div { + class: c_button_controls() + euv_button { + variant: EuvButtonVariant::Primary + label: "Try a healthy run" + onclick: hooks_protect_try_healthy(boundary) + } + euv_button { + variant: EuvButtonVariant::Outline + label: "Try a panic" + onclick: hooks_protect_try_panic(boundary) + } + euv_button { + variant: EuvButtonVariant::Outline + label: "Reset" + onclick: hooks_protect_reset(boundary) + } + } + p { + class: c_render_count_text() + "phase: " + span { + class: c_counter_value() + hooks_protect_phase_label(&boundary) + } + } + } + euv_card { + title: "Profiler" + p { + class: c_render_count_text() + "Each render records a measurement via profiler_measure. Click to push more rows into the entries list." + } + div { + class: c_button_controls() + euv_button { + variant: EuvButtonVariant::Primary + label: "Measure a slow op" + onclick: hooks_protect_profile_slow(profiler) + } + euv_button { + variant: EuvButtonVariant::Outline + label: "Clear" + onclick: hooks_protect_profile_clear(profiler) + } + } + p { + class: c_render_count_text() + "entries: " + span { + class: c_counter_value() + hooks_protect_entry_count(profiler) + } + } + p { + class: c_render_count_text() + "current render's trigger label: " + span { + class: c_counter_value() + trigger_label + } + } + } + } + } +} \ No newline at end of file diff --git a/example/src/page/hooks_protect/view/mod.rs b/example/src/page/hooks_protect/view/mod.rs new file mode 100644 index 00000000..0c6d0215 --- /dev/null +++ b/example/src/page/hooks_protect/view/mod.rs @@ -0,0 +1,6 @@ +mod r#fn; +mod r#struct; + +pub(crate) use {r#fn::*, r#struct::*}; + +use super::*; diff --git a/example/src/page/hooks_protect/view/struct.rs b/example/src/page/hooks_protect/view/struct.rs new file mode 100644 index 00000000..01cdb045 --- /dev/null +++ b/example/src/page/hooks_protect/view/struct.rs @@ -0,0 +1,5 @@ +use super::*; + +/// Props for the `page_hooks_protect` component. +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) struct PageHooksProtectProps; diff --git a/example/src/page/hooks_timing/hook/fn.rs b/example/src/page/hooks_timing/hook/fn.rs new file mode 100644 index 00000000..1cbee885 --- /dev/null +++ b/example/src/page/hooks_timing/hook/fn.rs @@ -0,0 +1,105 @@ +use super::*; +use std::time::Instant; + +/// Quiet period for the debounce row (ms). +pub(crate) const TIMING_DEBOUNCE_MS: u32 = 300; + +/// Throttle window for the throttle row (ms). +pub(crate) const TIMING_THROTTLE_MS: u32 = 250; + +/// Interval at which the ticks the `App::use_interval` driver +/// pushes the throttle / debounce state machine forward. +pub(crate) const TIMING_TICK_MS: i32 = 50; + +/// Placeholder text shared by the live-input boxes. +pub(crate) const TIMING_INPUT_PLACEHOLDER: &str = "Type hereโ€ฆ"; + +/// DOM id for the debounce row's input. +pub(crate) const TIMING_DEBOUNCE_INPUT_ID: &str = "timing-debounce-input"; + +/// DOM id for the throttle row's input. +pub(crate) const TIMING_THROTTLE_INPUT_ID: &str = "timing-throttle-input"; + +/// Creates an input handler that updates `live`, schedules a +/// debounce commit on `debounced`, and records `current` in +/// `previous` for the snapshot row to consume. +pub(crate) fn timing_debounce_on_input( + live: Signal, + debounced: DebouncedValue, + current: Signal, + previous: Previous, +) -> Option> { + Some(Rc::new(move |event: Event| { + if let Some(value) = timing_read_input(&event) { + live.set(value.clone()); + debounced.set(value.clone(), Instant::now()); + let snapshot: Option = previous_step(previous, value.clone()); + let next_current: String = match snapshot { + Some(prev) => format!("{prev} โ†’ {value}"), + None => value, + }; + current.set(next_current); + } + })) +} + +/// Creates an input handler that drives the throttle row. +pub(crate) fn timing_throttle_on_input( + live: Signal, + throttled: ThrottledValue, + current: Signal, + previous: Previous, +) -> Option> { + Some(Rc::new(move |event: Event| { + let captured_live: Signal = live; + let captured_throttled: ThrottledValue = throttled; + let captured_current: Signal = current; + let captured_previous: Previous = previous; + if let Some(value) = timing_read_input(&event) { + captured_live.set(value.clone()); + captured_throttled.set(value.clone(), Instant::now()); + let snapshot: Option = previous_step(captured_previous, value.clone()); + let next_current: String = match snapshot { + Some(prev) => format!("{prev} โ†’ {value}"), + None => value, + }; + captured_current.set(next_current); + } + })) +} + +/// Reads the current value from an `Event` whose target is an +/// `` element. Returns `None` if the cast failed or the +/// event has no target. +fn timing_read_input(event: &Event) -> Option { + let target: JsValue = event.target()?.into(); + let input: HtmlInputElement = target.dyn_into::().ok()?; + Some(input.value()) +} + +/// Returns the snapshot string the previous-value row displays. +/// +/// `None` (no prior value) renders as `"โ€”"`, `Some(value)` renders +/// as the value verbatim. Calling this also records the current +/// rendering's "current" value so subsequent renders see the value +/// that was on screen just before. +/// +/// # Arguments +/// +/// - `Previous` - The previous-value tracker. +/// +/// # Returns +/// +/// - `String` - The display string for the current previous state. +pub(crate) fn timing_previous_snapshot(previous: Previous) -> String { + match previous.get_previous_snapshot() { + Some(value) => value, + None => String::from("โ€”"), + } +} + +/// Reads a `Signal` and returns the underlying +/// `String` value. Coerces to a text node for html! slots. +pub(crate) fn timing_signal_to_string(signal: &Signal) -> String { + signal.get() +} diff --git a/example/src/page/hooks_timing/hook/mod.rs b/example/src/page/hooks_timing/hook/mod.rs new file mode 100644 index 00000000..87593256 --- /dev/null +++ b/example/src/page/hooks_timing/hook/mod.rs @@ -0,0 +1,4 @@ +mod r#fn; +pub(crate) use r#fn::*; + +use super::*; diff --git a/example/src/page/hooks_timing/mod.rs b/example/src/page/hooks_timing/mod.rs new file mode 100644 index 00000000..37979b2a --- /dev/null +++ b/example/src/page/hooks_timing/mod.rs @@ -0,0 +1,6 @@ +mod hook; +mod view; + +pub(crate) use {hook::*, view::*}; + +use super::*; diff --git a/example/src/page/hooks_timing/view/fn.rs b/example/src/page/hooks_timing/view/fn.rs new file mode 100644 index 00000000..2506cb73 --- /dev/null +++ b/example/src/page/hooks_timing/view/fn.rs @@ -0,0 +1,97 @@ +use super::*; +use std::time::Instant; + +#[component] +pub(crate) fn page_hooks_timing(node: VirtualNode) -> VirtualNode { + let PageHooksTimingProps: PageHooksTimingProps = node.try_get_props().unwrap_or_default(); + let debounced: DebouncedValue = use_debounced_value::(TIMING_DEBOUNCE_MS); + let throttled: ThrottledValue = + use_throttled_value::(TIMING_THROTTLE_MS); + let previous: Previous = use_previous::(); + let current: Signal = App::use_signal(String::new); + let live: Signal = App::use_signal(String::new); + App::use_interval(TIMING_TICK_MS, { + let debounced: DebouncedValue = debounced; + let throttled: ThrottledValue = throttled; + move || { + let now: Instant = Instant::now(); + debounced.tick(now); + throttled.tick(now); + } + }); + let debounced_value: Signal = debounced.get_value(); + let throttled_value: Signal = throttled.get_value(); + html! { + div { + class: c_page_container() + euv_header { + icon: "โฒ๏ธ" + title: "Hooks โ€” Timing" + subtitle: "DebouncedValue, ThrottledValue, and Previous side-by-side. Each row drives a Signal from a different rate-control policy." + } + euv_card { + title: "Debounce (quiet period)" + p { + "Type into the box to seed a pending value; after 300 ms of idle time the debounced signal commits the latest pending value." + } + div { + class: c_inline_input_row() + euv_input { + id: TIMING_DEBOUNCE_INPUT_ID + label: "Live input" + placeholder: TIMING_INPUT_PLACEHOLDER + value: live + oninput: timing_debounce_on_input(live, debounced, current, previous) + } + span { + class: c_counter_value() + timing_signal_to_string(&debounced_value) + } + } + } + euv_card { + title: "Throttle (max-once-per-window)" + p { + "Type into the box to push pending values into the throttler. The committed value updates at most once every 250 ms." + } + div { + class: c_inline_input_row() + euv_input { + id: TIMING_THROTTLE_INPUT_ID + label: "Live input" + placeholder: TIMING_INPUT_PLACEHOLDER + value: live + oninput: timing_throttle_on_input(live, throttled, current, previous) + } + span { + class: c_counter_value() + timing_signal_to_string(&throttled_value) + } + } + } + euv_card { + title: "Previous (snapshot of last render)" + p { + "Each render is preceded by `previous_step`, which records the current value and reports the snapshot from the previous render." + } + div { + class: c_counter_row() + div { + "current:" + span { + class: c_counter_value() + current + } + } + div { + "previous:" + span { + class: c_counter_value() + timing_previous_snapshot(previous) + } + } + } + } + } + } +} diff --git a/example/src/page/hooks_timing/view/mod.rs b/example/src/page/hooks_timing/view/mod.rs new file mode 100644 index 00000000..0c6d0215 --- /dev/null +++ b/example/src/page/hooks_timing/view/mod.rs @@ -0,0 +1,6 @@ +mod r#fn; +mod r#struct; + +pub(crate) use {r#fn::*, r#struct::*}; + +use super::*; diff --git a/example/src/page/hooks_timing/view/struct.rs b/example/src/page/hooks_timing/view/struct.rs new file mode 100644 index 00000000..e1fe5e66 --- /dev/null +++ b/example/src/page/hooks_timing/view/struct.rs @@ -0,0 +1,4 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) struct PageHooksTimingProps; diff --git a/example/src/page/mod.rs b/example/src/page/mod.rs index 4ee9a903..0950cc3e 100644 --- a/example/src/page/mod.rs +++ b/example/src/page/mod.rs @@ -1,3 +1,7 @@ +mod hooks_async; +mod hooks_i18n; +mod hooks_protect; +mod hooks_timing; mod about; mod animation; mod r#async; @@ -31,8 +35,9 @@ mod websocket; pub(crate) use { about::*, animation::*, r#async::*, attrs::*, badge::*, binding::*, browser::*, camera::*, canvas::*, conditional::*, counter::*, dynamic::*, event::*, file::*, form::*, game_2d::*, - game_3d::*, keep_alive::*, lifecycle::*, list::*, modal::*, not_found::*, observer::*, - select::*, sse::*, timer::*, virtual_list::*, webgpu_status::*, websocket::*, + game_3d::*, hooks_async::*, hooks_i18n::*, hooks_protect::*, hooks_timing::*, keep_alive::*, + lifecycle::*, list::*, modal::*, not_found::*, observer::*, select::*, sse::*, timer::*, + virtual_list::*, webgpu_status::*, websocket::*, }; use super::*; diff --git a/macros/Cargo.toml b/macros/Cargo.toml index c3ff3259..c189db52 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "euv-macros" -version = "0.16.1" +version = "0.17.0" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 61e59e03..934f28fa 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "euv-ui" -version = "0.16.1" +version = "0.17.0" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] diff --git a/ui/src/hook/debounced_value/fn.rs b/ui/src/hook/debounced_value/fn.rs new file mode 100644 index 00000000..edb8e38d --- /dev/null +++ b/ui/src/hook/debounced_value/fn.rs @@ -0,0 +1,29 @@ +use super::*; + +/// Obtains the debounced value registered against the current hook context slot. +/// +/// Behaves like `HookContext::use_hook`: the same `DebouncedValue` is +/// returned on every render at the same hook index, so the in-flight +/// throttle / pending slot survives across renders without losing state. +/// +/// The factory uses the supplied `delay_ms` to seed the slot. To change +/// the delay at runtime, call [`DebouncedValue::set`] with a `delay` +/// value of your choice (the field is `pub(crate)`, but `set` plus +/// `tick` is the supported public surface). +/// +/// # Arguments +/// +/// - `u32` - The quiet period in milliseconds. After this many +/// milliseconds without a fresh `set`, any pending value is committed. +/// +/// # Returns +/// +/// - `DebouncedValue` - The debounced value handle. +/// Returns the factory result directly when no hook context is +/// active (e.g. when called outside a render cycle). +pub fn use_debounced_value(delay_ms: u32) -> DebouncedValue +where + T: Clone + PartialEq + Debug + Default + 'static, +{ + HookContext::use_hook(|| DebouncedValue::::new(delay_ms)) +} diff --git a/ui/src/hook/debounced_value/impl.rs b/ui/src/hook/debounced_value/impl.rs index d97865ed..594fb9bd 100644 --- a/ui/src/hook/debounced_value/impl.rs +++ b/ui/src/hook/debounced_value/impl.rs @@ -1,5 +1,6 @@ use super::*; +/// Inherent implementation of [`DebouncedValue`]. impl DebouncedValue { /// Schedules `next` to become the emitted value. The /// commit happens on the next `tick` call at or after @@ -81,6 +82,7 @@ impl DebouncedValue { } } +/// Debug formatting for [`DebouncedValue`]. impl Display for DebouncedValue { /// Formats the [`DebouncedValue`] via the supplied formatter. /// diff --git a/ui/src/hook/debounced_value/mod.rs b/ui/src/hook/debounced_value/mod.rs index d8b7be25..9a63362b 100644 --- a/ui/src/hook/debounced_value/mod.rs +++ b/ui/src/hook/debounced_value/mod.rs @@ -1,8 +1,9 @@ mod r#enum; +mod r#fn; mod r#impl; mod r#struct; pub(crate) use r#enum::*; -pub use r#struct::*; +pub use {r#fn::*, r#struct::*}; use super::*; diff --git a/ui/src/hook/debounced_value/struct.rs b/ui/src/hook/debounced_value/struct.rs index d932da9f..e1e996ad 100644 --- a/ui/src/hook/debounced_value/struct.rs +++ b/ui/src/hook/debounced_value/struct.rs @@ -25,6 +25,7 @@ pub struct DebouncedValue { /// `Signal::create(T::default())` via /// `#[new(skip)]`. #[new(skip)] + #[get(type(copy))] pub(crate) value: Signal, /// The internal pending/empty state. Defaults to /// `Signal::create(DebounceState::Idle)` via @@ -34,3 +35,8 @@ pub struct DebouncedValue { /// The quiet period in milliseconds. pub(crate) delay_ms: u32, } + +/// `DebouncedValue` is `Copy` when `T` is โ€” every field +/// (`Signal`, `Signal>`, `u32`) is itself +/// `Copy`, so the blanket impl is sound. +impl Copy for DebouncedValue where T: Clone + PartialEq + Default + 'static {} diff --git a/ui/src/hook/error_boundary/fn.rs b/ui/src/hook/error_boundary/fn.rs index 2d27c7ab..fc6db883 100644 --- a/ui/src/hook/error_boundary/fn.rs +++ b/ui/src/hook/error_boundary/fn.rs @@ -1,21 +1,49 @@ use super::*; -/// Best-effort conversion of a `Box` -/// panic payload to a string. +/// Extracts the panic message from a `catch_unwind` payload. +/// +/// `catch_unwind` returns a `Box`. The boxed type +/// is whatever the panic site threw โ€” most commonly a `String` / +/// `&str`, but Rust also supports throwing `&'static str` from +/// `std::panic!`. This helper tries each, in order, and falls +/// back to `""` so the boundary always +/// has a useful message to display. /// /// # Arguments /// -/// - `&Box` - Shared reference to a `Box`. +/// - `&Box` - The boxed payload from +/// [`std::panic::catch_unwind`]. /// /// # Returns /// -/// - `String` - A `String` value. +/// - `String` - The recovered panic message. pub(crate) fn extract_message(payload: &Box) -> String { - if let Some(message) = payload.downcast_ref::<&'static str>() { - (*message).to_string() - } else if let Some(message) = payload.downcast_ref::() { - message.clone() - } else { - "".to_string() + if let Some(s) = payload.downcast_ref::() { + return s.clone(); + } + if let Some(s) = payload.downcast_ref::<&'static str>() { + return (*s).to_string(); } + String::from("") +} + +/// Obtains an `ErrorBoundary` registered against the current hook context slot. +/// +/// Behaves like `HookContext::use_hook`: the same `ErrorBoundary` is +/// returned on every render at the same hook index, preserving the +/// `Idle` / `Caught(message)` phase across renders. +/// +/// Use [`ErrorBoundary::try_with`] to run a closure under the +/// boundary; panics inside the closure are caught and the phase +/// transitions to `Caught`. The parent's render code reads +/// [`ErrorBoundary::phase`] (a `Signal`) to decide +/// whether to render the children or a fallback. +/// +/// # Returns +/// +/// - `ErrorBoundary` - The error boundary handle. +/// Returns the factory result directly when no hook context is +/// active (e.g. when called outside a render cycle). +pub fn use_error_boundary() -> ErrorBoundary { + HookContext::use_hook(ErrorBoundary::default) } diff --git a/ui/src/hook/error_boundary/impl.rs b/ui/src/hook/error_boundary/impl.rs index 2e1055bc..6c71fde5 100644 --- a/ui/src/hook/error_boundary/impl.rs +++ b/ui/src/hook/error_boundary/impl.rs @@ -42,6 +42,32 @@ impl ErrorBoundary { } } + /// Feeds an `Err` straight into the boundary + /// without forcing the caller to `panic!`. + /// + /// `try_with` requires a real panic to transition + /// the phase to `Caught`, which makes demonstrating + /// the hook from outside `tests/` awkward. This + /// helper lets demo / driver code report a failure + /// message via the regular `Result` channel and + /// still flip the boundary into `Caught`. + /// + /// # Arguments + /// + /// - `&str` - The error message to surface. + /// + /// # Returns + /// + /// - `String` - The same message that was passed in. + pub fn report_error(&self, message: &str) -> String { + let owned: String = String::from(message); + let _ = catch_unwind(AssertUnwindSafe(|| { + self.get_phase() + .set(ErrorBoundaryPhase::Caught(owned.clone())); + })); + owned + } + /// Transitions the boundary back to `Healthy`. /// Useful when invalidating the cache (e.g., /// after a retry). diff --git a/ui/src/hook/error_boundary/mod.rs b/ui/src/hook/error_boundary/mod.rs index 9bbc3e95..b5818f3a 100644 --- a/ui/src/hook/error_boundary/mod.rs +++ b/ui/src/hook/error_boundary/mod.rs @@ -3,9 +3,9 @@ mod r#fn; mod r#impl; mod r#struct; -pub(crate) use r#fn::extract_message; - pub use r#enum::*; +pub use r#fn::use_error_boundary; pub use r#struct::*; +pub(crate) use r#fn::extract_message; use super::*; diff --git a/ui/src/hook/error_boundary/struct.rs b/ui/src/hook/error_boundary/struct.rs index 8978c0d8..9830d823 100644 --- a/ui/src/hook/error_boundary/struct.rs +++ b/ui/src/hook/error_boundary/struct.rs @@ -18,3 +18,8 @@ pub struct ErrorBoundary { /// The phase signal. pub(crate) phase: Signal, } + +/// `ErrorBoundary` is `Copy` because `Signal` +/// is itself `Copy` โ€” the signal registry hands out cheap +/// `usize` addresses. +impl Copy for ErrorBoundary {} diff --git a/ui/src/hook/i18n/fn.rs b/ui/src/hook/i18n/fn.rs index 10cabc17..2840ecf2 100644 --- a/ui/src/hook/i18n/fn.rs +++ b/ui/src/hook/i18n/fn.rs @@ -1,59 +1,124 @@ use super::*; -/// Substitutes `{name}`-style placeholders in `template` -/// with values from `vars`. +/// Interpolates `{name}`-style placeholders in `template` from the +/// supplied `vars` map. /// -/// Placeholder names may contain alphanumerics and -/// underscores. Missing placeholders are left as the -/// literal `{name}` token. No escaping is performed โ€” the -/// literal text `{` in a translation is treated as the -/// start of a placeholder. +/// Walks the template once and replaces every `{key}` segment whose +/// key is present in `vars`. Placeholders whose key is missing are +/// left as the literal `{key}` token โ€” matching the i18next +/// default behaviour. No escaping is performed: callers that need +/// it must escape themselves. +/// +/// Returns the interpolated `String`. Empty / no-placeholder +/// templates round-trip unchanged. /// /// # Arguments /// -/// - `&str` - Shared reference to a `str`. -/// - `&HashMap<&'static str, &'static str>` - Shared reference to a `HashMap<&'static str, &'static str>`. +/// - `&str` - The template containing `{key}` placeholders. +/// - `&HashMap<&'static str, &'static str>` - The variable map. A +/// static-borrowed key list is enough because the typical +/// consumer (`I18n::t_with`) builds the map inline. /// /// # Returns /// -/// - `String` - A `String` value. +/// - `String` - The interpolated string. pub(crate) fn interpolate(template: &str, vars: &HashMap<&'static str, &'static str>) -> String { - let mut output: String = String::with_capacity(template.len()); - let mut chars: Chars<'_> = template.chars(); - while let Some(c) = chars.next() { - if c != '{' { - output.push(c); - continue; - } - // Try to read until the matching `}`. If we - // hit EOF or another `{` before `}`, treat - // the `{` as a literal. - let mut name: String = String::new(); - let mut closed: bool = false; - for inner in chars.by_ref() { - if inner == '}' { - closed = true; - break; + let mut result: String = String::new(); + let bytes: &[u8] = template.as_bytes(); + let mut cursor: usize = 0_usize; + while cursor < bytes.len() { + if bytes[cursor] == b'{' && cursor + 1_usize < bytes.len() && bytes[cursor + 1_usize] != b'{' { + // Look for the matching `}`. + if let Some(close_rel) = template[cursor + 1_usize..].find('}') { + let close: usize = cursor + 1_usize + close_rel; + let key: &str = &template[cursor + 1_usize..close]; + if let Some(value) = vars.get(key) { + result.push_str(value); + } else { + // Preserve the original `{key}` placeholder. + result.push('{'); + result.push_str(key); + result.push('}'); + } + cursor = close + 1_usize; + continue; } - name.push(inner); - } - if !closed { - // Unterminated `{` โ€” emit literally. - output.push('{'); - output.push_str(&name); - continue; } - // Look up the placeholder. - match vars.get(name.as_str()) { - Some(value) => output.push_str(value), - None => { - // Leave as literal `{name}` so the - // missing-translation bug is visible. - output.push('{'); - output.push_str(&name); - output.push('}'); - } + // Append the current char and move on. Works for ASCII; + // multi-byte UTF-8 falls through as-is because `result.push` + // re-encodes the byte at the cursor position. + if let Some(ch) = template[cursor..].chars().next() { + result.push(ch); + cursor += ch.len_utf8(); + } else { + break; } } - output + result +} + +/// Obtains the i18n handle registered against the current hook context slot. +/// +/// Behaves like `HookContext::use_hook`: the same `I18n` is returned +/// on every render at the same hook index, preserving the locale, +/// fallback locale, and message table across renders. +/// +/// The initial `locale` defaults to `"en"`; pass `init_locale` to +/// change it on the first render. Call [`I18n::add_messages`] to +/// register translations under each locale tag. +/// +/// # Arguments +/// +/// - `&str` - The initial locale tag. Defaults to `"en"` if the +/// empty string is supplied (so a wrapper can pass `""` to adopt +/// the default). +/// +/// # Returns +/// +/// - `I18n` - The i18n handle. +/// Returns the factory result directly when no hook context is +/// active (e.g. when called outside a render cycle). +pub fn use_i18n(init_locale: &str) -> I18n { + let locale: &str = if init_locale.is_empty() { + "en" + } else { + init_locale + }; + HookContext::use_hook(move || { + I18n::new( + Signal::create(locale.to_string()), + Signal::create(String::from("en")), + Signal::create(HashMap::new()), + ) + }) +} + +/// Registers a translation `key -> message` under `locale` on the +/// supplied i18n handle. +/// +/// Pair with [`use_i18n`]: the typical pattern is to call this in +/// an `App::use_cleanup`-style mount phase so the same translations +/// stay registered across renders. +/// +/// The `&'static` lifetime bound matches the internal +/// `MessageEntry` type alias (`(&'static str, &'static str)`); +/// in practice the call site passes an array literal that the +/// compiler naturally satisfies from string-literal promotion. +/// +/// # Arguments +/// +/// - `I18n` - The i18n handle obtained from `use_i18n()`. +/// - `&str` - The locale tag (e.g. `"en"`, `"zh-CN"`). +/// - `&[(&'static str, &'static str)]` - The `(key, message)` pairs. +/// Internally forwarded as `&[MessageEntry]`. +/// +/// # Panics +/// +/// This function does not panic. +pub fn i18n_register( + handle: I18n, + locale: &str, + entries: &[(&'static str, &'static str)], +) { + handle.add_messages(locale, entries); } diff --git a/ui/src/hook/i18n/mod.rs b/ui/src/hook/i18n/mod.rs index 80c6ffc1..2e81b07b 100644 --- a/ui/src/hook/i18n/mod.rs +++ b/ui/src/hook/i18n/mod.rs @@ -4,8 +4,6 @@ mod r#struct; mod r#trait; mod r#type; -pub use {r#struct::*, r#trait::*, r#type::*}; - -pub(crate) use r#fn::*; +pub use {r#fn::*, r#struct::*, r#trait::*, r#type::*}; use super::*; diff --git a/ui/src/hook/i18n/struct.rs b/ui/src/hook/i18n/struct.rs index 93ed052f..da8fb8a6 100644 --- a/ui/src/hook/i18n/struct.rs +++ b/ui/src/hook/i18n/struct.rs @@ -18,6 +18,11 @@ use super::*; /// &[(k, v), ...])` build the inner map by direct /// insertion without the user having to allocate one /// `HashMap` per locale. +/// `I18n` is `Copy` because every field is a `Signal`, which is +/// already `Copy` โ€” the registry hands out cheap `usize` +/// addresses for any `T: Clone + PartialEq + 'static`. +impl Copy for I18n {} + #[derive(Clone, Data, New)] pub struct I18n { /// The currently-active locale tag. Setting this diff --git a/ui/src/hook/lazy/fn.rs b/ui/src/hook/lazy/fn.rs new file mode 100644 index 00000000..d340a76c --- /dev/null +++ b/ui/src/hook/lazy/fn.rs @@ -0,0 +1,29 @@ +use super::*; + +/// Obtains a `LazyComponent` registered against the current hook context slot. +/// +/// Behaves like `HookContext::use_hook`: the same `LazyComponent` is +/// returned on every render at the same hook index, preserving the +/// load state across renders. The factory closure is invoked on first +/// access via [`LazyComponent::get`] / [`LazyComponent::loaded`] / +/// [`LazyComponent::prefetch`]. +/// +/// # Arguments +/// +/// - `Rc T>` - The factory that produces the underlying +/// value on demand. Wrapped in `Rc` so the `LazyComponent` can be +/// cloned cheaply and the factory can be invoked multiple times +/// after a [`LazyComponent::reset`] (when the load state is reset). +/// +/// # Returns +/// +/// - `LazyComponent` - The lazy component handle. +/// Returns the factory result directly when no hook context is +/// active (e.g. when called outside a render cycle). +pub fn use_lazy_component(factory: F) -> LazyComponent +where + T: Clone + PartialEq + Debug + 'static, + F: Fn() -> T + 'static, +{ + HookContext::use_hook(move || LazyComponent::::new(factory)) +} diff --git a/ui/src/hook/lazy/impl.rs b/ui/src/hook/lazy/impl.rs index ee0073b3..aa022821 100644 --- a/ui/src/hook/lazy/impl.rs +++ b/ui/src/hook/lazy/impl.rs @@ -1,5 +1,6 @@ use super::*; +/// Equality for [`LoadState`]. impl PartialEq for LoadState { /// Returns `true` when `self` and `other` are equivalent by the [`PartialEq`] contract. /// @@ -21,6 +22,7 @@ impl PartialEq for LoadState { } } +/// Inherent implementation of [`LazyComponent`]. impl LazyComponent { /// Creates a new lazy component with the given /// factory. The factory is NOT called yet. @@ -140,6 +142,7 @@ impl LazyComponent { } } +/// Debug formatting for [`LazyComponent`]. impl Debug for LazyComponent { /// Formats the [`LazyComponent`] via the supplied formatter. /// diff --git a/ui/src/hook/lazy/mod.rs b/ui/src/hook/lazy/mod.rs index 5d4187dc..67ac4f49 100644 --- a/ui/src/hook/lazy/mod.rs +++ b/ui/src/hook/lazy/mod.rs @@ -1,7 +1,8 @@ mod r#enum; +mod r#fn; mod r#impl; mod r#struct; -pub use {r#enum::*, r#struct::*}; +pub use {r#enum::*, r#fn::*, r#struct::*}; use super::*; diff --git a/ui/src/hook/previous/fn.rs b/ui/src/hook/previous/fn.rs new file mode 100644 index 00000000..3b0bbc5d --- /dev/null +++ b/ui/src/hook/previous/fn.rs @@ -0,0 +1,45 @@ +use super::*; + +/// Obtains the previous-value tracker registered against the current +/// hook context slot. +/// +/// Behaves like `HookContext::use_hook`: the same `Previous` is +/// returned on every render at the same hook index, so the captured +/// `previous` signal survives across renders without losing state. +/// +/// # Returns +/// +/// - `Previous` - The previous-value tracker handle. +/// Returns the factory result directly when no hook context is +/// active (e.g. when called outside a render cycle). +pub fn use_previous() -> Previous +where + T: Clone + PartialEq + Debug + 'static, +{ + HookContext::use_hook(Previous::::new) +} + +/// Records `current` against the supplied tracker and returns the +/// snapshot of what was previously recorded. +/// +/// Convenience wrapper used by component-level consumers that want +/// the "compute previous" + "record new current" steps glued together. +/// Returns `None` on the first call (no prior value exists yet). +/// +/// # Arguments +/// +/// - `Previous` - The tracker obtained from `use_previous()`. +/// - `T` - The current value to record. +/// +/// # Returns +/// +/// - `Option` - The value that was recorded on the previous call, +/// or `None` if no prior value exists. +pub fn previous_step(previous: Previous, current: T) -> Option +where + T: Clone + PartialEq + Debug + 'static, +{ + let snapshot: Option = previous.get_previous_snapshot(); + previous.record(current); + snapshot +} diff --git a/ui/src/hook/previous/impl.rs b/ui/src/hook/previous/impl.rs index e73d09f5..1d708e4c 100644 --- a/ui/src/hook/previous/impl.rs +++ b/ui/src/hook/previous/impl.rs @@ -1,5 +1,6 @@ use super::*; +/// Inherent implementation of [`Previous`]. impl Previous { /// Creates a new `Previous` with no recorded value. /// The `previous` signal starts at `None`. @@ -40,6 +41,7 @@ impl Previous { } } +/// Debug formatting for [`Previous`]. impl Display for Previous { /// Formats the [`Previous`] via the supplied formatter. /// @@ -58,6 +60,7 @@ impl Display for Previous { } } +/// Default-construction for [`Previous`]. impl Default for Previous { /// Constructs a default [`Previous`] value. fn default() -> Self { diff --git a/ui/src/hook/previous/mod.rs b/ui/src/hook/previous/mod.rs index 4dfc79f4..5316cde2 100644 --- a/ui/src/hook/previous/mod.rs +++ b/ui/src/hook/previous/mod.rs @@ -1,6 +1,7 @@ +mod r#fn; mod r#impl; mod r#struct; -pub use r#struct::*; +pub use {r#fn::*, r#struct::*}; use super::*; diff --git a/ui/src/hook/previous/struct.rs b/ui/src/hook/previous/struct.rs index da3e29a8..fac938f7 100644 --- a/ui/src/hook/previous/struct.rs +++ b/ui/src/hook/previous/struct.rs @@ -32,3 +32,8 @@ pub struct Previous { /// `record` call. pub(crate) previous: Signal>, } + +/// `Previous` is `Copy` when `T` is โ€” `Signal>` is +/// already `Copy` (the signal registry hands out cheap `usize` +/// addresses), so this blanket impl is sound. +impl Copy for Previous where T: Clone + PartialEq + 'static {} diff --git a/ui/src/hook/profiler/fn.rs b/ui/src/hook/profiler/fn.rs index 9c96d51f..9abc6aab 100644 --- a/ui/src/hook/profiler/fn.rs +++ b/ui/src/hook/profiler/fn.rs @@ -1,53 +1,81 @@ use super::*; -/// Returns a monotonic millisecond timestamp suitable for -/// profiling measurements. -/// -/// Delegates to `Performance::now()` when available, which is -/// monotonic per-spec, sub-millisecond resolution, and shared -/// across all `Worker` scopes in the same browsing context. -/// Falls back to `Date.now()` (non-monotonic, but always -/// available) when `performance.now()` is missing โ€” the -/// fallback only fires in worklets or non-browser wasm -/// runtimes. -/// -/// On non-wasm test runners where the browser API surface is -/// absent the first call may unwind; the `FALLBACK_MS` cell -/// then caches the result of a process-local monotonic clock -/// so subsequent calls don't re-trigger the web-sys lazy -/// initialiser (which would poison once-cell across the rest -/// of the test process). +/// Returns the current wall-clock time, in milliseconds. +/// +/// Browser-only wrapper around [`js_sys::Date::now`] (also doubles +/// as `performance.now()`-based input if the host environment +/// exposes one). Returns a `f64` because the upstream JavaScript +/// value is also `f64` and rounding to integer milliseconds throws +/// away the sub-millisecond precision the profiler relies on. +/// +/// The function is `pub(crate)` because the only intended consumer +/// lives in the same crate (the profiler's `measure` / `begin` / +/// `end` paths); downstream code does not need to call it directly. /// /// # Returns /// -/// - `f64` - A monotonically-increasing millisecond timestamp. -/// Always `>= 0.0`. +/// - `f64` - The current wall-clock time, in milliseconds. pub fn now_ms() -> f64 { - let result: Result> = - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - window() - .and_then(|window: Window| window.performance()) - .map(|performance: Performance| performance.now()) - .unwrap_or_else(Date::now) - })); - match result { - Ok(ms) => ms, - Err(_) => process_local_ms(), - } + js_sys::Date::now() +} + +/// Obtains a `ProfilerHandle` registered against the current hook context slot. +/// +/// Behaves like `HookContext::use_hook`: the same `ProfilerHandle` +/// is returned on every render at the same hook index, so +/// measurements pushed onto its entries signal remain visible +/// across renders. +/// +/// Use [`ProfilerHandle::measure`] for a single-shot +/// "label + closure" form or [`ProfilerHandle::begin`] / +/// [`ProfilerHandle::end`] for the split-timer form. Reads of +/// [`ProfilerHandle::entries`] inside a render closure subscribe +/// the render to new entries. +/// +/// # Returns +/// +/// - `ProfilerHandle` - The profiler handle. +/// Returns the factory result directly when no hook context is +/// active (e.g. when called outside a render cycle). +pub fn use_profiler() -> ProfilerHandle { + HookContext::use_hook(ProfilerHandle::new_with_empty_entries) } -/// Fallback millisecond clock anchored at this thread's first call. -/// Helper body of the `process_local_ms` free function. +/// Runs `body` and records the elapsed time under `label`. +/// +/// Convenience helper that pairs with `App::use_interval`-style +/// re-renders: any render closure can call `profiler_measure(label, ...)` +/// on its hot path and the result lands in the same `ProfilerHandle`'s +/// entries signal. +/// +/// # Arguments +/// +/// - `&str` - The free-form label that identifies this measurement. +/// - `F: FnOnce() -> R` - The closure whose execution time is +/// measured. /// /// # Returns /// -/// - `f64` - A non-negative monotonically-increasing millisecond value. -fn process_local_ms() -> f64 { - thread_local! { - static START: std::cell::OnceCell = const { std::cell::OnceCell::new() }; - } - START.with(|cell: &std::cell::OnceCell| { - let start: &Instant = cell.get_or_init(Instant::now); - start.elapsed().as_secs_f64() * 1000.0 - }) +/// - `R` - The closure's return value, unchanged. +pub fn profiler_measure(label: &str, body: F) -> R +where + F: FnOnce() -> R, +{ + let profiler: ProfilerHandle = use_profiler(); + let start_ms: f64 = now_ms(); + let result: R = body(); + let elapsed_ms: f64 = now_ms() - start_ms; + let timestamp_ms: f64 = now_ms(); + let entry: ProfileEntry = ProfileEntry { + label: label.to_string(), + elapsed_ms, + timestamp_ms, + }; + let next_entries: Vec = { + let mut next: Vec = profiler.get_entries().get(); + next.push(entry); + next + }; + profiler.get_entries().set(next_entries); + result } diff --git a/ui/src/hook/profiler/impl.rs b/ui/src/hook/profiler/impl.rs index 2a13082f..431248a4 100644 --- a/ui/src/hook/profiler/impl.rs +++ b/ui/src/hook/profiler/impl.rs @@ -2,6 +2,24 @@ use super::*; /// Inherent implementation of [`ProfilerHandle`]. impl ProfilerHandle { + /// Constructs a `ProfilerHandle` with an empty entries log. + /// + /// Lombok `New` cannot derive this for us because the + /// `entries` field is a `Signal<...>` rather than a plain + /// value โ€” we cannot synthesise a meaningful default at + /// compile time, so the hook-context factory wires one up at + /// runtime via `Signal::create(Vec::new())`. + /// + /// # Returns + /// + /// - `ProfilerHandle` - A profiler handle with no recorded + /// measurements. + pub fn new_with_empty_entries() -> Self { + Self { + entries: Signal::create(Vec::new()), + } + } + /// Records a fresh measurement around the given closure. /// /// Captures the start timestamp, runs `f`, captures the diff --git a/ui/src/hook/profiler/struct.rs b/ui/src/hook/profiler/struct.rs index e7069791..58c73f38 100644 --- a/ui/src/hook/profiler/struct.rs +++ b/ui/src/hook/profiler/struct.rs @@ -71,6 +71,11 @@ pub struct ProfilerHandle { pub(crate) entries: Signal>, } +/// `ProfilerHandle` is `Copy` because `Signal>` +/// is itself `Copy` (the registry hands out cheap `usize` +/// addresses; the vector lives in the global signal store). +impl Copy for ProfilerHandle {} + /// A `begin()` marker โ€” RAII guard that records the start /// timestamp and the label so the matching `end()` call can /// compute the elapsed time. diff --git a/ui/src/hook/suspense/fn.rs b/ui/src/hook/suspense/fn.rs new file mode 100644 index 00000000..e0182b89 --- /dev/null +++ b/ui/src/hook/suspense/fn.rs @@ -0,0 +1,25 @@ +use super::*; + +/// Obtains a `SuspenseHandle` registered against the current hook context slot. +/// +/// Behaves like `HookContext::use_hook`: the same `SuspenseHandle` is +/// returned on every render at the same hook index, preserving the +/// `Pending` / `Resolved(value)` / `Failed(message)` phase across renders. +/// +/// Pair the returned handle with [`SuspenseHandle::resolve_sync`] / +/// [`SuspenseHandle::fail`] to transition the phase; the parent +/// component reads [`SuspenseHandle::state`] (or the underlying +/// `phase` signal) to decide whether to render the children or a +/// fallback. +/// +/// # Returns +/// +/// - `SuspenseHandle` - The suspense handle. +/// Returns the factory result directly when no hook context is +/// active (e.g. when called outside a render cycle). +pub fn use_suspense() -> SuspenseHandle +where + T: Clone + PartialEq + Debug + 'static, +{ + HookContext::use_hook(SuspenseHandle::::new) +} diff --git a/ui/src/hook/suspense/impl.rs b/ui/src/hook/suspense/impl.rs index 920cf16a..6e862f9b 100644 --- a/ui/src/hook/suspense/impl.rs +++ b/ui/src/hook/suspense/impl.rs @@ -1,5 +1,6 @@ use super::*; +/// Inherent implementation of [`SuspenseHandle`]. impl SuspenseHandle { /// Creates a new `SuspenseHandle` in the `Pending` /// phase. @@ -37,6 +38,7 @@ impl SuspenseHandle { } } +/// Default-construction for [`SuspenseHandle`]. impl Default for SuspenseHandle { /// Constructs a default [`SuspenseHandle`] value. fn default() -> Self { @@ -44,6 +46,7 @@ impl Default for SuspenseHandle { } } +/// Debug formatting for [`SuspenseHandle`]. impl Display for SuspenseHandle { /// Formats the [`SuspenseHandle`] via the supplied formatter. /// @@ -59,6 +62,7 @@ impl Display for SuspenseHandle { } } +/// Equality for [`SuspensePhase`]. impl PartialEq for SuspensePhase { /// Returns `true` when `self` and `other` are equivalent by the [`PartialEq`] contract. /// diff --git a/ui/src/hook/suspense/mod.rs b/ui/src/hook/suspense/mod.rs index 47fb1625..67ac4f49 100644 --- a/ui/src/hook/suspense/mod.rs +++ b/ui/src/hook/suspense/mod.rs @@ -1,8 +1,8 @@ mod r#enum; +mod r#fn; mod r#impl; mod r#struct; -pub use r#enum::*; -pub use r#struct::*; +pub use {r#enum::*, r#fn::*, r#struct::*}; use super::*; diff --git a/ui/src/hook/suspense/struct.rs b/ui/src/hook/suspense/struct.rs index 59e2bc6e..b7b495b6 100644 --- a/ui/src/hook/suspense/struct.rs +++ b/ui/src/hook/suspense/struct.rs @@ -13,3 +13,8 @@ pub struct SuspenseHandle { /// The phase signal. pub(crate) phase: Signal>, } + +/// `SuspenseHandle` is `Copy` when `T` is โ€” `Signal<...>` is +/// `Copy` for any `T: Clone + PartialEq + 'static`, so this +/// blanket impl is sound. +impl Copy for SuspenseHandle where T: Clone + PartialEq + 'static {} diff --git a/ui/src/hook/throttled_value/fn.rs b/ui/src/hook/throttled_value/fn.rs new file mode 100644 index 00000000..83191216 --- /dev/null +++ b/ui/src/hook/throttled_value/fn.rs @@ -0,0 +1,28 @@ +use super::*; + +/// Obtains the throttled value registered against the current hook context slot. +/// +/// Behaves like `HookContext::use_hook`: the same `ThrottledValue` is +/// returned on every render at the same hook index, preserving the +/// emitted value, the pending slot, and the cooldown state across +/// renders. +/// +/// `interval_ms = 0` collapses to "every `set` is immediately committed"; +/// see [`ThrottledValue::set`] for the full behaviour. +/// +/// # Arguments +/// +/// - `u32` - The throttle window in milliseconds. The most-recent +/// input is committed at most once per window. +/// +/// # Returns +/// +/// - `ThrottledValue` - The throttled value handle. +/// Returns the factory result directly when no hook context is +/// active (e.g. when called outside a render cycle). +pub fn use_throttled_value(interval_ms: u32) -> ThrottledValue +where + T: Clone + PartialEq + Debug + Default + 'static, +{ + HookContext::use_hook(|| ThrottledValue::::new(interval_ms)) +} diff --git a/ui/src/hook/throttled_value/impl.rs b/ui/src/hook/throttled_value/impl.rs index 4fd4e70b..7d76b870 100644 --- a/ui/src/hook/throttled_value/impl.rs +++ b/ui/src/hook/throttled_value/impl.rs @@ -1,5 +1,6 @@ use super::*; +/// Inherent implementation of [`ThrottledValue`]. impl ThrottledValue { /// Sends `next` through the throttle. /// @@ -107,6 +108,7 @@ impl ThrottledValue { } } +/// Debug formatting for [`ThrottledValue`]. impl Display for ThrottledValue { /// Formats the [`ThrottledValue`] via the supplied formatter. /// diff --git a/ui/src/hook/throttled_value/mod.rs b/ui/src/hook/throttled_value/mod.rs index d8b7be25..9a63362b 100644 --- a/ui/src/hook/throttled_value/mod.rs +++ b/ui/src/hook/throttled_value/mod.rs @@ -1,8 +1,9 @@ mod r#enum; +mod r#fn; mod r#impl; mod r#struct; pub(crate) use r#enum::*; -pub use r#struct::*; +pub use {r#fn::*, r#struct::*}; use super::*; diff --git a/ui/src/hook/throttled_value/struct.rs b/ui/src/hook/throttled_value/struct.rs index be829ee6..fa8f9cc4 100644 --- a/ui/src/hook/throttled_value/struct.rs +++ b/ui/src/hook/throttled_value/struct.rs @@ -23,6 +23,7 @@ pub struct ThrottledValue { /// `Signal::create(T::default())` via /// `#[new(skip)]`. #[new(skip)] + #[get(type(copy))] pub(crate) value: Signal, /// The latest input waiting for the next commit. /// Defaults to `Signal::create(None)` via @@ -37,3 +38,8 @@ pub struct ThrottledValue { /// The throttle window in milliseconds. pub(crate) interval_ms: u32, } + +/// `ThrottledValue` is `Copy` when `T` is โ€” every field +/// is itself `Copy` (`Signal`, `Signal>`, `u32`) +/// or a simple `enum`, so the blanket impl is sound. +impl Copy for ThrottledValue where T: Clone + PartialEq + Default + 'static {} diff --git a/ui/src/hook/use_async/fn.rs b/ui/src/hook/use_async/fn.rs new file mode 100644 index 00000000..8fd9140c --- /dev/null +++ b/ui/src/hook/use_async/fn.rs @@ -0,0 +1,28 @@ +use super::*; + +/// Obtains a stand-alone (non-reactive) `UseAsyncHandle`. +/// +/// Unlike the `HookContext`-bound variant described in the trait +/// doc-comment, this factory deliberately skips the hook slot and +/// falls back to a self-contained handle. That's the same code path +/// that `UseAsyncHandle::default()` uses internally, and it's the +/// only path that compiles today (`HookContext::use_async` is on +/// the roadmap but not yet wired in `euv-core`). +/// +/// The returned handle is `Copy`, cheap to pass around, and exposes +/// `state()` / `set_state()` for non-async testing as well as +/// `refetch()` for the real wasm path. Render code that wants +/// reactive updates can still subscribe by reading `state()` inside +/// a render closure. +/// +/// # Returns +/// +/// - `UseAsyncHandle` - The async handle in the `Loading` +/// initial state, ready for an `refetch(...)` from the call site. +pub fn use_async() -> UseAsyncHandle +where + T: Clone + PartialEq + 'static, + L: Clone + PartialEq + HasLoadingHint + 'static, +{ + UseAsyncHandle::new_for_fallback() +} diff --git a/ui/src/hook/use_async/impl.rs b/ui/src/hook/use_async/impl.rs index d057be4a..9dfae297 100644 --- a/ui/src/hook/use_async/impl.rs +++ b/ui/src/hook/use_async/impl.rs @@ -1,5 +1,6 @@ use super::*; +/// Hook-context teardown for [`UseAsyncSlot`]. impl Drop for UseAsyncSlot where T: Clone + PartialEq + 'static, @@ -17,6 +18,7 @@ where } } +/// Inherent implementation of [`UseAsyncHandle`] โ€” slot lifecycle internals. impl UseAsyncHandle where T: Clone + PartialEq + 'static, @@ -58,6 +60,7 @@ where } } +/// Inherent implementation of [`UseAsyncHandle`] โ€” public reactive API. impl UseAsyncHandle where T: Clone + PartialEq + 'static, @@ -152,6 +155,7 @@ impl HasLoadingHint for () { fn empty() -> Self {} } +/// Debug formatting for [`UseAsyncHandle`]. impl core::fmt::Debug for UseAsyncHandle where T: Clone + PartialEq + 'static, @@ -176,6 +180,7 @@ where } } +/// Default-construction for [`UseAsyncHandle`]. impl Default for UseAsyncHandle where T: Clone + PartialEq + 'static, diff --git a/ui/src/hook/use_async/mod.rs b/ui/src/hook/use_async/mod.rs index 17d80a4d..2510f375 100644 --- a/ui/src/hook/use_async/mod.rs +++ b/ui/src/hook/use_async/mod.rs @@ -1,9 +1,10 @@ mod r#enum; +mod r#fn; mod r#impl; mod r#struct; mod r#trait; mod r#type; -pub use {r#enum::*, r#struct::*, r#trait::*, r#type::*}; +pub use {r#enum::*, r#fn::*, r#struct::*, r#trait::*, r#type::*}; use super::*; diff --git a/ui/src/hook/use_async/struct.rs b/ui/src/hook/use_async/struct.rs index af2c8a03..bf56a9be 100644 --- a/ui/src/hook/use_async/struct.rs +++ b/ui/src/hook/use_async/struct.rs @@ -16,7 +16,7 @@ use super::*; /// Cloning a handle is cheap โ€” `UseAsyncHandle` is `Copy` if its /// generic parameters are. Use it from event handlers the same way /// you'd use a `Signal`. -#[derive(Clone, Copy, Data)] +#[derive(Clone, Data)] pub struct UseAsyncHandle where T: Clone + PartialEq + 'static, @@ -28,6 +28,17 @@ where pub(crate) _marker: core::marker::PhantomData (T, L)>, } +/// Blanket `Copy` for any generic instance โ€” both fields are +/// themselves `Copy` (`usize`, `PhantomData`). +/// The `where` clause must be repeated because a separate impl +/// block cannot inherit bounds from the type declaration. +impl core::marker::Copy for UseAsyncHandle +where + T: Clone + PartialEq + 'static, + L: Clone + PartialEq + HasLoadingHint + 'static, +{ +} + /// Heap-allocated state backing a [`super::UseAsyncHandle`]. /// /// Reachable only through the raw address stored in the handle. diff --git a/ui/src/lib.rs b/ui/src/lib.rs index 99c7e06a..0ed8d94e 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -18,7 +18,6 @@ use std::{ ops::Deref, panic::{AssertUnwindSafe, UnwindSafe, catch_unwind}, rc::Rc, - str::Chars, sync::{ LazyLock, atomic::{AtomicBool, Ordering},