Skip to main content

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}