feather_ui/component/
text.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2025 Fundament Research Institute <https://fundament.institute>
3
4use crate::color::sRGB;
5use crate::component::{EventRouter, StateMachine};
6use crate::graphics::point_to_pixel;
7use crate::layout::{self, Layout, leaf};
8use crate::{SourceID, graphics};
9use cosmic_text::{LineIter, Metrics};
10use derive_where::derive_where;
11use std::cell::RefCell;
12use std::convert::Infallible;
13use std::rc::Rc;
14use std::sync::Arc;
15
16#[derive(Clone)]
17pub struct TextState {
18    buffer: Rc<RefCell<cosmic_text::Buffer>>,
19    text: String,
20    align: Option<cosmic_text::Align>,
21}
22
23impl EventRouter for TextState {
24    type Input = Infallible;
25    type Output = Infallible;
26}
27
28impl PartialEq for TextState {
29    fn eq(&self, other: &Self) -> bool {
30        Rc::ptr_eq(&self.buffer, &other.buffer)
31            && self.text == other.text
32            && self.align == other.align
33    }
34}
35
36#[derive_where(Clone)]
37pub struct Text<T> {
38    pub id: Arc<SourceID>,
39    pub props: Rc<T>,
40    pub font_size: f32,
41    pub line_height: f32,
42    pub text: String,
43    pub font: cosmic_text::FamilyOwned,
44    pub color: sRGB,
45    pub weight: cosmic_text::Weight,
46    pub style: cosmic_text::Style,
47    pub wrap: cosmic_text::Wrap,
48    pub align: Option<cosmic_text::Align>, /* Alignment overrides whether text is LTR or RTL so
49                                            * we usually only want to set it if we're centering
50                                            * text */
51}
52
53impl<T: leaf::Padded + 'static> Text<T> {
54    pub fn new(
55        id: Arc<SourceID>,
56        props: T,
57        font_size: f32,
58        line_height: f32,
59        text: String,
60        font: cosmic_text::FamilyOwned,
61        color: sRGB,
62        weight: cosmic_text::Weight,
63        style: cosmic_text::Style,
64        wrap: cosmic_text::Wrap,
65        align: Option<cosmic_text::Align>,
66    ) -> Self {
67        Self {
68            id,
69            props: props.into(),
70            font_size,
71            line_height,
72            text,
73            font,
74            color,
75            weight,
76            style,
77            wrap,
78            align,
79        }
80    }
81}
82
83impl<T: leaf::Padded + 'static> crate::StateMachineChild for Text<T> {
84    fn id(&self) -> Arc<SourceID> {
85        self.id.clone()
86    }
87
88    fn init(
89        &self,
90        _: &std::sync::Weak<graphics::Driver>,
91    ) -> Result<Box<dyn super::StateMachineWrapper>, crate::Error> {
92        let statemachine: StateMachine<TextState, 0> = StateMachine {
93            state: TextState {
94                buffer: Rc::new(RefCell::new(cosmic_text::Buffer::new_empty(Metrics::new(
95                    point_to_pixel(self.font_size, 1.0),
96                    point_to_pixel(self.line_height, 1.0),
97                )))),
98                text: String::new(),
99                align: None,
100            },
101            input_mask: 0,
102            output: [],
103            changed: true,
104        };
105        Ok(Box::new(statemachine))
106    }
107}
108
109impl<T: Default + leaf::Padded + 'static> Default for Text<T> {
110    fn default() -> Self {
111        Self {
112            id: Default::default(),
113            props: Default::default(),
114            font_size: Default::default(),
115            line_height: Default::default(),
116            text: Default::default(),
117            font: cosmic_text::FamilyOwned::SansSerif,
118            color: sRGB::new(1.0, 1.0, 1.0, 1.0),
119            weight: Default::default(),
120            style: Default::default(),
121            wrap: cosmic_text::Wrap::None,
122            align: None,
123        }
124    }
125}
126
127fn buffer_eq(s: &str, b: &cosmic_text::Buffer) -> bool {
128    let mut ranges = LineIter::new(s);
129    let mut lines = b.lines.iter();
130    loop {
131        match (lines.next(), ranges.next()) {
132            (Some(line), Some((r, _))) => {
133                if &s[r] != line.text() {
134                    return false;
135                }
136            }
137            (None, None) => return true,
138            _ => return false,
139        }
140    }
141}
142
143impl<T: leaf::Padded + 'static> super::Component for Text<T>
144where
145    for<'a> &'a T: Into<&'a (dyn leaf::Padded + 'static)>,
146{
147    type Props = T;
148
149    fn layout(
150        &self,
151        manager: &mut crate::StateManager,
152        driver: &graphics::Driver,
153        window: &Arc<SourceID>,
154    ) -> Box<dyn Layout<T>> {
155        let dpi = manager
156            .get::<super::window::WindowStateMachine>(window)
157            .map(|x| x.state.dpi)
158            .unwrap_or(crate::BASE_DPI);
159        let mut font_system = driver.font_system.write();
160
161        let metrics = cosmic_text::Metrics::new(
162            point_to_pixel(self.font_size, dpi.width),
163            point_to_pixel(self.line_height, dpi.height),
164        );
165
166        let textstate = manager
167            .get_mut::<StateMachine<TextState, 0>>(&self.id)
168            .unwrap();
169        let textstate = &mut textstate.state;
170        textstate
171            .buffer
172            .borrow_mut()
173            .set_metrics(&mut font_system, metrics);
174        textstate
175            .buffer
176            .borrow_mut()
177            .set_wrap(&mut font_system, self.wrap);
178
179        if self.align != textstate.align || !buffer_eq(&self.text, &textstate.buffer.borrow()) {
180            textstate.buffer.borrow_mut().set_text(
181                &mut font_system,
182                &self.text,
183                &cosmic_text::Attrs::new()
184                    .family(self.font.as_family())
185                    .color(self.color.into())
186                    .weight(self.weight)
187                    .style(self.style),
188                cosmic_text::Shaping::Advanced,
189                self.align,
190            );
191
192            textstate.text = self.text.clone();
193            textstate.align = self.align;
194        }
195
196        let render = Rc::new(crate::render::text::Instance {
197            text_buffer: textstate.buffer.clone(),
198            padding: self.props.padding().as_perimeter(dpi).into(),
199        });
200
201        Box::new(layout::text::Node::<T> {
202            props: self.props.clone(),
203            id: Arc::downgrade(&self.id),
204            buffer: textstate.buffer.clone(),
205            renderable: render.clone(),
206            realign: self.align.is_some_and(|x| x != cosmic_text::Align::Left),
207        })
208    }
209}