Skip to main content

gpui_cn/composer/
effort.rs

1use gpui_kit::{
2    App, ElementId, Entity, IntoElement, ParentElement as _, RenderOnce, StyleRefinement, Styled,
3    Window,
4    assets::IconName,
5    base::{Align, StyledExt as _},
6    div, px, rems,
7};
8
9use crate::{
10    ActiveTheme as _, Button, ButtonSize, DropdownMenu, Icon, MenuEntry, MenuItem,
11    ModelPickerState,
12    collapse::{self, Need},
13};
14
15/// The effort of the chosen model as a small dropdown: the level's name,
16/// such as "Medium", and a chevron. Its menu lists the state's effort
17/// levels, [`EFFORT_LABELS`](crate::EFFORT_LABELS) unless
18/// [`with_effort_levels`](crate::ModelPickerState::with_effort_levels) set
19/// others, with a check on the current one, and choosing a level
20/// sets it on the [`ModelPickerState`], which reports
21/// [`ModelPickerEvent::EffortChanged`](crate::ModelPickerEvent::EffortChanged).
22///
23/// The menu is a [`DropdownMenu`]. It draws nothing while the chosen model
24/// has no effort setting, so a toolbar can always include it; use
25/// [`ModelPickerState::effort`] to know whether it shows.
26///
27/// ```no_run
28/// use gpui_cn::{EffortMenu, ModelPickerState};
29/// use gpui_kit::Entity;
30///
31/// fn effort(models: &Entity<ModelPickerState>) -> EffortMenu {
32///     EffortMenu::new("effort", models)
33/// }
34/// ```
35///
36/// The default icons (the mic, the shield, the file kinds, the git branch) are
37/// not in the default `gpui_kit::assets::Assets`: register
38/// [`ComposerAssets`](crate::ComposerAssets).
39#[derive(IntoElement)]
40#[non_exhaustive]
41pub struct EffortMenu {
42    id: ElementId,
43    state: Entity<ModelPickerState>,
44    icon_only: bool,
45    style: StyleRefinement,
46}
47
48impl EffortMenu {
49    /// An effort menu on `state`, with a stable id.
50    pub fn new(id: impl Into<ElementId>, state: &Entity<ModelPickerState>) -> Self {
51        Self {
52            id: id.into(),
53            state: state.clone(),
54            icon_only: false,
55            style: StyleRefinement::default(),
56        }
57    }
58
59    /// Shows the gauge alone, with "Effort: Medium" in a tooltip and no
60    /// chevron, as a narrow toolbar does.
61    pub fn icon_only(mut self, icon_only: bool) -> Self {
62        self.icon_only = icon_only;
63        self
64    }
65
66    /// What the trigger needs of a row: its width with the label at the
67    /// minimum, and its width as an icon alone. Nothing while the chosen
68    /// model has no effort.
69    pub(crate) fn need(&self, window: &Window, cx: &App) -> Option<Need> {
70        let level = self.state.read(cx).effort()?;
71        let theme = cx.theme();
72        let padding = theme.metrics.control_padding_sm;
73        let rem = window.rem_size();
74        let gap = rems(0.375).to_pixels(rem);
75        let label = collapse::text_width(
76            &self.state.read(cx).effort_levels()[usize::from(level)],
77            window,
78        )
79        .min(collapse::min_label_width(window, collapse::MIN_LABEL_CHARS));
80        Some(Need::flexible(
81            padding * 2. + rems(1.).to_pixels(rem) + gap + label + gap + rems(0.75).to_pixels(rem),
82            Some(theme.metrics.control_sm),
83        ))
84    }
85}
86
87impl Styled for EffortMenu {
88    fn style(&mut self) -> &mut StyleRefinement {
89        &mut self.style
90    }
91}
92
93impl RenderOnce for EffortMenu {
94    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
95        let need = self.need(window, cx);
96        let (level, menu) = {
97            let state = self.state.read(cx);
98            (state.effort(), state.effort_menu_state().clone())
99        };
100        let Some(level) = level else {
101            return gpui_kit::div().into_any_element();
102        };
103        let muted = cx.theme().muted_foreground();
104        let word = self.state.read(cx).effort_levels()[usize::from(level)].clone();
105        let min = match (self.icon_only, need.and_then(|need| need.icon_only)) {
106            (true, Some(icon_only)) => icon_only,
107            _ => need.map_or(px(0.), |need| need.min),
108        };
109        let button = Button::new(ElementId::NamedChild(
110            self.id.clone().into(),
111            "trigger".into(),
112        ))
113        .ghost()
114        .size(ButtonSize::Sm)
115        .accessibility_label("Effort")
116        .min_w(min)
117        .text_color(muted)
118        .icon(Icon::from(IconName::Gauge));
119        let trigger = if self.icon_only {
120            button.tooltip(format!("Effort: {word}"))
121        } else {
122            let keep = collapse::text_width(&word, window)
123                .min(collapse::min_label_width(window, collapse::MIN_LABEL_CHARS));
124            button
125                .child(
126                    div()
127                        .min_w(keep)
128                        .flex_shrink(1.)
129                        .overflow_hidden()
130                        .whitespace_nowrap()
131                        .text_ellipsis()
132                        .child(word),
133                )
134                .flex_shrink(1.)
135                .trailing_icon(Icon::from(IconName::ChevronDown).size_3())
136        };
137        let state = self.state.clone();
138        DropdownMenu::new(self.id, &menu)
139            .align(Align::End)
140            .refine_style(&self.style)
141            .trigger(trigger)
142            .items(move |_, cx| {
143                let current = state.read(cx).effort();
144                state
145                    .read(cx)
146                    .effort_levels()
147                    .iter()
148                    .enumerate()
149                    .map(|(index, label)| {
150                        MenuEntry::from(
151                            MenuItem::new(index.to_string(), label.clone())
152                                .checked(current == Some(index as u8)),
153                        )
154                    })
155                    .collect()
156            })
157            .into_any_element()
158    }
159}