Skip to main content

gpui_kit/navigation/
undo_history.rs

1//! A caller-owned history whose entries can be revisited.
2//!
3//! The history does not keep an undo stack and does not decide what can be
4//! restored. Entry order, the current entry, labels, already-formatted times,
5//! sources, and refusals all belong to the caller. The component only renders
6//! those facts and reports the identity of an entry the typist asked to jump
7//! to.
8
9use 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
22/// The local width reserved for the history rail.
23const RAIL_WIDTH: f32 = 20.0;
24/// The marker is local geometry: it occurs only on this rail.
25const MARKER_SIZE: f32 = 10.0;
26
27type JumpHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
28
29/// One caller-owned point in an undo history.
30#[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    /// `id` is the entry's durable business identity, never its list position.
42    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    /// A time already formatted in the caller's locale and time zone.
59    pub fn time(mut self, time: impl Into<SharedString>) -> Self {
60        self.time = Some(time.into());
61        self
62    }
63
64    /// Who or what produced the entry, in the caller's own words.
65    pub fn source(mut self, source: impl Into<SharedString>) -> Self {
66        self.source = Some(source.into());
67        self
68    }
69
70    /// Keeps the entry visible while stating why it cannot be restored.
71    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/// A history list that reports jump intents and changes no document state.
86#[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    /// The entry the caller says the document currently holds.
132    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}