1use {
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
33const 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 let tabs_row = self.tabs.render_to_surface();
153
154 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 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 let colors = color_grid_surf(14, 8);
241
242 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 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 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 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 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 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 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 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 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}