Skip to main content

gpui_cn/
nav.rs

1//! Navigation between pages: a stack of views with back and forward.
2//!
3//! `gpui-base` owns the stack ([`NavStackState`]) and the lifecycle of a
4//! change. gpui-cn adds how a change looks ([`NavStack`]) and the pair of
5//! arrows that drive it ([`NavButtons`]).
6//!
7//! ```
8//! use gpui_cn::{NavButtons, NavStack, NavStackState};
9//! use gpui_kit::{Entity, IntoElement, ParentElement as _, Styled as _, div};
10//!
11//! fn window(stack: &Entity<NavStackState>) -> impl IntoElement {
12//!     div()
13//!         .child(NavButtons::new("nav", stack))
14//!         .child(NavStack::new(stack).size_full())
15//! }
16//! ```
17
18use std::time::Duration;
19
20use gpui_kit::{
21    AnyView, App, ElementId, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
22    RenderOnce, StyleRefinement, Styled, Window,
23    base::{self, Disableable as _, PresencePhase, StyledExt as _},
24    div,
25    prelude::FluentBuilder as _,
26};
27
28pub use gpui_kit::base::{NavMotion, NavOperation, NavPage, NavStackEvent, NavStackState};
29
30use crate::{Button, Icon, Theme};
31use gpui_kit::assets::IconName;
32
33/// Navigation that also frees pages.
34///
35/// A popped page waits in the stack's forward history until the next
36/// push, so its view stays alive. These methods drop that history, so a
37/// page the application will not bring back releases its state.
38pub trait NavStackExt {
39    /// Drops the forward history at once, keeping the views from the root
40    /// to the current page. The stack reports `Cleared` and then one
41    /// `Pushed` per kept page. A running transition is abandoned.
42    fn discard_forward(&self, cx: &mut App);
43
44    /// Pops the top view like [`NavStackState::pop`], then drops it from
45    /// the forward history once its exit transition has run, so `forward`
46    /// cannot bring it back and nothing keeps it alive. Returns the popped
47    /// view, or `None` at the root.
48    fn pop_and_discard(&self, motion: NavMotion, cx: &mut App) -> Option<AnyView>;
49}
50
51impl NavStackExt for Entity<NavStackState> {
52    fn discard_forward(&self, cx: &mut App) {
53        self.update(cx, |stack, cx| {
54            if stack.forward_views().len() == 0 {
55                return;
56            }
57            let views: Vec<AnyView> = stack.views().cloned().collect();
58            stack.clear(cx);
59            for view in views {
60                stack.push(view, NavMotion::Immediate, cx);
61            }
62        });
63    }
64
65    fn pop_and_discard(&self, motion: NavMotion, cx: &mut App) -> Option<AnyView> {
66        let popped = self.update(cx, |stack, cx| stack.pop(motion, cx))?;
67        // The transition samples the outgoing view until it is done, so
68        // the history can only go once it has run. Discarding it earlier
69        // would abandon the motion.
70        let animated = motion == NavMotion::Animated && !cx.reduce_motion();
71        if !animated {
72            self.discard_forward(cx);
73            return Some(popped);
74        }
75        let delay = Theme::global(cx).motion.fast + Duration::from_millis(16);
76        let stack = self.clone();
77        let waiting = popped.clone();
78        cx.spawn(async move |cx| {
79            cx.background_executor().timer(delay).await;
80            cx.update(|cx| {
81                // Only the page that was popped waits in front; a push in
82                // the meantime already dropped it, and a `forward` brought
83                // it back on purpose.
84                let still_waiting = stack.read(cx).forward_views().next() == Some(&waiting);
85                if still_waiting {
86                    stack.discard_forward(cx);
87                }
88            });
89        })
90        .detach();
91        Some(popped)
92    }
93}
94
95/// The pages of a [`NavStackState`], one visible at a time.
96///
97/// A change crossfades over the theme's fast transition. Under reduced
98/// motion it switches at once. The stack fills its parent, so give it a
99/// sized one.
100#[derive(IntoElement)]
101pub struct NavStack {
102    state: Entity<NavStackState>,
103    style: StyleRefinement,
104}
105
106impl NavStack {
107    /// The pages of `state`.
108    pub fn new(state: &Entity<NavStackState>) -> Self {
109        Self {
110            state: state.clone(),
111            style: StyleRefinement::default(),
112        }
113    }
114}
115
116impl Styled for NavStack {
117    fn style(&mut self) -> &mut StyleRefinement {
118        &mut self.style
119    }
120}
121
122impl RenderOnce for NavStack {
123    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
124        let transition = Theme::global(cx).motion.fast_transition();
125        base::NavStack::new(&self.state)
126            .size_full()
127            .refine_style(&self.style)
128            .transition(transition)
129            .item(|page, _, _| {
130                let opacity = match page.phase() {
131                    PresencePhase::Entering => page.progress(),
132                    PresencePhase::Exiting => 1. - page.progress(),
133                    PresencePhase::Present => 1.,
134                    PresencePhase::Absent => 0.,
135                };
136                page.opacity(opacity).into_any_element()
137            })
138    }
139}
140
141/// Back and forward arrows for a [`NavStackState`].
142///
143/// Two icon-only ghost [`Button`]s. Back is enabled while there is a page
144/// under the current one, forward while a popped page waits to come
145/// back. Each moves the stack with the animated motion.
146#[derive(IntoElement)]
147pub struct NavButtons {
148    id: ElementId,
149    state: Entity<NavStackState>,
150    tooltips: bool,
151    back_icon: Icon,
152    forward_icon: Icon,
153}
154
155impl NavButtons {
156    /// Arrows for `state`. The id keys the two buttons.
157    pub fn new(id: impl Into<ElementId>, state: &Entity<NavStackState>) -> Self {
158        Self {
159            id: id.into(),
160            state: state.clone(),
161            tooltips: true,
162            back_icon: IconName::ArrowLeft.into(),
163            forward_icon: IconName::ArrowRight.into(),
164        }
165    }
166
167    /// The back arrow, in place of Lucide's `arrow-left`, such as an
168    /// application's own SVG through [`Icon::from_bytes`].
169    pub fn back_icon(mut self, icon: impl Into<Icon>) -> Self {
170        self.back_icon = icon.into();
171        self
172    }
173
174    /// The forward arrow, in place of Lucide's `arrow-right`.
175    pub fn forward_icon(mut self, icon: impl Into<Icon>) -> Self {
176        self.forward_icon = icon.into();
177        self
178    }
179
180    /// Whether the arrows show their names as tooltips. On by default.
181    pub fn tooltips(mut self, tooltips: bool) -> Self {
182        self.tooltips = tooltips;
183        self
184    }
185}
186
187impl RenderOnce for NavButtons {
188    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
189        let (can_go_back, can_go_forward) = {
190            let state = self.state.read(cx);
191            (state.depth() > 1, state.forward_views().len() > 0)
192        };
193        let back_state = self.state.clone();
194        let forward_state = self.state;
195        div()
196            .id(self.id.clone())
197            .flex()
198            .items_center()
199            .gap_1()
200            .child(
201                Button::new(ElementId::NamedChild(self.id.clone().into(), "back".into()))
202                    .ghost()
203                    .icon(self.back_icon)
204                    .accessibility_label("Back")
205                    .when(self.tooltips, |this| this.tooltip("Back"))
206                    .disabled(!can_go_back)
207                    .on_click(move |_, _, cx| {
208                        back_state.update(cx, |state, cx| {
209                            state.pop(NavMotion::Animated, cx);
210                        });
211                    }),
212            )
213            .child(
214                Button::new(ElementId::NamedChild(self.id.into(), "forward".into()))
215                    .ghost()
216                    .icon(self.forward_icon)
217                    .accessibility_label("Forward")
218                    .when(self.tooltips, |this| this.tooltip("Forward"))
219                    .disabled(!can_go_forward)
220                    .on_click(move |_, _, cx| {
221                        forward_state.update(cx, |state, cx| {
222                            state.forward(NavMotion::Animated, cx);
223                        });
224                    }),
225            )
226    }
227}