egui_macview/controls/
segmented.rs1use crate::theme::MacTheme;
7use egui::{
8 CornerRadius, Key, Modifiers, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2,
9};
10
11pub struct MacSegmentedControl<'a> {
36 options: &'a [&'a str],
37 selected_idx: usize,
38 enabled: bool,
39 theme: Option<&'a MacTheme>,
40 height: Option<f32>,
41}
42
43impl<'a> MacSegmentedControl<'a> {
44 pub fn new(options: &'a [&'a str], selected_idx: usize) -> Self {
46 Self {
47 options,
48 selected_idx,
49 enabled: true,
50 theme: None,
51 height: None,
52 }
53 }
54
55 pub fn enabled(mut self, enabled: bool) -> Self {
57 self.enabled = enabled;
58 self
59 }
60
61 pub fn theme(mut self, theme: &'a MacTheme) -> Self {
63 self.theme = Some(theme);
64 self
65 }
66
67 pub fn height(mut self, height: f32) -> Self {
69 self.height = Some(height);
70 self
71 }
72
73 pub fn show(self, ui: &mut Ui, mut on_select: impl FnMut(usize)) -> Response {
75 let fallback_theme;
76 let theme = match self.theme {
77 Some(t) => t,
78 None => {
79 fallback_theme = MacTheme::from_ctx(ui.ctx());
80 &fallback_theme
81 }
82 };
83
84 let segmented_tokens = theme.segmented();
85 let metrics = &segmented_tokens.regular;
86 let height = self.height.unwrap_or_else(|| metrics.height.points());
87 let corner_radius = metrics.corner_radius.points();
88 let indicator_radius = metrics.indicator_radius.points();
89 let border_width = segmented_tokens.border_width.points();
90 let indicator_inset = segmented_tokens.indicator_inset.points();
91
92 let font_id = egui::FontId::proportional(segmented_tokens.font_size.points());
93 let padding_h = segmented_tokens.padding_horizontal.points();
94 let min_item_w = segmented_tokens.item_width.points();
95
96 let mut max_content_w = min_item_w;
97 for opt in self.options.iter() {
98 let galley =
99 ui.painter()
100 .layout_no_wrap((*opt).to_string(), font_id.clone(), theme.label());
101 let padded_w = galley.size().x + padding_h * 2.0;
102 if padded_w > max_content_w {
103 max_content_w = padded_w;
104 }
105 }
106 let segment_width = max_content_w;
107 let count = self.options.len().max(1);
108 let total_width = segment_width * count as f32;
109
110 let sense = if self.enabled {
111 Sense::click()
112 } else {
113 Sense::hover()
114 };
115 let (rect, mut response) = ui.allocate_exact_size(Vec2::new(total_width, height), sense);
116 let mut active_idx = self.selected_idx.min(count.saturating_sub(1));
117
118 if self.enabled && response.has_focus() {
119 if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowRight))
120 && active_idx + 1 < count
121 {
122 active_idx += 1;
123 on_select(active_idx);
124 response.mark_changed();
125 } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowLeft))
126 && active_idx > 0
127 {
128 active_idx -= 1;
129 on_select(active_idx);
130 response.mark_changed();
131 }
132 }
133
134 let alpha_factor = if self.enabled {
135 1.0
136 } else {
137 theme.disabled_opacity()
138 };
139 let painter = ui.painter_at(rect);
140
141 painter.rect(
142 rect,
143 CornerRadius::same(corner_radius as u8),
144 theme.control_background().gamma_multiply(alpha_factor),
145 Stroke::new(border_width, theme.separator().gamma_multiply(alpha_factor)),
146 StrokeKind::Inside,
147 );
148
149 let spring = theme.motion().swiftui_snappy.to_spring();
151 let now = ui.input(|i| i.time);
152 let spring_key = response.id.with("seg_caspring");
153 let target_idx = active_idx as f32;
154 let (prev_target, start_val, start_time) = ui.data(|d| {
155 d.get_temp::<(f32, f32, f64)>(spring_key)
156 .unwrap_or((target_idx, target_idx, now))
157 });
158
159 let anim_idx = if (prev_target - target_idx).abs() > f32::EPSILON {
160 let elapsed = (now - start_time).max(0.0) as f32;
161 let cur = start_val + (prev_target - start_val) * spring.position_at(elapsed);
162 ui.data_mut(|d| d.insert_temp(spring_key, (target_idx, cur, now)));
163 ui.ctx().request_repaint();
164 cur
165 } else {
166 let elapsed = (now - start_time).max(0.0) as f32;
167 if elapsed < spring.settling_duration() {
168 ui.ctx().request_repaint();
169 start_val + (target_idx - start_val) * spring.position_at(elapsed)
170 } else {
171 target_idx
172 }
173 };
174
175 let pill_x = rect.min.x + anim_idx * segment_width;
176 let animated_seg_rect = Rect::from_min_size(
177 Pos2::new(pill_x, rect.min.y),
178 Vec2::new(segment_width, height),
179 );
180 let pill_rect = animated_seg_rect.shrink(indicator_inset);
181
182 painter.rect_filled(
183 pill_rect.translate(Vec2::new(0.0, border_width)),
184 CornerRadius::same(indicator_radius as u8),
185 theme.drop_shadow().gamma_multiply(alpha_factor),
186 );
187
188 painter.rect(
189 pill_rect,
190 CornerRadius::same(indicator_radius as u8),
191 theme.control().gamma_multiply(alpha_factor),
192 Stroke::new(border_width, theme.separator().gamma_multiply(alpha_factor)),
193 StrokeKind::Inside,
194 );
195
196 for (i, opt) in self.options.iter().enumerate() {
197 let seg_rect = Rect::from_min_size(
198 Pos2::new(rect.min.x + i as f32 * segment_width, rect.min.y),
199 Vec2::new(segment_width, height),
200 );
201
202 if self.enabled && response.clicked() {
203 if let Some(pos) = ui.input(|inp| inp.pointer.interact_pos()) {
204 if seg_rect.contains(pos) {
205 response.request_focus();
206 on_select(i);
207 response.mark_changed();
208 }
209 }
210 }
211
212 let is_hovered = self.enabled && ui.rect_contains_pointer(seg_rect);
213 let text_color = if i == active_idx || is_hovered {
214 theme.label().gamma_multiply(alpha_factor)
215 } else {
216 theme.secondary_label().gamma_multiply(alpha_factor)
217 };
218
219 painter.text(
220 seg_rect.center(),
221 egui::Align2::CENTER_CENTER,
222 *opt,
223 font_id.clone(),
224 text_color,
225 );
226 }
227
228 response
229 }
230}