Skip to main content

teksilo_widgets/styles/
recipe_rich_text_editor_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `RichTextEditorStyle` impl.
5//!
6//! Frames the editor viewport in a TextInput-like border + padding +
7//! corner-radius surface. The `RichTextEditor` widget routes its
8//! body through this trait — apps installing a custom
9//! `RichTextEditorStyle` (per-call `.style(...)` or theme-wide via
10//! `style_slots.rich_text_editor`) swap the chrome wholesale
11//! without touching the editor's handlers, focus, or paint.
12
13use teksilo_core::build_context::BuildContext;
14use teksilo_core::color_prop::ColorProp;
15use teksilo_core::styles::{RichTextEditorStyle, RichTextEditorStyleConfig};
16use teksilo_core::widget_id::WidgetId;
17use teksilo_tokens::{BorderRole, CornerRadius, InputTokens, SurfaceRole};
18
19use crate::primitives::{Padding, RectWidget, ZStack};
20use crate::styles::TextInputRecipe;
21
22/// Default `RichTextEditorStyle` shipped with Teksilo. Wraps the
23/// viewport in a TextInput-like border + padding + corner-radius
24/// frame, with a focus-aware border. Returns the viewport id directly
25/// when read-only — viewers shouldn't carry an editable-field frame.
26#[derive(Debug, Default, Clone, Copy)]
27pub struct RecipeRichTextEditorStyle;
28
29impl RecipeRichTextEditorStyle {
30    /// This style resolved against a density's [`InputTokens`], as
31    /// `RecipeRichTextEditorStyle::for_tokens(&ctx.theme().input)` at the widget's own
32    /// build site.
33    ///
34    /// The style is a unit struct: it borrows the text-field dimensions
35    /// wholesale, and resolves them per density inside
36    /// [`make_body`](RichTextEditorStyle::make_body) from
37    /// `ctx.theme().input`, so there is nothing to bake here.
38    pub fn for_tokens(_tokens: &InputTokens) -> Self {
39        Self
40    }
41}
42
43impl RichTextEditorStyle for RecipeRichTextEditorStyle {
44    fn make_body(&self, cfg: &RichTextEditorStyleConfig, ctx: &mut BuildContext) -> WidgetId {
45        // Read-only viewers stay frameless — they're typically rendered
46        // inside an outer surface (Card, Panel) that owns the chrome.
47        // The widget-level `content_padding` knob is still honoured here:
48        // a user-set inset wraps the viewport in a Padding so the text
49        // gets the requested gutter against whatever surface the viewer
50        // is mounted in.
51        if cfg.is_read_only {
52            let content = match cfg.content_padding {
53                Some((t, r, b, l)) => ctx.add(Padding::new(t, r, b, l).child(cfg.viewport)),
54                None => cfg.viewport,
55            };
56            return match cfg.background.clone() {
57                Some(bg) => {
58                    let bg_id = ctx.add(RectWidget::new().background(bg));
59                    ctx.add(ZStack::new().child(bg_id).child(content))
60                }
61                None => content,
62            };
63        }
64
65        // Editable: TextInput-style focus-aware frame. The widget-level
66        // `content_padding` replaces the default field insets when set.
67        let theme = ctx.theme_signal().get();
68        // The field metrics come from the same recipe `TextInput` uses, resolved
69        // against the active density so the two stay on one baseline.
70        let field = TextInputRecipe::for_tokens(&theme.input);
71        let focus_ring_width = theme.shape.focus_ring_width;
72        let field_border_width = field.border_width;
73        let border_role = cfg.is_focused.map(|f| {
74            if *f {
75                BorderRole::Focused
76            } else {
77                BorderRole::Default
78            }
79        });
80        let border_width_signal = cfg.is_focused.map(move |f| {
81            if *f {
82                focus_ring_width
83            } else {
84                field_border_width
85            }
86        });
87        let bg = RectWidget::new()
88            .background(
89                cfg.background
90                    .clone()
91                    .unwrap_or_else(|| SurfaceRole::Content.into()),
92            )
93            .border_color(ColorProp::DynamicBorderRole(border_role))
94            .border_width(border_width_signal)
95            .corner_radius(CornerRadius::uniform(field.corner_radius));
96        let bg_id = ctx.add(bg);
97        let (pt, pr, pb, pl) = cfg.content_padding.unwrap_or((
98            field.padding_vertical,
99            field.padding_horizontal,
100            field.padding_vertical,
101            field.padding_horizontal,
102        ));
103        let padded = ctx.add(Padding::new(pt, pr, pb, pl).child(cfg.viewport));
104        ctx.add(ZStack::new().child(bg_id).child(padded))
105    }
106}