Skip to main content

kitchen_shell/
container.rs

1//! One container for every workspace's views (lumen's planes, tables and plots; dsp-kitchen's
2//! traces and rasters): a dock whose views are tabs dragged into splits, the "Add view or
3//! container" dialog (after Rerun's: arrange every view, or add one), and the buttons in a view's
4//! tab. A workspace only lists its view kinds ([`ViewOption`]) with their flags: whether each can
5//! be added now (and why not), which one a view shows.
6
7use std::rc::Rc;
8
9use gpui_kit::assets::IconName;
10use gpui_kit::base::dock::{DockArea, DockLayout, DockPlacement, InsertTarget, PanelId};
11use gpui_kit::base::Placement;
12use gpui_kit::component::button::{Button, ButtonVariants as _};
13use gpui_kit::component::dock::{panel_handle, DockSkin, Panel};
14use gpui_kit::component::{h_flex, v_flex, Disableable as _, Icon, Selectable as _, Sizable as _};
15use gpui_kit::prelude::FluentBuilder as _;
16use gpui_kit::{
17    div, px, App, AppContext as _, Context, Entity, EntityId, EventEmitter, FocusHandle, FontWeight, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, ParentElement as _, Render,
18    SharedString, StatefulInteractiveElement as _, Styled as _, Window,
19};
20
21use kitchen_ui::theme::{self, color};
22use serde::{Deserialize, Serialize};
23
24/// How the views are laid out together.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum Arrangement {
28    /// Every view as a tab of one group.
29    Tabs,
30    /// Every view side by side.
31    Horizontal,
32    /// Every view stacked.
33    Vertical,
34    /// Every view in a grid (as square as the count allows).
35    Grid,
36}
37
38impl Arrangement {
39    /// Every arrangement, in the dialog's order.
40    pub const ALL: [Arrangement; 4] = [Arrangement::Tabs, Arrangement::Horizontal, Arrangement::Vertical, Arrangement::Grid];
41
42    /// Its name ("Side by side").
43    pub fn title(self) -> &'static str {
44        match self {
45            Arrangement::Tabs => "Tabs",
46            Arrangement::Horizontal => "Side by side",
47            Arrangement::Vertical => "Stacked",
48            Arrangement::Grid => "Grid",
49        }
50    }
51}
52
53/// A kind of view a container offers.
54#[derive(Clone, Debug, PartialEq)]
55pub struct ViewOption {
56    /// Its name ("Plane", "Table").
57    pub title: String,
58    /// Its icon (the tab button, the dialog's tile).
59    pub icon: IconName,
60    /// What it shows (the dialog's line, the tab button's tooltip).
61    pub hint: String,
62    /// Whether it can be added now; when not, `hint` says why.
63    pub enabled: bool,
64}
65
66impl ViewOption {
67    /// A kind of view titled `title` showing what `hint` says.
68    pub fn new(title: impl Into<String>, icon: IconName, hint: impl Into<String>) -> Self {
69        Self { title: title.into(), icon, hint: hint.into(), enabled: true }
70    }
71
72    /// Not available now, for `why`.
73    pub fn unavailable(mut self, why: impl Into<String>) -> Self {
74        self.enabled = false;
75        self.hint = why.into();
76        self
77    }
78}
79
80/// A dock for views, with the look every workspace shares (no dock toggle buttons).
81pub fn new_dock(id: &'static str, window: &mut Window, cx: &mut App) -> Entity<DockArea> {
82    // The skin hides the toggles once the dock exists (it updates the dock)
83    let mut skin = None;
84    let dock = cx.new(|cx| {
85        let s = DockSkin::new(cx);
86        skin = Some(s.clone());
87        DockArea::new(id, Some(2), window, cx).with_renderer(s)
88    });
89    if let Some(skin) = skin {
90        skin.set_toggle_button_visible(false, cx);
91    }
92    dock
93}
94
95/// Adds `panel` to the dock's centre, split off `beside` (another view's entity) at `placement`
96/// when it is there.
97pub fn add_beside<P: Panel>(dock: &Entity<DockArea>, panel: Entity<P>, beside: Option<(EntityId, Placement)>, window: &mut Window, cx: &mut App) {
98    let id = PanelId::from(panel.entity_id());
99    dock.update(cx, |d, cx| {
100        let target = beside.and_then(|(e, at)| Some((d.layout(DockPlacement::Center)?.find_panel_node(PanelId::from(e))?, at)));
101        d.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
102        if let Some((node, placement)) = target {
103            d.move_panel(id, InsertTarget::Split { node, placement, size: None }, window, cx);
104        }
105    });
106}
107
108/// Lays out `panels` in `dock`'s centre as `how`.
109pub fn arrange<P: Panel>(dock: &Entity<DockArea>, panels: &[Entity<P>], how: Arrangement, window: &mut Window, cx: &mut App) {
110    let layout = arranged(panels, how, cx);
111    dock.update(cx, |d, cx| d.set_center(layout, window, cx));
112}
113
114/// The views laid out as `how`, in order.
115pub fn arranged<P: Panel>(panels: &[Entity<P>], how: Arrangement, cx: &App) -> DockLayout {
116    let group = |p: &Entity<P>, cx: &App| DockLayout::tabs().panel_view(panel_handle(p.clone()), cx);
117    match how {
118        Arrangement::Tabs => panels.iter().fold(DockLayout::tabs(), |l, p| l.panel_view(panel_handle(p.clone()), cx)),
119        Arrangement::Horizontal => panels.iter().fold(DockLayout::h_split(), |l, p| l.child(group(p, cx), None)),
120        Arrangement::Vertical => panels.iter().fold(DockLayout::v_split(), |l, p| l.child(group(p, cx), None)),
121        Arrangement::Grid => {
122            let cols = (panels.len() as f64).sqrt().ceil().max(1.0) as usize;
123            panels.chunks(cols).fold(DockLayout::v_split(), |l, row| l.child(row.iter().fold(DockLayout::h_split(), |r, p| r.child(group(p, cx), None)), None))
124        }
125    }
126}
127
128type Pick = Rc<dyn Fn(usize, &mut Window, &mut App)>;
129
130/// The buttons in a view's tab: one per kind it can show (`current` selected; a click shows that
131/// kind in this view), then "+" (add a view, or arrange them). Ids: `{key}-kind-{i}`,
132/// `{key}-add`. gpui-kit's small size (about 24 px: designparser `touch-target`).
133pub fn tab_buttons(key: &str, options: &[ViewOption], current: Option<usize>, show: impl Fn(usize, &mut Window, &mut App) + 'static, add: impl Fn(&mut Window, &mut App) + 'static) -> Vec<Button> {
134    let show: Pick = Rc::new(show);
135    let mut out: Vec<Button> = options
136        .iter()
137        .enumerate()
138        .map(|(i, o)| {
139            let show = show.clone();
140            Button::new(SharedString::from(format!("{key}-kind-{i}")))
141                .ghost()
142                .small()
143                .icon(Icon::new(o.icon))
144                .tooltip(format!("{}: {}", o.title, o.hint))
145                .selected(current == Some(i))
146                .disabled(!o.enabled && current != Some(i))
147                .on_click(move |_, window, cx| show(i, window, cx))
148        })
149        .collect();
150    out.push(Button::new(SharedString::from(format!("{key}-add"))).ghost().small().icon(Icon::new(IconName::Plus)).tooltip("Add a view, or arrange the views").on_click(move |_, window, cx| add(window, cx)));
151    out
152}
153
154/// What the dialog asks for.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum DialogEvent {
157    /// Arrange every view so.
158    Arrange(Arrangement),
159    /// Add the view of option `i`.
160    Add(usize),
161    /// Close it.
162    Close,
163}
164
165/// "Add view or container": the arrangements, then the workspace's views; ↑ ↓ move between the
166/// rows that can run, Enter runs one, Esc or a click outside closes.
167pub struct AddViewDialog {
168    options: Vec<ViewOption>,
169    /// Arranging needs views to arrange.
170    can_arrange: bool,
171    cursor: usize,
172    focus: FocusHandle,
173}
174
175impl EventEmitter<DialogEvent> for AddViewDialog {}
176
177/// The arrangements the dialog offers: icon and line.
178const ARRANGEMENTS: [(Arrangement, IconName, &str); 4] = [
179    (Arrangement::Tabs, IconName::AppWindow, "Every view as a tab of one group"),
180    (Arrangement::Horizontal, IconName::Columns2, "Every view side by side"),
181    (Arrangement::Vertical, IconName::Rows2, "Every view stacked"),
182    (Arrangement::Grid, IconName::Grid2x2, "Every view in a grid"),
183];
184
185impl AddViewDialog {
186    /// The dialog offering `options` (and the arrangements, when `can_arrange`); focuses it.
187    pub fn new(options: Vec<ViewOption>, can_arrange: bool, window: &mut Window, cx: &mut Context<Self>) -> Self {
188        let focus = cx.focus_handle();
189        window.focus(&focus, cx);
190        let mut d = Self { options, can_arrange, cursor: 0, focus };
191        d.cursor = d.enabled().first().copied().unwrap_or(0);
192        d
193    }
194
195    /// The rows that can run (arrangements first, then the views).
196    fn enabled(&self) -> Vec<usize> {
197        let arr = (0..ARRANGEMENTS.len()).filter(|_| self.can_arrange);
198        arr.chain(self.options.iter().enumerate().filter(|(_, o)| o.enabled).map(|(i, _)| ARRANGEMENTS.len() + i)).collect()
199    }
200
201    /// Runs row `row` (arrangements first, then the views), as a click does.
202    pub fn run(&mut self, row: usize, cx: &mut Context<Self>) {
203        cx.emit(if row < ARRANGEMENTS.len() { DialogEvent::Arrange(ARRANGEMENTS[row].0) } else { DialogEvent::Add(row - ARRANGEMENTS.len()) });
204    }
205
206    fn on_key(&mut self, e: &KeyDownEvent, _: &mut Window, cx: &mut Context<Self>) {
207        let enabled = self.enabled();
208        let at = enabled.iter().position(|&i| i == self.cursor);
209        match e.keystroke.key.as_str() {
210            "escape" => cx.emit(DialogEvent::Close),
211            "down" | "up" if !enabled.is_empty() => {
212                let n = enabled.len();
213                let next = match (at, e.keystroke.key.as_str()) {
214                    (Some(i), "down") => (i + 1) % n,
215                    (Some(i), _) => (i + n - 1) % n,
216                    (None, "down") => 0,
217                    (None, _) => n - 1,
218                };
219                self.cursor = enabled[next];
220                cx.notify();
221            }
222            "enter" => {
223                if enabled.contains(&self.cursor) {
224                    self.run(self.cursor, cx);
225                }
226            }
227            _ => return,
228        }
229        cx.stop_propagation();
230    }
231}
232
233impl Render for AddViewDialog {
234    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
235        let me = cx.entity().downgrade();
236        let cursor = self.cursor;
237        let row = |ix: usize, icon: IconName, title: String, text: String, enabled: bool| {
238            let me = me.clone();
239            h_flex()
240                .id(SharedString::from(format!("add-view-row-{ix}")))
241                .min_h(px(48.))
242                .px(px(theme::SM))
243                .gap(px(theme::MD))
244                .rounded(px(theme::RADIUS))
245                .when(enabled && ix == cursor, |d| d.bg(color::control()))
246                .when(enabled, |d| {
247                    d.cursor_pointer().hover(|d| d.bg(color::control())).on_click(move |_, _, cx| {
248                        let _ = me.update(cx, |d, cx| d.run(ix, cx));
249                    })
250                })
251                .when(!enabled, |d| d.opacity(0.5))
252                .child(div().flex_none().size(px(32.)).rounded(px(theme::RADIUS)).border_1().border_color(color::hover()).flex().items_center().justify_center().child(Icon::new(icon)))
253                .child(v_flex().min_w_0().child(div().text_sm().font_weight(FontWeight::SEMIBOLD).child(title)).child(div().text_xs().text_color(color::text_muted()).truncate().child(text)))
254        };
255        let heading = |text: &'static str, hint: &'static str| v_flex().px(px(theme::SM)).pt(px(theme::MD)).pb(px(theme::XS)).child(div().text_xs().font_weight(FontWeight::SEMIBOLD).child(text)).child(div().text_xs().text_color(color::text_muted()).child(hint));
256        let close = me.clone();
257        let n = ARRANGEMENTS.len();
258        let card = v_flex()
259            .id("add-view-dialog")
260            .occlude()
261            .w(px(460.))
262            .max_h(px(620.))
263            .overflow_y_scroll()
264            .p(px(theme::SM))
265            .rounded(px(theme::RADIUS_LG))
266            .border_1()
267            .border_color(color::border())
268            .bg(color::raised())
269            .shadow_lg()
270            .track_focus(&self.focus)
271            .on_key_down(cx.listener(Self::on_key))
272            .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
273            .child(
274                h_flex()
275                    .px(px(theme::SM))
276                    .py(px(theme::XS))
277                    .justify_between()
278                    .child(h_flex().gap(px(theme::SM)).items_baseline().child(div().text_sm().font_weight(FontWeight::SEMIBOLD).child("Add view or container")).child(div().text_xs().text_color(color::text_muted()).child("↑ ↓ Enter · Esc")))
279                    .child(Button::new("add-view-close").ghost().small().icon(Icon::new(IconName::X)).tooltip("Close").on_click(move |_, _, cx| {
280                        let _ = close.update(cx, |_, cx| cx.emit(DialogEvent::Close));
281                    })),
282            )
283            .child(heading("Containers", "Arrange the views"))
284            .children(ARRANGEMENTS.iter().enumerate().map(|(i, (how, icon, text))| row(i, *icon, how.title().to_string(), text.to_string(), self.can_arrange)))
285            .child(div().mx(px(theme::SM)).my(px(theme::SM)).h(px(1.)).bg(color::border()))
286            .child(heading("Views", "Added beside the focused view"))
287            .children(self.options.iter().enumerate().map(|(i, o)| row(n + i, o.icon, o.title.clone(), o.hint.clone(), o.enabled)));
288        let back = me.clone();
289        // The backdrop over the views closes it
290        div()
291            .id("add-view-backdrop")
292            .absolute()
293            .inset_0()
294            .bg(gpui_kit::black().opacity(0.35))
295            .flex()
296            .items_start()
297            .justify_center()
298            .pt(px(48.))
299            .on_mouse_down(MouseButton::Left, move |_, _, cx| {
300                let _ = back.update(cx, |_, cx| cx.emit(DialogEvent::Close));
301            })
302            .child(card)
303    }
304}