bobatea 0.0.1

Terminal layout + styling you won't hate.
Documentation
//! Layout demo roughly equivalent to the `charmbracelet/lipgloss`
//! `examples/layout/main.go` showcase.
//!
//! Run with: `cargo run --example layout`

use {
    bobatea::{
        App, AppEvent, View,
        components::{
            border::Border,
            button::{Button, ButtonVariant},
            layer::{Compositor, CompositorLayer},
            style::{BobaStyle, blend_1d, blend_2d, has_dark_background, hex_color, light_dark},
            tabs::Tabs,
        },
        events::{EventTarget, SubscriptionPriority},
        surface::{Cell, Position, Surface, clip, fit_width, join_horizontal, join_vertical, place_filled},
        theme::Theme,
    },
    crossterm::event::{KeyCode, MouseEvent},
    futures_signals::signal::Mutable,
    ratatui::{
        Frame,
        layout::Alignment,
        prelude::Rect,
        style::{Color, Style},
    },
    std::sync::Mutex,
};

// Global layout constants
const WIDTH: usize = 96;
const COLUMN_WIDTH: usize = 28;

fn color_grid_surf(x_steps: usize, y_steps: usize) -> Surface {
    let colors =
        blend_2d(x_steps, y_steps, hex_color("#F25D94"), hex_color("#EDFF82"), hex_color("#643AFF"), hex_color("#14F9D5"));
    let mut rows = Vec::new();
    for row in colors {
        let mut cells = Vec::new();
        for color in row {
            let style = Style::default().bg(color);
            cells.push(Cell::new(" ", style));
            cells.push(Cell::new(" ", style));
        }
        rows.push(cells);
    }
    Surface { rows }
}

fn apply_gradient(text: &str, from: Color, to: Color) -> Surface {
    let chars: Vec<char> = text.chars().collect();
    if chars.is_empty() {
        return Surface { rows: vec![vec![]] };
    }
    let n = chars.len().max(2);
    let gradients = blend_1d(n, from, to);
    let mut row = Vec::new();
    for (i, ch) in chars.iter().enumerate() {
        let style = BobaStyle::new().fg(gradients[i]).into();
        row.push(Cell::new(ch.to_string(), style));
    }
    Surface { rows: vec![row] }
}

fn status_seg(text: &str, bg: Color) -> BobaStyle {
    BobaStyle::new().fg(hex_color("#FFFDF5")).bg(bg).padding_y(0).padding_x(1)
}

struct LayoutView {
    tabs: Tabs,
    ok_btn: Button,
    cancel_btn: Button,
    active_tab: Mutable<usize>,
    tabs_area: Mutex<Rect>,
    ok_btn_area: Mutex<Rect>,
    cancel_btn_area: Mutex<Rect>,
}

impl LayoutView {
    fn new() -> Self {
        let active_tab = Mutable::new(0);
        Self {
            tabs: Tabs::new(["Lip Gloss", "Blush", "Eye Shadow", "Mascara", "Foundation"])
                .id_as("main-tabs")
                .active(&active_tab),
            ok_btn: Button::new("Yes")
                .id_as("dialog-ok")
                .variant(ButtonVariant::Primary)
                .default_style(
                    BobaStyle::new()
                        .fg(hex_color("#FFF7DB"))
                        .bg(hex_color("#F25D94"))
                        .padding_y(0)
                        .padding_x(1)
                        .margin_top(1)
                        .margin_right(2),
                )
                .hovered_style(
                    BobaStyle::new()
                        .fg(hex_color("#FFF7DB"))
                        .bg(hex_color("#F25D94"))
                        .padding_y(0)
                        .padding_x(1)
                        .margin_top(1)
                        .margin_right(2)
                        .bold(),
                ),
            cancel_btn: Button::new("Maybe").id_as("dialog-cancel").variant(ButtonVariant::Secondary).default_style(
                BobaStyle::new().fg(hex_color("#FFF7DB")).bg(hex_color("#888B7E")).padding_y(0).padding_x(1).margin_top(1),
            ),
            active_tab,
            tabs_area: Mutex::new(Rect::new(0, 0, 0, 0)),
            ok_btn_area: Mutex::new(Rect::new(0, 0, 0, 0)),
            cancel_btn_area: Mutex::new(Rect::new(0, 0, 0, 0)),
        }
    }
}

