1use iced::widget::text::LineHeight;
9use iced::widget::{Row, container, row, text};
10use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme};
11
12use crate::icon::{self, Glyph, themed};
13use crate::theme::{Tokens, fade, radius, space, text_size};
14
15pub const ROW_PADDING: Padding = Padding {
17 top: 6.0,
18 bottom: 6.0,
19 left: space::SM,
20 right: space::SM,
21};
22pub const LINE_HEIGHT: f32 = 20.0;
24pub const LABEL_LINE_HEIGHT: f32 = 16.0;
26pub const ICON_SIZE: f32 = 16.0;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
31pub enum RowStatus {
32 #[default]
33 Idle,
34 Highlighted,
36 Disabled,
37}
38
39impl RowStatus {
40 pub const ALL: [RowStatus; 3] = [RowStatus::Idle, RowStatus::Highlighted, RowStatus::Disabled];
41}
42
43#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct RowStyle {
47 pub background: Option<Color>,
48 pub text: Color,
49 pub icon: Color,
51 pub hint: Color,
53}
54
55pub fn row_style(tokens: &Tokens, status: RowStatus, destructive: bool) -> RowStyle {
58 match (status, destructive) {
59 (RowStatus::Disabled, _) => RowStyle {
60 background: None,
61 text: tokens.muted_foreground,
62 icon: tokens.muted_foreground,
63 hint: fade(tokens.muted_foreground, 0.5),
64 },
65 (RowStatus::Idle, false) => RowStyle {
66 background: None,
67 text: tokens.foreground,
68 icon: tokens.muted_foreground,
69 hint: tokens.muted_foreground,
70 },
71 (RowStatus::Highlighted, false) => RowStyle {
72 background: Some(tokens.accent),
73 text: tokens.accent_foreground,
74 icon: tokens.muted_foreground,
75 hint: tokens.muted_foreground,
76 },
77 (RowStatus::Idle, true) => RowStyle {
78 background: None,
79 text: tokens.destructive,
80 icon: tokens.destructive,
81 hint: tokens.muted_foreground,
82 },
83 (RowStatus::Highlighted, true) => RowStyle {
84 background: Some(fade(
85 tokens.destructive,
86 if tokens.is_dark { 0.2 } else { 0.1 },
87 )),
88 text: tokens.destructive,
89 icon: tokens.destructive,
90 hint: tokens.muted_foreground,
91 },
92 }
93}
94
95pub fn row_container_style(style: RowStyle) -> container::Style {
97 container::Style {
98 background: style.background.map(Background::Color),
99 text_color: Some(style.text),
100 border: Border {
101 radius: radius::SM.into(),
102 ..Border::default()
103 },
104 ..container::Style::default()
105 }
106}
107
108pub fn label_style(tokens: &Tokens) -> text::Style {
110 text::Style {
111 color: Some(tokens.muted_foreground),
112 }
113}
114
115pub fn separator_style(tokens: &Tokens) -> container::Style {
117 container::Style {
118 background: Some(Background::Color(tokens.border)),
119 ..container::Style::default()
120 }
121}
122
123#[cfg(any(feature = "command", feature = "dropdown-menu"))]
126const INDICATOR_WIDTH: f32 = ICON_SIZE + space::SM;
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub(crate) enum Leading {
133 None,
135 #[cfg(feature = "dropdown-menu")]
137 Empty,
138 #[cfg(feature = "dropdown-menu")]
139 Check,
140 #[cfg(feature = "dropdown-menu")]
141 Dot,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub(crate) enum Trailing {
147 None,
148 #[cfg(any(feature = "combobox", feature = "select"))]
150 Check,
151 #[cfg(feature = "dropdown-menu")]
153 Chevron,
154}
155
156pub(crate) struct Parts<'a> {
158 pub label: &'a str,
159 pub icon: Option<Glyph>,
160 pub hint: Option<&'a str>,
161 pub leading: Leading,
162 pub trailing: Trailing,
163}
164
165impl<'a> Parts<'a> {
166 pub fn label(label: &'a str) -> Self {
167 Self {
168 label,
169 icon: None,
170 hint: None,
171 leading: Leading::None,
172 trailing: Trailing::None,
173 }
174 }
175}
176
177fn glyph<'a, Message: 'a>(
179 glyph: Glyph,
180 colours: impl Fn(&Theme) -> RowStyle + 'a,
181 pick: fn(&RowStyle) -> Color,
182 opacity: f32,
183) -> Element<'a, Message> {
184 themed(glyph, ICON_SIZE, opacity, move |theme| {
185 pick(&colours(theme))
186 })
187 .into()
188}
189
190pub(crate) fn content<'a, Message: 'a>(
193 parts: Parts<'_>,
194 status: RowStatus,
195 destructive: bool,
196) -> Row<'a, Message> {
197 let colours = move |theme: &Theme| row_style(&Tokens::of(theme), status, destructive);
198 let opacity = icon::opacity(status != RowStatus::Disabled);
199
200 let mut content = row![].spacing(space::SM).align_y(Alignment::Center);
201 let icon = parts
202 .icon
203 .map(|icon| glyph(icon, colours, |style| style.icon, opacity));
204 let leading: Option<Element<'a, Message>> = match parts.leading {
205 Leading::None => icon,
206 #[cfg(feature = "dropdown-menu")]
207 Leading::Empty => Some(icon.unwrap_or_else(|| iced::widget::space().into())),
208 #[cfg(feature = "dropdown-menu")]
209 Leading::Check => Some(glyph(
210 crate::lucide!(Check),
211 colours,
212 |style| style.text,
213 opacity,
214 )),
215 #[cfg(feature = "dropdown-menu")]
216 Leading::Dot => Some(dot(colours)),
217 };
218 if let Some(leading) = leading {
219 content = content.push(container(leading).center_x(ICON_SIZE).center_y(ICON_SIZE));
220 }
221 content = content.push(
222 text(parts.label.to_owned())
223 .size(text_size::SM)
224 .line_height(LineHeight::Absolute(LINE_HEIGHT.into()))
225 .width(Length::Fill),
226 );
227 if let Some(hint) = parts.hint {
228 content = content.push(
229 text(hint.to_owned())
230 .size(text_size::XS)
231 .style(move |theme| text::Style {
232 color: Some(colours(theme).hint),
233 }),
234 );
235 }
236 let trailing = match parts.trailing {
237 Trailing::None => None,
238 #[cfg(any(feature = "combobox", feature = "select"))]
239 Trailing::Check => Some(crate::lucide!(Check)),
240 #[cfg(feature = "dropdown-menu")]
241 Trailing::Chevron => Some(crate::lucide!(ChevronRight)),
242 };
243 if let Some(trailing) = trailing {
244 content = content.push(glyph(trailing, colours, |style| style.text, opacity));
245 }
246 content
247}
248
249#[cfg(feature = "dropdown-menu")]
251fn dot<'a, Message: 'a>(colours: impl Fn(&Theme) -> RowStyle + 'a) -> Element<'a, Message> {
252 container(iced::widget::space())
253 .width(8)
254 .height(8)
255 .style(move |theme| container::Style {
256 background: Some(Background::Color(colours(theme).text)),
257 border: Border {
258 radius: radius::FULL.into(),
259 ..Border::default()
260 },
261 ..container::Style::default()
262 })
263 .into()
264}
265
266#[cfg(any(feature = "combobox", feature = "command", feature = "dropdown-menu"))]
270pub(crate) fn item<'a, Message: Clone + 'a>(
271 parts: Parts<'_>,
272 status: RowStatus,
273 destructive: bool,
274 messages: Option<(Message, Message)>,
275) -> Element<'a, Message> {
276 let body = container(content(parts, status, destructive))
277 .padding(ROW_PADDING)
278 .width(Length::Fill)
279 .style(move |theme| {
280 row_container_style(row_style(&Tokens::of(theme), status, destructive))
281 });
282 let Some((highlight, activate)) = messages else {
283 return inset(body);
284 };
285 inset(
286 iced::widget::mouse_area(body)
287 .on_enter(highlight)
288 .on_press(activate)
289 .interaction(iced::mouse::Interaction::Pointer),
290 )
291}
292
293#[cfg(any(feature = "command", feature = "dropdown-menu"))]
296pub(crate) fn group_label<'a, Message: 'a>(label: &str, indented: bool) -> Element<'a, Message> {
297 let left = ROW_PADDING.left + if indented { INDICATOR_WIDTH } else { 0.0 };
298 inset(
299 container(
300 text(label.to_owned())
301 .size(text_size::XS)
302 .line_height(LineHeight::Absolute(LABEL_LINE_HEIGHT.into()))
303 .font(crate::theme::semibold())
304 .style(|theme| label_style(&Tokens::of(theme))),
305 )
306 .padding(ROW_PADDING.left(left))
307 .width(Length::Fill),
308 )
309}
310
311#[cfg(any(feature = "command", feature = "dropdown-menu"))]
313pub(crate) fn separator<'a, Message: 'a>() -> Element<'a, Message> {
314 container(
315 container(iced::widget::space())
316 .width(Length::Fill)
317 .height(1)
318 .style(|theme| separator_style(&Tokens::of(theme))),
319 )
320 .padding([space::XS, 0.0])
321 .into()
322}
323
324#[cfg(any(feature = "combobox", feature = "command"))]
326pub(crate) fn empty<'a, Message: 'a>(message: &str) -> Element<'a, Message> {
327 container(
328 text(message.to_owned())
329 .size(text_size::SM)
330 .style(|theme| label_style(&Tokens::of(theme))),
331 )
332 .padding([space::XL, space::SM])
333 .center_x(Length::Fill)
334 .into()
335}
336
337pub(crate) fn inset<'a, Message: 'a>(
340 content: impl Into<Element<'a, Message>>,
341) -> Element<'a, Message> {
342 container(content)
343 .padding([0.0, space::XS])
344 .width(Length::Fill)
345 .into()
346}
347
348#[cfg(any(feature = "combobox", feature = "dropdown-menu", feature = "select"))]
350pub(crate) fn surface<'a, Message: 'a>(
351 rows: impl Into<Element<'a, Message>>,
352) -> container::Container<'a, Message> {
353 container(rows)
354 .padding([space::XS, 0.0])
355 .style(|theme| crate::overlay::anchored::surface_style(&Tokens::of(theme)))
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use crate::theme::{dark, light};
362
363 #[test]
364 fn row_metrics_add_up_to_32_pixels() {
365 assert_eq!(ROW_PADDING.top + LINE_HEIGHT + ROW_PADDING.bottom, 32.0);
366 assert_eq!(ROW_PADDING.left, 8.0);
367 assert_eq!(ICON_SIZE, 16.0);
368 }
369
370 #[test]
371 fn highlighted_rows_use_the_accent_in_both_themes() {
372 for theme in [light(), dark()] {
373 let tokens = Tokens::of(&theme);
374 let idle = row_style(&tokens, RowStatus::Idle, false);
375 let highlighted = row_style(&tokens, RowStatus::Highlighted, false);
376 assert_eq!(idle.background, None);
377 assert_eq!(idle.text, tokens.foreground);
378 assert_eq!(highlighted.background, Some(tokens.accent));
379 assert_eq!(highlighted.text, tokens.accent_foreground);
380 assert_eq!(idle.icon, tokens.muted_foreground);
381 assert_eq!(idle.hint, tokens.muted_foreground);
382 }
383 }
384
385 #[test]
386 fn disabled_rows_are_muted_with_opaque_faded_icons() {
387 for theme in [light(), dark()] {
388 let tokens = Tokens::of(&theme);
389 for destructive in [false, true] {
390 let style = row_style(&tokens, RowStatus::Disabled, destructive);
391 assert_eq!(style.background, None);
392 assert_eq!(style.text, tokens.muted_foreground);
393 assert_eq!(style.icon.a, tokens.muted_foreground.a, "icons stay opaque");
394 assert!(style.hint.a < tokens.muted_foreground.a);
395 }
396 }
397 }
398
399 #[test]
400 fn destructive_rows_keep_their_colour_and_highlight_with_a_tint() {
401 for theme in [light(), dark()] {
402 let tokens = Tokens::of(&theme);
403 for status in [RowStatus::Idle, RowStatus::Highlighted] {
404 let style = row_style(&tokens, status, true);
405 assert_eq!(style.text, tokens.destructive);
406 assert_eq!(style.icon, tokens.destructive);
407 }
408 let tint = row_style(&tokens, RowStatus::Highlighted, true).background;
409 let expected = if tokens.is_dark { 0.2 } else { 0.1 };
410 assert!(tint.is_some_and(|colour| (colour.a - expected).abs() < 1e-6));
411 }
412 }
413
414 #[test]
415 fn every_status_resolves_distinctly() {
416 for theme in [light(), dark()] {
417 let tokens = Tokens::of(&theme);
418 for destructive in [false, true] {
419 let styles = RowStatus::ALL.map(|status| row_style(&tokens, status, destructive));
420 for (i, a) in styles.iter().enumerate() {
421 assert!(styles[i + 1..].iter().all(|b| a != b));
422 }
423 }
424 }
425 }
426
427 #[test]
428 fn row_containers_carry_the_background_text_and_small_radius() {
429 let tokens = Tokens::of(&light());
430 let style = row_container_style(row_style(&tokens, RowStatus::Highlighted, false));
431 assert_eq!(style.background, Some(Background::Color(tokens.accent)));
432 assert_eq!(style.text_color, Some(tokens.accent_foreground));
433 assert_eq!(style.border.radius, radius::SM.into());
434 let idle = row_container_style(row_style(&tokens, RowStatus::Idle, false));
435 assert_eq!(idle.background, None);
436 }
437
438 #[test]
439 fn label_and_separator_styles() {
440 for theme in [light(), dark()] {
441 let tokens = Tokens::of(&theme);
442 assert_eq!(label_style(&tokens).color, Some(tokens.muted_foreground));
443 assert_eq!(
444 separator_style(&tokens).background,
445 Some(Background::Color(tokens.border))
446 );
447 }
448 }
449}