Skip to main content

layout/
layout.rs

1//! Layout demo roughly equivalent to the `charmbracelet/lipgloss`
2//! `examples/layout/main.go` showcase.
3//!
4//! Run with: `cargo run --example layout`
5
6use {
7    bobatea::{
8        App, AppEvent, View,
9        components::{
10            border::Border,
11            button::{Button, ButtonVariant},
12            layer::{Compositor, CompositorLayer},
13            style::{BobaStyle, blend_1d, blend_2d, has_dark_background, hex_color, light_dark},
14            tabs::Tabs,
15        },
16        events::{EventTarget, SubscriptionPriority},
17        surface::{
18            Cell, Position, Surface, clip, fit_width, join_horizontal, join_vertical, place_filled,
19        },
20        theme::Theme,
21    },
22    crossterm::event::{KeyCode, MouseEvent},
23    futures_signals::signal::Mutable,
24    ratatui::{
25        Frame,
26        layout::Alignment,
27        prelude::Rect,
28        style::{Color, Style},
29    },
30    std::sync::Mutex,
31};
32
33// Global layout constants
34const WIDTH: usize = 96;
35const DIALOG_HEIGHT: usize = 9;
36const HISTORY_GAP: usize = 3;
37const HISTORY_TOTAL_HORIZONTAL_PADDING: usize = 4;
38const HISTORY_CONTENT_WIDTH: usize =
39    ((WIDTH - (HISTORY_GAP * 2)) / 3) - HISTORY_TOTAL_HORIZONTAL_PADDING;
40
41fn color_grid_surf(x_steps: usize, y_steps: usize) -> Surface {
42    let colors = blend_2d(
43        x_steps,
44        y_steps,
45        hex_color("#F25D94"),
46        hex_color("#EDFF82"),
47        hex_color("#643AFF"),
48        hex_color("#14F9D5"),
49    );
50    let mut rows = Vec::new();
51    for row in colors {
52        let mut cells = Vec::new();
53        for color in row {
54            let style = Style::default().bg(color);
55            cells.push(Cell::new(" ", style));
56            cells.push(Cell::new(" ", style));
57        }
58        rows.push(cells);
59    }
60    Surface { rows }
61}
62
63fn apply_gradient(text: &str, from: Color, to: Color) -> Surface {
64    let chars: Vec<char> = text.chars().collect();
65    if chars.is_empty() {
66        return Surface { rows: vec![vec![]] };
67    }
68    let n = chars.len().max(2);
69    let gradients = blend_1d(n, from, to);
70    let mut row = Vec::new();
71    for (i, ch) in chars.iter().enumerate() {
72        let style = BobaStyle::new().fg(gradients[i]).into();
73        row.push(Cell::new(ch.to_string(), style));
74    }
75    Surface { rows: vec![row] }
76}
77
78fn status_seg(bg: Color) -> BobaStyle {
79    BobaStyle::new()
80        .fg(hex_color("#FFFDF5"))
81        .bg(bg)
82        .padding_y(0)
83        .padding_x(1)
84}
85
86struct LayoutView {
87    tabs: Tabs,
88    ok_btn: Button,
89    cancel_btn: Button,
90    active_tab: Mutable<usize>,
91    tabs_area: Mutex<Rect>,
92    ok_btn_area: Mutex<Rect>,
93    cancel_btn_area: Mutex<Rect>,
94}
95
96impl LayoutView {
97    fn new() -> Self {
98        let active_tab = Mutable::new(0);
99        Self {
100            tabs: Tabs::new(["Lip Gloss", "Blush", "Eye Shadow", "Mascara", "Foundation"])
101                .id_as("main-tabs")
102                .active(&active_tab),
103            ok_btn: Button::new("Yes")
104                .id_as("dialog-ok")
105                .variant(ButtonVariant::Primary)
106                .default_style(
107                    BobaStyle::new()
108                        .fg(hex_color("#FFF7DB"))
109                        .bg(hex_color("#F25D94"))
110                        .padding_y(0)
111                        .padding_x(1)
112                        .margin_top(1)
113                        .margin_right(2),
114                )
115                .hovered_style(
116                    BobaStyle::new()
117                        .fg(hex_color("#FFF7DB"))
118                        .bg(hex_color("#F25D94"))
119                        .padding_y(0)
120                        .padding_x(1)
121                        .margin_top(1)
122                        .margin_right(2)
123                        .bold(),
124                ),
125            cancel_btn: Button::new("Maybe")
126                .id_as("dialog-cancel")
127                .variant(ButtonVariant::Secondary)
128                .default_style(
129                    BobaStyle::new()
130                        .fg(hex_color("#FFF7DB"))
131                        .bg(hex_color("#888B7E"))
132                        .padding_y(0)
133                        .padding_x(1)
134                        .margin_top(1),
135                ),
136            active_tab,
137            tabs_area: Mutex::new(Rect::new(0, 0, 0, 0)),
138            ok_btn_area: Mutex::new(Rect::new(0, 0, 0, 0)),
139            cancel_btn_area: Mutex::new(Rect::new(0, 0, 0, 0)),
140        }
141    }
142}
143
144impl LayoutView {
145    fn build_document(&self) -> Surface {
146        let has_dark_bg = has_dark_background();
147        let subtle = light_dark(has_dark_bg, hex_color("#D9DCCF"), hex_color("#383838"));
148        let highlight = light_dark(has_dark_bg, hex_color("#874BFD"), hex_color("#7D56F4"));
149        let special = light_dark(has_dark_bg, hex_color("#43BF6D"), hex_color("#73F59F"));
150
151        // Tabs
152        let tabs_row = self.tabs.render_to_surface();
153
154        // Title
155        let title_style = BobaStyle::new()
156            .margin_left(1)
157            .margin_right(5)
158            .padding(0, 1, 0, 1)
159            .italic()
160            .fg(hex_color("#FFF7DB"));
161        let title_colors = blend_2d(
162            1,
163            5,
164            hex_color("#F25D94"),
165            hex_color("#643AFF"),
166            hex_color("#EDFF82"),
167            hex_color("#14F9D5"),
168        );
169        let mut title_surfaces = Vec::new();
170        for (i, row) in title_colors.iter().enumerate() {
171            let color = row[0];
172            let s = title_style
173                .margin_left((i * 2) as u16)
174                .bg(color)
175                .render("Boba Tea");
176            title_surfaces.push(s);
177        }
178        let title = join_vertical(Position::Left, &title_surfaces);
179
180        let desc_style = BobaStyle::new().margin_top(1);
181        let info_style = BobaStyle::new()
182            .border(Border::normal().no_left().no_right().no_bottom())
183            .border_fg(subtle);
184        let divider = BobaStyle::new().padding(0, 1, 0, 1).fg(subtle).render("•");
185        let url = BobaStyle::new()
186            .fg(special)
187            .render("https://github.com/tascord/boba");
188        let info_content = join_horizontal(
189            Position::Top,
190            &[
191                BobaStyle::new().render("by flora (based on Charm)"),
192                divider,
193                url,
194            ],
195        );
196        let info = info_style.render_surface(&info_content);
197        let desc = join_vertical(
198            Position::Left,
199            &[
200                desc_style.render("Terminal layout + styling you won't hate."),
201                info,
202            ],
203        );
204        let title_row = join_horizontal(Position::Top, &[title, desc]);
205
206        // Dialog
207        let dialog_box = BobaStyle::new()
208            .border(Border::rounded())
209            .border_fg(hex_color("#874BFD"))
210            .padding(1, 4, 1, 4)
211            .align(Alignment::Center, Position::Center);
212
213        let question = BobaStyle::new().render_surface(&apply_gradient(
214            "Are you sure you want to eat marmalade?",
215            hex_color("#EDFF82"),
216            hex_color("#F25D94"),
217        ));
218        let ok_surf = self.ok_btn.render_to_surface(&Theme::default());
219        let cancel_surf = self.cancel_btn.render_to_surface(&Theme::default());
220        let buttons = join_horizontal(Position::Top, &[ok_surf, cancel_surf]);
221        let dialog_content = join_vertical(Position::Center, &[question, buttons]);
222        let dialog_inner = dialog_box.render_surface(&dialog_content);
223        let dialog = place_filled(
224            WIDTH,
225            DIALOG_HEIGHT,
226            Position::Center,
227            Position::Center,
228            &dialog_inner,
229            Style::default().fg(subtle),
230            "l o r e m ",
231        );
232        let mut dialog = dialog;
233        fit_width(
234            &mut dialog,
235            WIDTH,
236            &Cell::blank(Style::default().fg(subtle)),
237        );
238
239        // Color grid
240        let colors = color_grid_surf(14, 8);
241
242        // Lists
243        let list_header_style = BobaStyle::new()
244            .border(Border::normal().no_top().no_left().no_right())
245            .border_fg(subtle)
246            .margin_right(2);
247        let list_style = BobaStyle::new()
248            .border(Border::normal().no_top().no_bottom().no_right())
249            .border_fg(subtle)
250            .margin_right(1)
251            .height(8)
252            .width((WIDTH / 3) as u16);
253
254        let check = BobaStyle::new().fg(special).padding_right(1).render("✓");
255        let gray_done = light_dark(has_dark_bg, hex_color("#969B86"), hex_color("#696969"));
256        let list_done = |text: &str| -> Surface {
257            let body = BobaStyle::new().crossed_out().fg(gray_done).render(text);
258            join_horizontal(Position::Top, &[check.clone(), body])
259        };
260        let list_item = |text: &str| -> Surface { BobaStyle::new().padding_left(2).render(text) };
261
262        let list1 = join_vertical(
263            Position::Left,
264            &[
265                list_header_style.render("Citrus Fruits to Try"),
266                list_done("Grapefruit"),
267                list_done("Yuzu"),
268                list_item("Citron"),
269                list_item("Kumquat"),
270                list_item("Pomelo"),
271            ],
272        );
273        let list1 = list_style.render_surface(&list1);
274
275        let list2 = join_vertical(
276            Position::Left,
277            &[
278                list_header_style.render("Actual Boba Vendors"),
279                list_item("Chatime"),
280                list_item("Gong Cha"),
281                list_done("Teaser"),
282            ],
283        );
284        let list2 = list_style.render_surface(&list2);
285
286        let lists = join_horizontal(Position::Top, &[list1, list2, colors]);
287
288        // History
289        let history_style = BobaStyle::new()
290            .align(Alignment::Left, Position::Top)
291            .fg(hex_color("#FAFAFA"))
292            .bg(highlight)
293            .margin(1, HISTORY_GAP as u16, 0, 0)
294            .padding(1, 2, 1, 2)
295            .height(19)
296            .width(HISTORY_CONTENT_WIDTH as u16);
297
298        let history_a = history_style
299            .clone()
300            .align(Alignment::Right, Position::Top)
301            .render(LOREM);
302        let history_b = history_style
303            .clone()
304            .align(Alignment::Center, Position::Top)
305            .render(LOREM);
306        let history_c = history_style
307            .align(Alignment::Left, Position::Top)
308            .margin_right(0)
309            .render(LOREM);
310        let history = join_horizontal(Position::Top, &[history_a, history_b, history_c]);
311
312        // Status bar
313        let light_dark_state = if has_dark_bg { "Dark" } else { "Light" };
314        let bar_style = BobaStyle::new()
315            .fg(light_dark(
316                has_dark_bg,
317                hex_color("#343433"),
318                hex_color("#C1C6B2"),
319            ))
320            .bg(light_dark(
321                has_dark_bg,
322                hex_color("#D9DCCF"),
323                hex_color("#353533"),
324            ));
325
326        let status_key = status_seg(hex_color("#FF5F87")).render("STATUS");
327        let encoding = status_seg(hex_color("#A550DF"))
328            .align(Alignment::Right, Position::Center)
329            .render("UTF-8");
330        let fish = status_seg(hex_color("#6124DF")).render("Fish Cake");
331
332        // Segment widths already include their horizontal padding.
333        let used = status_key.width() + encoding.width() + fish.width();
334        let remaining = WIDTH.saturating_sub(used);
335        let status_text_width = remaining.saturating_sub(2);
336
337        let mut status_val = BobaStyle::new()
338            .inherit(bar_style)
339            .width(status_text_width as u16)
340            .padding_x(1)
341            .render(&format!("Ravishingly {}!", light_dark_state));
342        clip(&mut status_val, remaining);
343        fit_width(&mut status_val, remaining, &Cell::blank(*bar_style));
344
345        let bar = join_horizontal(Position::Top, &[status_key, status_val, encoding, fish]);
346        let mut status_bar = bar;
347        let fill_cell = Cell::blank(
348            Style::new()
349                .fg(light_dark(
350                    has_dark_bg,
351                    hex_color("#343433"),
352                    hex_color("#C1C6B2"),
353                ))
354                .bg(light_dark(
355                    has_dark_bg,
356                    hex_color("#D9DCCF"),
357                    hex_color("#353533"),
358                )),
359        );
360        fit_width(&mut status_bar, WIDTH, &fill_cell);
361
362        // Assemble document
363        join_vertical(
364            Position::Left,
365            &[tabs_row, title_row, dialog, lists, history, status_bar],
366        )
367    }
368
369    fn build_modal(&self) -> Surface {
370        BobaStyle::new()
371            .italic()
372            .fg(hex_color("#FFF7DB"))
373            .bg(hex_color("#F25D94"))
374            .padding(1, 6, 1, 6)
375            .width(40u16)
376            .align(Alignment::Center, Position::Center)
377            .render("Now with Compositing!")
378    }
379
380    fn on_mouse(&self, ev: &MouseEvent) {
381        if let Ok(tabs_area) = self.tabs_area.lock() {
382            self.tabs.on_mouse(*tabs_area, ev);
383        }
384        if let Ok(ok_btn_area) = self.ok_btn_area.lock() {
385            self.ok_btn.on_mouse(*ok_btn_area, ev);
386        }
387        if let Ok(cancel_btn_area) = self.cancel_btn_area.lock() {
388            self.cancel_btn.on_mouse(*cancel_btn_area, ev);
389        }
390    }
391}
392
393impl View for LayoutView {
394    fn mount(&self, app: &EventTarget<AppEvent>) {
395        let app_for_quit = app.clone();
396        app.on_key(SubscriptionPriority::High, move |ev, key| {
397            if key.code == KeyCode::Char('q') {
398                ev.cancel();
399                app_for_quit.emit(AppEvent::Quit);
400            }
401        })
402        .forget();
403    }
404
405    fn render(&self, ctx: &mut Frame<'_>, theme: &Theme) {
406        let area = ctx.area();
407        let buf = ctx.buffer_mut();
408
409        // Clear with theme background
410        let bg = theme.global_bg;
411        for y in area.top()..area.bottom() {
412            for x in area.left()..area.right() {
413                buf[(x, y)].reset();
414                buf[(x, y)].set_bg(bg);
415            }
416        }
417
418        let doc = self.build_document();
419        let modal = self.build_modal();
420
421        // Center document in available area
422        let doc_w = doc.width().min(area.width as usize);
423        let doc_h = doc.height().min(area.height as usize);
424        let doc_x = (area.width as usize).saturating_sub(doc_w) / 2;
425        let doc_y = (area.height as usize).saturating_sub(doc_h) / 2;
426
427        // Update component areas
428        if let Ok(mut tabs_area) = self.tabs_area.lock() {
429            *tabs_area = Rect::new(doc_x as u16, doc_y as u16, doc_w as u16, 3);
430        }
431
432        // Keep the composited badge inside the document bounds.
433        let modal_x = doc_x + doc_w.saturating_sub(modal.width() + 2);
434        let modal_y = doc_y + doc_h.saturating_sub(modal.height() + 2);
435
436        let comp = Compositor::new(vec![
437            CompositorLayer::new(doc).x(doc_x as u16).y(doc_y as u16),
438            CompositorLayer::new(modal)
439                .x(modal_x as u16)
440                .y(modal_y as u16),
441        ]);
442        comp.render_to_buf(area, buf);
443    }
444}
445
446const LOREM: &str = "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque \
447                     sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna \
448                     tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada \
449                     lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora \
450                     torquent per conubia nostra inceptos himenaeos.";
451
452fn main() {
453    let rt = tokio::runtime::Builder::new_multi_thread()
454        .enable_all()
455        .build()
456        .unwrap();
457    rt.block_on(async {
458        App::new(LayoutView::new()).run().await.unwrap();
459    });
460}