Skip to main content

guise/
themepicker.rs

1//! `ThemePicker` — the list of installed themes, and the click that wears one.
2//!
3//! Reads [`ThemeManager`] out of the global and writes the choice straight back
4//! to it, so there is no state to hold and nothing to wire: drop it in a
5//! settings page and it is done. Without a manager installed it draws nothing
6//! rather than guessing at a theme list.
7//!
8//! Each row previews the theme it offers — the swatch is painted from *that*
9//! theme's body, surface, border and primary, which is the only honest way to
10//! show a theme you are not currently wearing.
11
12use std::rc::Rc;
13
14use gpui::prelude::*;
15use gpui::{div, px, App, ElementId, FontWeight, Hsla, IntoElement, SharedString, Window};
16
17use crate::devtools::Probed;
18use crate::icon::{Icon, IconName};
19use crate::theme::{manager, theme, ColorScheme, Size, ThemeChoice, ThemeManager};
20
21/// How the themes are laid out.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum ThemePickerLayout {
24  /// One row per theme: swatch, name, tick. Fits a settings pane.
25  #[default]
26  List,
27  /// Wrapping cards with a large swatch. Fits a theme gallery.
28  Grid,
29}
30
31impl ThemePickerLayout {
32  pub fn label(self) -> &'static str {
33    match self {
34      ThemePickerLayout::List => "list",
35      ThemePickerLayout::Grid => "grid",
36    }
37  }
38}
39
40type ChoiceHandler = Rc<dyn Fn(&ThemeChoice, &mut Window, &mut App)>;
41
42/// A picker over the installed [`ThemeManager`]'s registry.
43///
44/// ```ignore
45/// ThemePicker::new().layout(ThemePickerLayout::Grid)
46/// ```
47#[derive(IntoElement)]
48pub struct ThemePicker {
49  layout: ThemePickerLayout,
50  system_option: bool,
51  on_change: Option<ChoiceHandler>,
52}
53
54impl Default for ThemePicker {
55  fn default() -> Self {
56    ThemePicker::new()
57  }
58}
59
60impl ThemePicker {
61  pub fn new() -> Self {
62    ThemePicker {
63      layout: ThemePickerLayout::default(),
64      system_option: true,
65      on_change: None,
66    }
67  }
68
69  pub fn layout(mut self, layout: ThemePickerLayout) -> Self {
70    self.layout = layout;
71    self
72  }
73
74  /// Offer "System" as the first entry (default true). Turn it off in an app
75  /// that has no business following the OS.
76  pub fn system_option(mut self, system_option: bool) -> Self {
77    self.system_option = system_option;
78    self
79  }
80
81  /// Called after the manager has applied the new choice — for persisting it.
82  pub fn on_change(
83    mut self,
84    handler: impl Fn(&ThemeChoice, &mut Window, &mut App) + 'static,
85  ) -> Self {
86    self.on_change = Some(Rc::new(handler));
87    self
88  }
89}
90
91/// The four colors a swatch is painted from, lifted out of a theme that isn't
92/// the active one.
93#[derive(Clone, Copy)]
94struct Swatch {
95  body: Hsla,
96  surface: Hsla,
97  border: Hsla,
98  primary: Hsla,
99}
100
101impl Swatch {
102  fn of(theme: &crate::theme::Theme) -> Self {
103    Swatch {
104      body: theme.body().hsla(),
105      surface: theme.surface().hsla(),
106      border: theme.border().hsla(),
107      primary: theme.primary().hsla(),
108    }
109  }
110}
111
112/// One offer in the picker, resolved out of the manager before any listener
113/// needs `cx` mutably.
114struct Row {
115  choice: ThemeChoice,
116  name: SharedString,
117  selected: bool,
118  /// One swatch, or the light/dark pair for the System row.
119  swatches: Vec<Swatch>,
120}
121
122/// A theme preview: the window in miniature — body ground, a surface bar, an
123/// accent bar.
124fn swatch(colors: Swatch, width: Option<f32>, height: f32, radius: f32) -> impl IntoElement {
125  let mut el = div()
126    .flex()
127    .flex_col()
128    .justify_end()
129    .gap(px(3.0))
130    .h(px(height))
131    .p(px(4.0))
132    .rounded(px(radius))
133    .bg(colors.body)
134    .child(
135      div()
136        .h(px(4.0))
137        .w_full()
138        .rounded(px(2.0))
139        .bg(colors.surface),
140    )
141    .child(
142      div()
143        .h(px(4.0))
144        .w(px(height * 0.6))
145        .rounded(px(2.0))
146        .bg(colors.primary),
147    );
148  el = match width {
149    Some(width) => el.w(px(width)),
150    None => el.w_full(),
151  };
152  el.border_1().border_color(colors.border)
153}
154
155/// The System row's swatch: the light and dark halves side by side, which reads
156/// as "whichever the OS says" without a word of explanation.
157fn split_swatch(
158  colors: &[Swatch],
159  width: Option<f32>,
160  height: f32,
161  radius: f32,
162) -> impl IntoElement {
163  let mut el = div().flex().gap(px(3.0)).h(px(height));
164  el = match width {
165    Some(width) => el.w(px(width)),
166    None => el.w_full(),
167  };
168  for half in colors {
169    el = el.child(div().flex_1().child(swatch(*half, None, height, radius)));
170  }
171  el
172}
173
174impl RenderOnce for ThemePicker {
175  fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
176    let t = theme(cx);
177    let radius = t.radius(t.default_radius);
178    let text = t.text().hsla();
179    let dimmed = t.dimmed().hsla();
180    let accent = t.primary().hsla();
181    let surface = t.surface().hsla();
182    let surface_hover = t.surface_hover().hsla();
183    let border = t.border().hsla();
184    let selected_bg = crate::style::ColorValue::Named(t.primary_color).soft(t);
185    let gap = t.spacing(Size::Xs);
186
187    let mut rows: Vec<Row> = Vec::new();
188    if let Some(manager) = manager(cx) {
189      let choice = manager.selection().clone();
190      if self.system_option {
191        let light = manager
192          .entries()
193          .iter()
194          .find(|e| e.scheme() == ColorScheme::Light);
195        let dark = manager
196          .entries()
197          .iter()
198          .find(|e| e.scheme() == ColorScheme::Dark);
199        rows.push(Row {
200          selected: choice == ThemeChoice::System,
201          choice: ThemeChoice::System,
202          name: SharedString::new_static("System"),
203          swatches: [light, dark]
204            .into_iter()
205            .flatten()
206            .map(|e| Swatch::of(&e.theme))
207            .collect(),
208        });
209      }
210      for entry in manager.entries() {
211        rows.push(Row {
212          selected: choice == ThemeChoice::Fixed(entry.id.clone()),
213          choice: ThemeChoice::Fixed(entry.id.clone()),
214          name: entry.name.clone(),
215          swatches: vec![Swatch::of(&entry.theme)],
216        });
217      }
218    }
219
220    let grid = self.layout == ThemePickerLayout::Grid;
221    let mut list = div().flex().gap(px(gap));
222    list = if grid {
223      list.flex_wrap()
224    } else {
225      list.flex_col()
226    };
227
228    for row in rows {
229      let handler = self.on_change.clone();
230      let choice = row.choice.clone();
231      let id: ElementId = SharedString::from(format!("theme-picker-{choice}")).into();
232      let preview = if row.swatches.len() > 1 {
233        split_swatch(
234          &row.swatches,
235          (!grid).then_some(46.0),
236          if grid { 54.0 } else { 28.0 },
237          radius,
238        )
239        .into_any_element()
240      } else {
241        let colors = row.swatches.first().copied().unwrap_or(Swatch {
242          body: surface,
243          surface,
244          border,
245          primary: accent,
246        });
247        swatch(
248          colors,
249          (!grid).then_some(46.0),
250          if grid { 54.0 } else { 28.0 },
251          radius,
252        )
253        .into_any_element()
254      };
255
256      let mut item = div()
257        .id(id)
258        .cursor_pointer()
259        .rounded(px(radius))
260        .text_color(text)
261        .on_click(move |_, window, cx| {
262          ThemeManager::set_choice(cx, choice.clone());
263          if let Some(handler) = &handler {
264            handler(&choice, window, cx);
265          }
266        });
267
268      item = if grid {
269        item
270          .flex()
271          .flex_col()
272          .gap(px(6.0))
273          .w(px(132.0))
274          .p(px(8.0))
275          .border_1()
276          .border_color(if row.selected { accent } else { border })
277          .bg(if row.selected { selected_bg } else { surface })
278          .child(preview)
279          .child(
280            div()
281              .text_size(px(12.0))
282              .font_weight(if row.selected {
283                FontWeight::SEMIBOLD
284              } else {
285                FontWeight::NORMAL
286              })
287              .child(row.name),
288          )
289      } else {
290        item
291          .flex()
292          .items_center()
293          .gap(px(10.0))
294          .h(px(40.0))
295          .px(px(8.0))
296          .when(row.selected, |el| el.bg(selected_bg))
297          .when(!row.selected, |el| el.hover(move |s| s.bg(surface_hover)))
298          .child(preview)
299          .child(div().flex_1().text_size(px(14.0)).child(row.name))
300          .child(
301            div()
302              .text_color(if row.selected { accent } else { dimmed })
303              .when(row.selected, |el| {
304                el.child(Icon::new(IconName::Check).size(Size::Sm))
305              }),
306          )
307      };
308      list = list.child(item);
309    }
310
311    list
312      .probe("ThemePicker")
313      .attr("layout", self.layout.label())
314      .attr_if("system", self.system_option)
315  }
316}