Skip to main content

rosace_widgets/tree/
list_tile.rs

1use rosace_core::types::{Point, Rect, Size};
2use rosace_layout::Constraints;
3use rosace_render::Color;
4use super::{Widget, LayoutCtx, PaintCtx, BoxedWidget, avail_w};
5
6/// A standard list row: leading widget + title + subtitle + trailing widget.
7pub struct ListTile {
8    pub title: String,
9    pub subtitle: Option<String>,
10    pub leading: Option<BoxedWidget>,
11    pub trailing: Option<BoxedWidget>,
12    pub selected: bool,
13    pub height: f32,
14    pub padding_h: f32,
15    pub title_size: f32,
16    pub subtitle_size: f32,
17    /// `TRANSPARENT` (alpha 0) = use the active theme's `on_surface`.
18    pub title_color: Color,
19    press: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
20    /// `TRANSPARENT` (alpha 0) = use the active theme's `secondary`.
21    pub subtitle_color: Color,
22    pub bg: Color,
23    /// `TRANSPARENT` (alpha 0) = use the active theme's `primary_container`.
24    pub selected_bg: Color,
25    /// `TRANSPARENT` (alpha 0) = use the active theme's `primary`.
26    pub selected_accent: Color,
27    pub divider: bool,
28}
29
30impl ListTile {
31    pub fn new(title: impl Into<String>) -> Self {
32        Self {
33            title: title.into(),
34            subtitle: None,
35            leading: None,
36            trailing: None,
37            selected: false,
38            height: 48.0,
39            padding_h: 14.0,
40            title_size: 11.0,
41            subtitle_size: 9.0,
42            title_color: Color::TRANSPARENT,
43            subtitle_color: Color::TRANSPARENT,
44            bg: Color::rgba(0, 0, 0, 0),
45            selected_bg: Color::TRANSPARENT,
46            selected_accent: Color::TRANSPARENT,
47            divider: true,
48            press: None,
49        }
50    }
51    pub fn subtitle(mut self, s: impl Into<String>) -> Self { self.subtitle = Some(s.into()); self }
52    pub fn leading(mut self, w: impl Widget + 'static) -> Self { self.leading = Some(Box::new(w)); self }
53    pub fn trailing(mut self, w: impl Widget + 'static) -> Self { self.trailing = Some(Box::new(w)); self }
54    pub fn selected(mut self) -> Self { self.selected = true; self }
55    pub fn height(mut self, h: f32) -> Self { self.height = h; self }
56    pub fn no_divider(mut self) -> Self { self.divider = false; self }
57    pub fn title_color(mut self, c: Color) -> Self { self.title_color = c; self }
58    pub fn background(mut self, c: Color) -> Self { self.bg = c; self }
59
60    /// Make the whole tile pressable.
61    pub fn on_press(mut self, f: impl Fn() + Send + Sync + 'static) -> Self {
62        self.press = Some(std::sync::Arc::new(f));
63        self
64    }
65
66    /// Greedy word-wrap, same primitive the `Text` widget uses.
67    fn wrap_subtitle(&self, font: &rosace_render::FontCache, sub: &str, max_w: f32) -> Vec<String> {
68        rosace_text::word_wrap(sub, max_w, |s| font.measure_text(s, self.subtitle_size))
69    }
70}
71
72impl Widget for ListTile {
73    fn layout(&self, ctx: &LayoutCtx) -> Size {
74        let constraints = ctx.constraints;
75        let width = avail_w(constraints);
76        // A subtitle wider than the row (leading/trailing/padding already
77        // eat into it) used to just get clipped by the row's edge instead
78        // of wrapping — `_text_w` sitting unused right below in `paint` was
79        // the tell. Reserve the same leading/trailing budget `paint` uses
80        // so the wrap width and the drawn width agree, then grow the row
81        // past `self.height` when the wrapped subtitle needs more room.
82        let height = match &self.subtitle {
83            Some(sub) => {
84                let lead_w = if self.leading.is_some() { 32.0 + 10.0 } else { 0.0 };
85                let trail_w = if self.trailing.is_some() { 60.0 + self.padding_h } else { self.padding_h };
86                let text_w = (width - self.padding_h - lead_w - trail_w).max(1.0);
87                let sub_lines = self.wrap_subtitle(ctx.font, sub, text_w).len().max(1);
88                let line_h_title = ctx.font.line_height(self.title_size);
89                let line_h_sub = ctx.font.line_height(self.subtitle_size);
90                let content_h = line_h_title + 2.0 + line_h_sub * sub_lines as f32;
91                self.height.max(content_h + 12.0)
92            }
93            None => self.height,
94        };
95        Size { width, height }
96    }
97
98    fn paint(&self, ctx: &mut PaintCtx) {
99        let label = match &self.subtitle {
100            Some(sub) => format!("{}, {}", self.title, sub),
101            None => self.title.clone(),
102        };
103        ctx.semantics(super::Semantics::new(rosace_core::Role::ListItem).label(label));
104        if let Some(f) = &self.press {
105            let f = f.clone();
106            ctx.on_press(move || f());
107            // Hover/press feedback, eased between three levels (D108 Phase
108            // 26 Step 1) — idle, hover (matches the old flat 14-alpha wash),
109            // press (double it).
110            let target = if ctx.pressed() { 1.0 } else if ctx.hovered() { 0.5 } else { 0.0 };
111            let emphasis = ctx.animate_to(target, 0.0);
112            if emphasis > 0.0 {
113                let a = (14.0 * emphasis * 2.0).min(255.0) as u8;
114                ctx.fill_rect(ctx.rect, rosace_render::Color::rgba(255, 255, 255, a));
115            }
116        }
117        let t = &ctx.theme.colors;
118        let title_color = if self.title_color.a == 0 { ctx.tc(t.on_surface) } else { self.title_color };
119        let subtitle_color = if self.subtitle_color.a == 0 { ctx.tc(t.secondary) } else { self.subtitle_color };
120        let selected_bg = if self.selected_bg.a == 0 { ctx.tc(t.primary_container) } else { self.selected_bg };
121        let selected_accent = if self.selected_accent.a == 0 { ctx.tc(t.primary) } else { self.selected_accent };
122        let divider_color = ctx.tc(t.outline);
123
124        let r = ctx.rect;
125        let bg = if self.selected { selected_bg } else { self.bg };
126        if bg.a > 0 { ctx.fill_rect(r, bg); }
127
128        if self.selected {
129            ctx.fill_rect(Rect {
130                origin: r.origin,
131                size: Size { width: 2.5, height: r.size.height },
132            }, selected_accent);
133        }
134
135        let mut x = r.origin.x + self.padding_h;
136
137        // Leading
138        if let Some(lead) = &self.leading {
139            let ls = lead.layout(&ctx.layout_ctx(Constraints::loose(32.0, r.size.height)));
140            let ly = r.origin.y + (r.size.height - ls.height) / 2.0;
141            lead.paint(&mut ctx.child(Rect {
142                origin: Point { x, y: ly },
143                size: ls,
144            }));
145            x += ls.width + 10.0;
146        }
147
148        // Trailing
149        let trailing_w = if let Some(trail) = &self.trailing {
150            let ts = trail.layout(&ctx.layout_ctx(Constraints::loose(60.0, r.size.height)));
151            let ty = r.origin.y + (r.size.height - ts.height) / 2.0;
152            let tx = r.origin.x + r.size.width - self.padding_h - ts.width;
153            trail.paint(&mut ctx.child(Rect { origin: Point { x: tx, y: ty }, size: ts }));
154            ts.width + self.padding_h
155        } else { self.padding_h };
156
157        // Title + subtitle
158        let text_w = r.size.width - x + r.origin.x - trailing_w;
159        let line_h_title = ctx.font.line_height(self.title_size);
160        let line_h_sub = ctx.font.line_height(self.subtitle_size);
161        let sub_lines = self.subtitle.as_ref()
162            .map(|sub| self.wrap_subtitle(ctx.font, sub, text_w.max(1.0)))
163            .unwrap_or_default();
164        let total_text_h = if sub_lines.is_empty() {
165            line_h_title
166        } else {
167            line_h_title + 2.0 + line_h_sub * sub_lines.len() as f32
168        };
169        let text_y = r.origin.y + (r.size.height - total_text_h) / 2.0;
170
171        ctx.draw_text_at(&self.title, Point { x, y: text_y }, title_color, self.title_size);
172
173        for (i, line) in sub_lines.iter().enumerate() {
174            let sub_y = text_y + line_h_title + 2.0 + line_h_sub * i as f32;
175            ctx.draw_text_at(line, Point { x, y: sub_y }, subtitle_color, self.subtitle_size);
176        }
177
178        if self.divider {
179            ctx.fill_rect(Rect {
180                origin: Point { x: r.origin.x + self.padding_h, y: r.origin.y + r.size.height - 1.0 },
181                size: Size { width: r.size.width - self.padding_h, height: 1.0 },
182            }, divider_color);
183        }
184    }
185}
186