impl LayoutView {
    fn build_document(&self) -> Surface {
        let has_dark_bg = has_dark_background();
        let subtle = light_dark(has_dark_bg, hex_color("#D9DCCF"), hex_color("#383838"));
        let highlight = light_dark(has_dark_bg, hex_color("#874BFD"), hex_color("#7D56F4"));
        let special = light_dark(has_dark_bg, hex_color("#43BF6D"), hex_color("#73F59F"));

        // Tabs
        let tabs_row = self.tabs.render_to_surface();

        // Title
        let title_style =
            BobaStyle::new().margin_left(1).margin_right(5).padding(0, 1, 0, 1).italic().fg(hex_color("#FFF7DB"));
        let title_colors =
            blend_2d(1, 5, hex_color("#F25D94"), hex_color("#643AFF"), hex_color("#EDFF82"), hex_color("#14F9D5"));
        let mut title_surfaces = Vec::new();
        for (i, row) in title_colors.iter().enumerate() {
            let color = row[0];
            let s = title_style.margin_left((i * 2) as u16).bg(color).render("Boba Tea");
            title_surfaces.push(s);
        }
        let title = join_vertical(Position::Left, &title_surfaces);

        let desc_style = BobaStyle::new().margin_top(1);
        let info_style = BobaStyle::new().border(Border::normal().no_left().no_right().no_bottom()).border_fg(subtle);
        let divider = BobaStyle::new().padding(0, 1, 0, 1).fg(subtle).render("");
        let url = BobaStyle::new().fg(special).render("https://github.com/tascord/boba");
        let info_content =
            join_horizontal(Position::Top, &[BobaStyle::new().render("by flora (based on Charm)"), divider, url]);
        let info = info_style.render_surface(&info_content);
        let desc = join_vertical(Position::Left, &[desc_style.render("Terminal layout + styling you won't hate."), info]);
        let title_row = join_horizontal(Position::Top, &[title, desc]);

        // Dialog
        let dialog_box = BobaStyle::new()
            .border(Border::rounded())
            .border_fg(hex_color("#874BFD"))
            .padding(1, 0, 1, 0)
            .width(WIDTH as u16);

        let question = BobaStyle::new().render_surface(&apply_gradient(
            "Are you sure you want to eat marmalade?",
            hex_color("#EDFF82"),
            hex_color("#F25D94"),
        ));
        let ok_surf = self.ok_btn.render_to_surface(&Theme::default());
        let cancel_surf = self.cancel_btn.render_to_surface(&Theme::default());
        let buttons = join_horizontal(Position::Top, &[ok_surf, cancel_surf]);
        let dialog_inner = dialog_box.render_surface(&join_vertical(Position::Center, &[question, buttons]));
        let dialog = place_filled(
            WIDTH,
            9,
            Position::Center,
            Position::Center,
            &dialog_inner,
            Style::default().fg(subtle),
            "l o r e m ",
        );
        let mut dialog = dialog;
        fit_width(&mut dialog, WIDTH, &Cell::blank(Style::default().fg(subtle)));

        // Color grid
        let colors = color_grid_surf(14, 8);

        // Lists
        let list_header_style =
            BobaStyle::new().border(Border::normal().no_top().no_left().no_right()).border_fg(subtle).margin_right(2);
        let list_style = BobaStyle::new()
            .border(Border::normal().no_top().no_bottom().no_right())
            .border_fg(subtle)
            .margin_right(1)
            .height(8)
            .width((WIDTH / 3) as u16);

        let check = BobaStyle::new().fg(special).padding_right(1).render("");
        let gray_done = light_dark(has_dark_bg, hex_color("#969B86"), hex_color("#696969"));
        let list_done = |text: &str| -> Surface {
            let body = BobaStyle::new().crossed_out().fg(gray_done).render(text);
            join_horizontal(Position::Top, &[check.clone(), body])
        };
        let list_item = |text: &str| -> Surface { BobaStyle::new().padding_left(2).render(text) };

        let list1 = join_vertical(Position::Left, &[
            list_header_style.render("Citrus Fruits to Try"),
            list_done("Grapefruit"),
            list_done("Yuzu"),
            list_item("Citron"),
            list_item("Kumquat"),
            list_item("Pomelo"),
        ]);
        let list1 = list_style.render_surface(&list1);

        let list2 = join_vertical(Position::Left, &[
            list_header_style.render("Actual Boba Vendors"),
            list_item("Chatime"),
            list_item("Gong Cha"),
            list_done("Teaser"),
        ]);
        let list2 = list_style.render_surface(&list2);

        let lists = join_horizontal(Position::Top, &[list1, list2, BobaStyle::new().margin_left(1).render_surface(&colors)]);

        // History
        let history_style = BobaStyle::new()
            .align(Alignment::Left, Position::Top)
            .fg(hex_color("#FAFAFA"))
            .bg(highlight)
            .margin(1, 3, 0, 0)
            .padding(1, 2, 1, 2)
            .height(19)
            .width(COLUMN_WIDTH as u16);

        let history_a = history_style.clone().align(Alignment::Right, Position::Top).render(LOREM);
        let history_b = history_style.clone().align(Alignment::Center, Position::Top).render(LOREM);
        let history_c = history_style.align(Alignment::Left, Position::Top).margin_right(0).render(LOREM);
        let history = join_horizontal(Position::Top, &[history_a, history_b, history_c]);

        // Status bar
        let light_dark_state = if has_dark_bg { "Dark" } else { "Light" };
        let bar_style = BobaStyle::new()
            .fg(light_dark(has_dark_bg, hex_color("#343433"), hex_color("#C1C6B2")))
            .bg(light_dark(has_dark_bg, hex_color("#D9DCCF"), hex_color("#353533")));

        let status_key = status_seg("STATUS", hex_color("#FF5F87")).render("STATUS");
        let encoding = status_seg("UTF-8", hex_color("#A550DF")).align(Alignment::Right, Position::Center).render("UTF-8");
        let fish = status_seg("🍥 Fish Cake", hex_color("#6124DF")).render("🍥 Fish Cake");

        // Calculate remaining width after segments + their padding (2 cells each)
        let used = status_key.width() + 2 + encoding.width() + 2 + fish.width() + 2;
        let remaining = WIDTH.saturating_sub(used);

        let mut status_val = BobaStyle::new()
            .inherit(bar_style)
            .width(remaining as u16)
            .padding_x(1)
            .render(&format!("Ravishingly {}!", light_dark_state));
        clip(&mut status_val, remaining);
        fit_width(&mut status_val, remaining, &Cell::blank(*bar_style));

        let bar = join_horizontal(Position::Top, &[status_key, status_val, encoding, fish]);
        let mut status_bar = bar;
        let fill_cell = Cell::blank(
            Style::new().fg(light_dark(has_dark_bg, hex_color("#343433"), hex_color("#C1C6B2"))).bg(light_dark(
                has_dark_bg,
                hex_color("#D9DCCF"),
                hex_color("#353533"),
            )),
        );
        fit_width(&mut status_bar, WIDTH, &fill_cell);

        // Assemble document
        join_vertical(Position::Left, &[tabs_row, title_row, dialog, lists, history, status_bar])
    }

