teksilo_webview/styles/recipe_web_view_style.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default [`WebViewStyle`] implementation reading IntUI tokens, plus the
5//! tiny core-only overlay leaf it builds.
6//!
7//! `teksilo-webview` deliberately does NOT depend on `teksilo-widgets` (so
8//! apps that don't embed web content pay zero compile time for the widget
9//! catalog), so the default overlay can't use `Spinner` / `TextWidget` /
10//! `ZStack`. It is instead a minimal self-contained container that fills its
11//! bounds with a state-derived surface tint behind the app-supplied overlay
12//! content: a subtle "loading" wash before the first page paint, an error
13//! wash on failure, and fully transparent once the engine surface is showing.
14//! Apps that want a richer overlay (animated spinner, retry button) install
15//! their own [`WebViewStyle`] via `WebView::style` or
16//! `theme.style_slots.web_view`.
17
18use teksilo_canvas::{Canvas, Rect, SizeProposal};
19use teksilo_core::accessibility::AccessNodeBuilder;
20use teksilo_core::binding::BindingLevel;
21use teksilo_core::build_context::BuildContext;
22use teksilo_core::signal::Signal;
23use teksilo_core::styles::{WebViewStyle, WebViewStyleConfig, WebViewVisualState};
24use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
25use teksilo_core::widget_id::WidgetId;
26use teksilo_tokens::{BorderRole, CornerRadius};
27
28/// Focus-ring stroke width, in logical pixels. Two, not the usual one: the ring
29/// is drawn against page content the toolkit did not choose and cannot know the
30/// contrast of, so it is deliberately the heavier of the framework's two.
31const FOCUS_RING_WIDTH: f32 = 2.0;
32
33/// Default IntUI web-view style. Stateless; reads theme tokens at paint time.
34#[derive(Debug, Default, Clone, Copy)]
35pub struct RecipeWebViewStyle;
36
37impl WebViewStyle for RecipeWebViewStyle {
38 fn make_body(&self, cfg: &WebViewStyleConfig, ctx: &mut BuildContext) -> WidgetId {
39 ctx.add(WebViewOverlay {
40 state: cfg.state.clone(),
41 focused: cfg.focused.clone(),
42 content: cfg.content,
43 })
44 }
45}
46
47/// State-tinted fill container — the default loading/error wash, with the
48/// app-supplied overlay content composited on top.
49#[derive(Debug)]
50struct WebViewOverlay {
51 state: Signal<WebViewVisualState>,
52 focused: Signal<bool>,
53 content: WidgetId,
54}
55
56impl Widget for WebViewOverlay {
57 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
58 // Repaint (not relayout) when the lifecycle state flips.
59 let self_id = ctx.self_id();
60 self.state
61 .bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
62 self.focused
63 .bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
64 // Adopt the pre-built overlay content as our single child.
65 vec![self.content]
66 }
67
68 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
69 // Fill whatever the parent proposes — the engine surface and overlay
70 // both occupy the full WebView bounds.
71 proposal.resolve(0.0, 0.0).into()
72 }
73
74 fn place_children(
75 &self,
76 bounds: Rect,
77 _proposal: SizeProposal,
78 children: &mut [WidgetPlacement],
79 _ctx: &LayoutContext,
80 ) {
81 for child in children.iter_mut() {
82 child.origin = bounds.origin();
83 child.size = bounds.size();
84 }
85 }
86
87 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
88 // Painted before children, so the wash sits behind the overlay content.
89 let role = self.state.get().surface_role();
90 let color = role.resolve(&ctx.theme.colors);
91 if color.a() > 0.0 {
92 canvas.fill_rounded_rect(bounds, CornerRadius::ZERO, color);
93 }
94
95 // Focus ring. Inset by the full stroke width rather than centred on the
96 // edge, because the engine subview is composited *over* this paint —
97 // a ring straddling the boundary would have its inner half covered by
98 // the page and read as half as thick as it is.
99 if self.focused.get() {
100 let w = FOCUS_RING_WIDTH;
101 let rect = Rect::new(
102 bounds.x + w * 0.5,
103 bounds.y + w * 0.5,
104 (bounds.width - w).max(0.0),
105 (bounds.height - w).max(0.0),
106 );
107 canvas.stroke_rect(rect, BorderRole::Focused.resolve(&ctx.theme.colors), w);
108 }
109 }
110
111 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
112 // Presentational — the WebView composite node owns the a11y story.
113 builder.set_hidden();
114 }
115
116 fn children(&self) -> Vec<WidgetId> {
117 vec![self.content]
118 }
119}