1use std::rc::Rc;
10
11use gpui::{
12 AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
13 StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
14};
15use gpui_kit_semantics::{NodeSpec, Role, Semantic};
16use gpui_kit_theme::{ActiveTheme, Radius, Space, TextTone, Theme, TypeScale};
17
18use crate::foundation::{
19 Disableable, FocusRing, Ident, Pressable, StyledExt, text as foundation_text,
20};
21
22const RAIL_WIDTH: f32 = 20.0;
24const MARKER_SIZE: f32 = 10.0;
26
27type JumpHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct HistoryEntry {
32 id: SharedString,
33 label: SharedString,
34 description: Option<SharedString>,
35 time: Option<SharedString>,
36 source: Option<SharedString>,
37 unavailable: Option<SharedString>,
38}
39
40impl HistoryEntry {
41 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
43 Self {
44 id: id.into(),
45 label: label.into(),
46 description: None,
47 time: None,
48 source: None,
49 unavailable: None,
50 }
51 }
52
53 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
54 self.description = Some(description.into());
55 self
56 }
57
58 pub fn time(mut self, time: impl Into<SharedString>) -> Self {
60 self.time = Some(time.into());
61 self
62 }
63
64 pub fn source(mut self, source: impl Into<SharedString>) -> Self {
66 self.source = Some(source.into());
67 self
68 }
69
70 pub fn unavailable(mut self, reason: impl Into<SharedString>) -> Self {
72 self.unavailable = Some(reason.into());
73 self
74 }
75
76 pub fn id(&self) -> &SharedString {
77 &self.id
78 }
79
80 pub fn label(&self) -> &SharedString {
81 &self.label
82 }
83}
84
85#[derive(IntoElement)]
87pub struct UndoHistory {
88 ident: Ident,
89 label: SharedString,
90 entries: Vec<HistoryEntry>,
91 current: Option<SharedString>,
92 disabled: bool,
93 on_jump: Option<JumpHandler>,
94}
95
96impl std::fmt::Debug for UndoHistory {
97 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 formatter
99 .debug_struct("UndoHistory")
100 .field("ident", &self.ident)
101 .field("entries", &self.entries.len())
102 .field("current", &self.current)
103 .field("disabled", &self.disabled)
104 .field("has_handler", &self.on_jump.is_some())
105 .finish()
106 }
107}
108
109impl UndoHistory {
110 pub fn new(ident: impl Into<Ident>, label: impl Into<SharedString>) -> Self {
111 Self {
112 ident: ident.into(),
113 label: label.into(),
114 entries: Vec::new(),
115 current: None,
116 disabled: false,
117 on_jump: None,
118 }
119 }
120
121 pub fn entry(mut self, entry: HistoryEntry) -> Self {
122 self.entries.push(entry);
123 self
124 }
125
126 pub fn entries(mut self, entries: impl IntoIterator<Item = HistoryEntry>) -> Self {
127 self.entries.extend(entries);
128 self
129 }
130
131 pub fn current(mut self, id: impl Into<SharedString>) -> Self {
133 self.current = Some(id.into());
134 self
135 }
136
137 pub fn on_jump(
138 mut self,
139 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
140 ) -> Self {
141 self.on_jump = Some(Rc::new(handler));
142 self
143 }
144}
145
146impl Disableable for UndoHistory {
147 fn disabled(mut self, disabled: bool) -> Self {
148 self.disabled = disabled;
149 self
150 }
151}
152
153impl RenderOnce for UndoHistory {
154 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
155 let theme = cx.theme().clone();
156 let count = self.entries.len();
157 let entries = Rc::new(self.entries);
158 let current = self.current;
159 let handler = self.on_jump.filter(|_| !self.disabled);
160 let mut list = div().column().w_full();
161
162 for (index, entry) in entries.iter().enumerate() {
163 list = list.child(entry_element(
164 &self.ident,
165 &theme,
166 entry,
167 index + 1 < count,
168 current.as_ref(),
169 self.disabled,
170 handler.as_ref(),
171 cx,
172 ));
173 }
174
175 if let Some(handler) = handler {
176 let keyboard = Rc::clone(&handler);
177 let entries = Rc::clone(&entries);
178 list = list.on_key_down(move |event, window, cx| {
179 let current_index = current
180 .as_ref()
181 .and_then(|current| entries.iter().position(|entry| &entry.id == current));
182 let target = match event.keystroke.key.as_str() {
183 "up" => jump_target(
184 &entries,
185 current_index.and_then(|index| index.checked_sub(1)),
186 -1,
187 current.as_ref(),
188 ),
189 "down" => jump_target(
190 &entries,
191 current_index.map(|index| index + 1),
192 1,
193 current.as_ref(),
194 ),
195 "home" => jump_target(&entries, Some(0), 1, current.as_ref()),
196 "end" => {
197 jump_target(&entries, entries.len().checked_sub(1), -1, current.as_ref())
198 }
199 _ => None,
200 };
201 if let Some(target) = target {
202 keyboard(target, window, cx);
203 cx.stop_propagation();
204 }
205 });
206 }
207
208 list.semantic_in(
209 cx,
210 NodeSpec::new(self.ident.semantic_id(), Role::List)
211 .text(self.label)
212 .value(count.to_string()),
213 )
214 }
215}
216
217#[allow(clippy::too_many_arguments)]
218fn entry_element(
219 history: &Ident,
220 theme: &Theme,
221 entry: &HistoryEntry,
222 continues: bool,
223 current: Option<&SharedString>,
224 history_disabled: bool,
225 handler: Option<&JumpHandler>,
226 cx: &mut App,
227) -> AnyElement {
228 let ident = history.child(entry.id.as_ref());
229 let selected = current == Some(&entry.id);
230 let disabled = history_disabled || entry.unavailable.is_some();
231 let actionable = !disabled && !selected && handler.is_some();
232 let marker_color = if selected {
233 theme.colors.accent
234 } else {
235 theme.colors.hairline
236 };
237
238 let rail = div()
239 .w(px(RAIL_WIDTH))
240 .flex_none()
241 .column()
242 .items_center()
243 .child(
244 div()
245 .mt(px(5.0))
246 .size(px(MARKER_SIZE))
247 .rounded_full()
248 .border(px(theme.borders.hairline))
249 .border_color(marker_color)
250 .when(selected, |element| element.bg(marker_color)),
251 )
252 .when(continues, |element| {
253 element.child(
254 div()
255 .mt(px(3.0))
256 .w(px(theme.borders.hairline))
257 .flex_1()
258 .min_h(px(theme.space(Space::Lg)))
259 .bg(theme.colors.hairline),
260 )
261 });
262
263 let mut metadata = div().row().flex_wrap().gap_token(theme, Space::Sm);
264 if let Some(source) = &entry.source {
265 metadata = metadata.child(
266 foundation_text(theme, TypeScale::Caption, source.clone())
267 .text_tone(theme, TextTone::Muted)
268 .semantic_in(
269 cx,
270 NodeSpec::new(ident.child("source").semantic_id(), Role::Text)
271 .parent(ident.semantic_id())
272 .text(source.clone()),
273 ),
274 );
275 }
276 if let Some(time) = &entry.time {
277 metadata = metadata.child(
278 foundation_text(theme, TypeScale::Caption, time.clone())
279 .text_tone(theme, TextTone::Faint)
280 .semantic_in(
281 cx,
282 NodeSpec::new(ident.child("time").semantic_id(), Role::Text)
283 .parent(ident.semantic_id())
284 .text(time.clone()),
285 ),
286 );
287 }
288
289 let mut content = div()
290 .column()
291 .flex_1()
292 .min_w_0()
293 .gap(px(2.0))
294 .pb(px(theme.space(Space::Md)))
295 .child(
296 foundation_text(theme, TypeScale::Label, entry.label.clone()).text_tone(
297 theme,
298 if disabled {
299 TextTone::Faint
300 } else {
301 TextTone::Primary
302 },
303 ),
304 )
305 .children(entry.description.clone().map(|description| {
306 foundation_text(theme, TypeScale::Body, description).text_tone(theme, TextTone::Muted)
307 }))
308 .when(entry.source.is_some() || entry.time.is_some(), |element| {
309 element.child(metadata)
310 });
311
312 if let Some(reason) = &entry.unavailable {
313 content = content.child(
314 foundation_text(theme, TypeScale::Caption, reason.clone())
315 .text_color(theme.colors.warning)
316 .semantic_in(
317 cx,
318 NodeSpec::new(ident.child("reason").semantic_id(), Role::Text)
319 .parent(ident.semantic_id())
320 .text(reason.clone()),
321 ),
322 );
323 }
324
325 let mut row = div()
326 .id(ident.element_id())
327 .row()
328 .items_stretch()
329 .w_full()
330 .gap_token(theme, Space::Sm)
331 .px_token(theme, Space::Sm)
332 .pt(px(theme.space(Space::Sm)))
333 .radius(theme, Radius::Control)
334 .when(selected, |element| element.bg(theme.colors.selected))
335 .when(history_disabled, |element| {
336 element.opacity(theme.opacity.disabled)
337 })
338 .child(rail)
339 .child(content);
340
341 if !disabled && handler.is_some() {
342 row = row.tab_index(0).pressable(cx).focus_ring(theme);
343 }
344 if actionable {
345 row = row
346 .cursor_pointer()
347 .hover(|style| style.bg(theme.colors.hover));
348 }
349
350 if let Some(handler) = handler {
351 if actionable {
352 let click = Rc::clone(handler);
353 let id = entry.id.clone();
354 row = row.on_click(move |_, window, cx| click(id.clone(), window, cx));
355 }
356
357 if !disabled {
358 let keyboard = Rc::clone(handler);
359 let id = entry.id.clone();
360 row = row.on_key_down(move |event, window, cx| {
361 if matches!(event.keystroke.key.as_str(), "enter" | "space") && !selected {
362 keyboard(id.clone(), window, cx);
363 cx.stop_propagation();
364 }
365 });
366 }
367 }
368
369 let mut spec = NodeSpec::new(ident.semantic_id(), Role::Row)
370 .parent(history.semantic_id())
371 .text(entry.label.clone())
372 .selected(selected)
373 .disabled(disabled);
374 if let Some(description) = &entry.description {
375 spec = spec.description(description.clone());
376 }
377
378 row.semantic_in(cx, spec).into_any_element()
379}
380
381fn jump_target(
382 entries: &[HistoryEntry],
383 start: Option<usize>,
384 delta: isize,
385 current: Option<&SharedString>,
386) -> Option<SharedString> {
387 let mut index = start? as isize;
388 while index >= 0 && (index as usize) < entries.len() {
389 let entry = &entries[index as usize];
390 if entry.unavailable.is_none() && current != Some(&entry.id) {
391 return Some(entry.id.clone());
392 }
393 index += delta;
394 }
395 None
396}