    fn build_modal(&self) -> Surface {
        BobaStyle::new()
            .italic()
            .fg(hex_color("#FFF7DB"))
            .bg(hex_color("#F25D94"))
            .padding(1, 6, 1, 6)
            .border(Border::rounded())
            .width(40u16)
            .align(Alignment::Center, Position::Center)
            .render("Now with Compositing!")
    }

    fn on_mouse(&self, ev: &MouseEvent) {
        if let Ok(tabs_area) = self.tabs_area.lock() {
            self.tabs.on_mouse(*tabs_area, ev);
        }
        if let Ok(ok_btn_area) = self.ok_btn_area.lock() {
            self.ok_btn.on_mouse(*ok_btn_area, ev);
        }
        if let Ok(cancel_btn_area) = self.cancel_btn_area.lock() {
            self.cancel_btn.on_mouse(*cancel_btn_area, ev);
        }
    }
}

impl View for LayoutView {
    fn mount(&self, app: &EventTarget<AppEvent>) {
        let app_for_quit = app.clone();
        app.on_key(SubscriptionPriority::High, move |ev, key| {
            if key.code == KeyCode::Char('q') {
                ev.cancel();
                app_for_quit.emit(AppEvent::Quit);
            }
        })
        .forget();
    }

    fn render(&self, ctx: &mut Frame<'_>, theme: &Theme) {
        let area = ctx.area();
        let buf = ctx.buffer_mut();

        // Clear with theme background
        let bg = theme.global_bg;
        for y in area.top()..area.bottom() {
            for x in area.left()..area.right() {
                buf[(x, y)].reset();
                buf[(x, y)].set_bg(bg);
            }
        }

        let doc = self.build_document();
        let modal = self.build_modal();

        // Debug border around document
        let doc_with_border = BobaStyle::new().border(Border::thick()).border_fg(hex_color("#FF0000")).render_surface(&doc);

        // Center document in available area
        let doc_w = doc_with_border.width().min(area.width as usize);
        let doc_h = doc_with_border.height().min(area.height as usize);
        let doc_x = (area.width as usize).saturating_sub(doc_w) / 2;
        let doc_y = (area.height as usize).saturating_sub(doc_h) / 2;

        // Update component areas
        if let Ok(mut tabs_area) = self.tabs_area.lock() {
            *tabs_area = Rect::new(doc_x as u16, doc_y as u16, doc_w as u16, 3);
        }

        // Modal position (relative to document center)
        let modal_x = doc_x + 58;
        let modal_y = doc_y + 44;

        let comp = Compositor::new(vec![
            CompositorLayer::new(doc_with_border).x(doc_x as u16).y(doc_y as u16),
            CompositorLayer::new(modal).x(modal_x as u16).y(modal_y as u16),
        ]);
        comp.render_to_buf(area, buf);
    }
}

const LOREM: &str = "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque \
                     sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna \
                     tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada \
                     lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora \
                     torquent per conubia nostra inceptos himenaeos.";

fn main() {
    let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap();
    rt.block_on(async {
        App::new(LayoutView::new()).run().await.unwrap();
    });
}