Skip to main content

perspective_viewer/components/form/
mirrored_textarea.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13//! A `<textarea>` whose box is sized by its own text.
14//!
15//! A textarea cannot grow with its content — it scrolls at whatever height
16//! it was given. The technique here, shared by [`CodeEditor`] and the chat
17//! prompt, is to make the textarea a purely functional layer: it takes the
18//! keystrokes, the selection and the caret, but paints no glyphs
19//! (`color: transparent`). Underneath sits a `<pre>` mirror of the same
20//! characters which IS painted and IS in flow, so the ordinary layout
21//! engine sizes the box.
22//!
23//! This is deliberately not a JS measure-and-resize loop
24//! (`scrollHeight` → inline `height`): that re-measures on a reflow it
25//! just forced, drifts whenever font-size or the loaded font changes
26//! without an `input` event to re-trigger it, and jitters at fractional
27//! line heights. Here the two layers share their text metrics, so growth
28//! is exact by construction and free at runtime.
29//!
30//! Consumers decide the AXES: the mirror's `white-space` governs growth,
31//! so `pre-wrap` grows only downward (the chat prompt) while `pre` also
32//! grows sideways (the expression editor). They also own the box —
33//! padding, borders, min/max size — with `mirrored-textarea.css` fixing
34//! only what MUST agree between the two layers.
35//!
36//! [`CodeEditor`]: super::code_editor::CodeEditor
37
38use yew::html::IntoPropValue;
39use yew::prelude::*;
40
41#[derive(Clone)]
42pub struct Mirror(pub Html);
43
44impl PartialEq for Mirror {
45    fn eq(&self, _other: &Self) -> bool {
46        false
47    }
48}
49
50impl IntoPropValue<Mirror> for Html {
51    fn into_prop_value(self) -> Mirror {
52        Mirror(self)
53    }
54}
55
56#[derive(Properties, PartialEq)]
57pub struct MirroredTextareaProps {
58    /// `id` of the `<textarea>` itself — consumer CSS and tests key on it.
59    pub id: AttrValue,
60
61    /// The mirror's content: the same characters the textarea holds,
62    /// as plain text, or marked up (`CodeEditor` passes highlighted
63    /// spans). A stale mirror means a caret that drifts from the glyphs,
64    /// so this is re-rendered unconditionally — see [`Mirror`].
65    pub mirror: Mirror,
66
67    /// `id` of the mirror `<pre>`, for consumers that style it directly.
68    #[prop_or_default]
69    pub mirror_id: Option<AttrValue>,
70
71    /// Extra classes for the positioned container.
72    #[prop_or_default]
73    pub class: Classes,
74
75    #[prop_or_default]
76    pub textarea_ref: NodeRef,
77
78    #[prop_or_default]
79    pub mirror_ref: NodeRef,
80
81    /// The `placeholder` ATTRIBUTE, which exists for assistive tech: it
82    /// is painted transparent, because a CSS-`content` label cannot be
83    /// written into an attribute and so cannot be localized. The text a
84    /// sighted user reads comes from the mirror instead — see
85    /// [`Self::is_empty`].
86    #[prop_or_default]
87    pub placeholder: AttrValue,
88
89    /// Whether [`Self::mirror`] is empty, which the component cannot see
90    /// for itself (the mirror is opaque `Html`). Tags the mirror with
91    /// `is-empty` so a consumer's stylesheet can supply placeholder text
92    /// through `content`, and therefore through the intl label
93    /// mechanism, rather than through the untranslatable attribute.
94    #[prop_or_default]
95    pub is_empty: bool,
96
97    #[prop_or_default]
98    pub disabled: bool,
99
100    #[prop_or_default]
101    pub oninput: Callback<InputEvent>,
102
103    #[prop_or_default]
104    pub onkeydown: Callback<KeyboardEvent>,
105
106    #[prop_or_default]
107    pub onscroll: Callback<Event>,
108
109    /// Extra nodes inside the positioned container, e.g. `CodeEditor`'s
110    /// minimum-height sizer.
111    #[prop_or_default]
112    pub children: Children,
113}
114
115#[function_component]
116pub fn MirroredTextarea(props: &MirroredTextareaProps) -> Html {
117    html! {
118        <div class={classes!("mirrored-textarea", props.class.clone())}>
119            // `scrollable` is the viewer's webkit scrollbar styling: the
120            // input layer scrolls wherever a consumer lets it (the
121            // expression editor syncs its `scrollTop` to the mirror and
122            // line numbers), and is inert where it does not (the chat
123            // prompt, whose wrapper scrolls instead).
124            <textarea
125                id={props.id.clone()}
126                ref={props.textarea_ref.clone()}
127                class="mirrored-textarea-input scrollable"
128                spellcheck="false"
129                placeholder={props.placeholder.clone()}
130                disabled={props.disabled}
131                oninput={props.oninput.clone()}
132                onkeydown={props.onkeydown.clone()}
133                onscroll={props.onscroll.clone()}
134            />
135            { props.children.iter().collect::<Html>() }
136            <pre
137                id={props.mirror_id.clone()}
138                ref={props.mirror_ref.clone()}
139                class={classes!(
140                    "mirrored-textarea-mirror",
141                    props.is_empty.then_some("is-empty"),
142                )}
143            >
144                { props.mirror.0.clone() }
145                // A trailing newline opens a line box in the textarea but
146                // not in the `<pre>` (which has no caret to place there),
147                // so the mirror would come up one line short exactly when
148                // the text is about to overflow. The space forces it.
149                { " " }
150            </pre>
151        </div>
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn mirror_never_compares_equal() {
161        let mirror = Mirror(Html::default());
162        assert!(mirror != mirror.clone());
163        assert!(Mirror(Html::default()) != Mirror(Html::default()));
164    }
165}