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}