1use rosace_core::types::{Point, Rect, Size};
2use rosace_layout::Constraints;
3use rosace_render::Color;
4use super::{Widget, LayoutCtx, PaintCtx, BoxedWidget, avail_w};
5
6pub 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 pub title_color: Color,
19 press: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
20 pub subtitle_color: Color,
22 pub bg: Color,
23 pub selected_bg: Color,
25 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 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 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 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 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 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 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 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