use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use super::super::app::{App, Control, Link};
use super::super::keymap::{Binding, ENTRY_ROWS, Group, KEY_CELL, TEXT_CELL, rows};
use super::super::theme::Palette;
use super::flock::fit;
use super::{overlay, status};
pub(super) const INTERIOR: u16 = 126;
const COLUMN_COUNT: u16 = 4;
const GAP: u16 = 1;
const GUTTER: u16 = 2;
const COLUMN: u16 = KEY_CELL + GAP + TEXT_CELL;
const SHEEP_FIRST_ROW: usize = 4;
const SHEEP: [&str; 4] = [
" \u{259f}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2599}",
" \u{259f}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2599}",
" \u{259c}\u{2588}\u{2588}\u{2580} \u{2580}\u{2588}\u{2588}\u{259b}",
" \u{2580}\u{2598} \u{2580}\u{2598}",
];
fn gutter_span() -> Span<'static> {
Span::raw(" ".repeat(usize::from(GUTTER)))
}
fn heading_line_for(groups: &[Group], palette: Palette) -> Line<'static> {
let mut spans = Vec::with_capacity(groups.len() * 2);
for (index, &group) in groups.iter().enumerate() {
if index > 0 {
spans.push(gutter_span());
}
spans.push(Span::styled(
fit(group.heading(), COLUMN),
palette.band(group.role()),
));
}
Line::from(spans)
}
fn heading_line(palette: Palette) -> Line<'static> {
heading_line_for(&Group::DRAWN, palette)
}
fn entry_line_for(
groups: &[Group],
all_rows: &[Binding],
index: usize,
palette: Palette,
show_sheep: bool,
) -> Line<'static> {
let mut spans = Vec::new();
for (column, &group) in groups.iter().enumerate() {
if column > 0 {
spans.push(gutter_span());
}
let binding = all_rows
.iter()
.copied()
.filter(|row| row.group == group)
.nth(index);
spans.extend(entry_cell(group, binding, index, palette, show_sheep));
}
Line::from(spans)
}
fn entry_cell(
group: Group,
binding: Option<Binding>,
index: usize,
palette: Palette,
show_sheep: bool,
) -> Vec<Span<'static>> {
if let Some(binding) = binding {
return vec![
Span::styled(fit(binding.keys, KEY_CELL), palette.attention()),
Span::raw(format!(
"{}{}",
" ".repeat(usize::from(GAP)),
fit(binding.does, TEXT_CELL)
)),
];
}
if show_sheep
&& group == Group::Doing
&& let Some(row) = index
.checked_sub(SHEEP_FIRST_ROW)
.and_then(|row| SHEEP.get(row))
{
return vec![Span::raw(fit(row, COLUMN))];
}
vec![Span::raw(" ".repeat(usize::from(COLUMN)))]
}
fn gate_line(app: &App, palette: Palette, width: u16) -> Line<'static> {
Line::from(Span::styled(
fit(&gate_text(app), width),
palette.attention(),
))
}
fn gate_text(app: &App) -> String {
if matches!(app.link(), Link::Lost { .. }) {
" the three above are refused \u{b7} \u{2588} the link is down \u{b7} nothing acts"
.to_string()
} else {
let label = match app.control() {
Control::ReadOnly => status::READ_ONLY_LABEL,
Control::Allowed => status::CONTROL_ENABLED_LABEL,
};
format!(
" the three above each arm a confirm \u{b7} \u{21b5} confirms \u{b7} 10s to \
answer \u{b7} \u{2588} {label}"
)
}
}
const fn colour_sentence() -> &'static str {
" colour is decoration only: every coloured cell says the same \
thing in words. NO_COLOR loses nothing but the colour."
}
#[track_caller]
fn quit_text(all_rows: &[Binding]) -> String {
let quit = all_rows
.iter()
.find(|row| row.group == Group::Closing)
.expect("binding() always gives Quit a Closing row");
format!(" h or ? closes this \u{b7} {} quits lookout", quit.keys)
}
fn quit_line(all_rows: &[Binding], palette: Palette, width: u16) -> Line<'static> {
Line::from(Span::styled(
fit(&quit_text(all_rows), width),
palette.muted(),
))
}
fn closing_lines(all_rows: &[Binding], palette: Palette, width: u16) -> [Line<'static>; 2] {
[
Line::from(Span::styled(fit(colour_sentence(), width), palette.muted())),
quit_line(all_rows, palette, width),
]
}
fn folded_gate_and_quit_line(
app: &App,
all_rows: &[Binding],
palette: Palette,
width: u16,
) -> Line<'static> {
let text = format!(
"{} \u{b7} {}",
gate_text(app).trim(),
quit_text(all_rows).trim()
);
Line::from(Span::styled(fit(&text, width), palette.attention()))
}
#[must_use]
pub(super) fn lines(app: &App, interior: u16) -> Vec<Line<'static>> {
let palette = app.palette();
let all_rows = rows();
let mut out = Vec::with_capacity(ENTRY_ROWS + 5);
out.push(heading_line(palette));
out.extend(
(0..ENTRY_ROWS).map(|index| entry_line_for(&Group::DRAWN, &all_rows, index, palette, true)),
);
out.push(Line::default());
out.push(gate_line(app, palette, interior));
out.extend(closing_lines(&all_rows, palette, interior));
out
}
const fn columns_for(width: u16) -> u16 {
let fits = width.saturating_add(GUTTER) / (COLUMN + GUTTER);
if fits < 1 {
1
} else if fits > COLUMN_COUNT {
COLUMN_COUNT
} else {
fits
}
}
const HEIGHT_FLOOR: u16 = 13;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Shed {
Boxed,
Nothing,
Decoration,
Blank,
Gate,
Refuse,
}
const fn rows_for_height(height: u16) -> Shed {
if height >= 19 {
Shed::Boxed
} else if height >= 17 {
Shed::Nothing
} else if height == 16 {
Shed::Decoration
} else if height == 15 {
Shed::Blank
} else if height >= HEIGHT_FLOOR {
Shed::Gate
} else {
Shed::Refuse
}
}
pub(super) fn draw(app: &App, area: Rect, buffer: &mut Buffer) {
let palette = app.palette();
if overlay::is_boxed(area.width, INTERIOR) {
let lines = lines(app, INTERIOR);
if overlay::boxed_height(&lines) <= area.height {
overlay::draw_boxed(&lines, INTERIOR, palette, palette.ground(), area, buffer);
return;
}
}
draw_borderless(app, area, buffer, palette);
}
fn draw_borderless(app: &App, area: Rect, buffer: &mut Buffer, palette: Palette) {
let all_rows = rows();
let columns = usize::from(columns_for(area.width));
let banks: Vec<&[Group]> = Group::DRAWN.chunks(columns).collect();
let bank_count = u16::try_from(banks.len()).expect("at most COLUMN_COUNT banks of groups");
let extra_banks_cost = bank_count.saturating_sub(1) * (HEIGHT_FLOOR + 1);
let effective_height = area.height.saturating_sub(extra_banks_cost);
let shed = rows_for_height(effective_height);
let lines = if shed == Shed::Refuse {
let needed = extra_banks_cost + HEIGHT_FLOOR;
vec![Line::from(Span::styled(
format!(
"the keymap needs {needed} rows, this terminal has {}",
area.height
),
palette.refusal(),
))]
} else {
borderless_lines(
&banks,
&all_rows,
app,
palette,
area.width,
effective_height,
shed,
)
};
let blank = overlay::blank_of(area.width);
for (offset, line) in lines.iter().enumerate() {
let Ok(offset) = u16::try_from(offset) else {
break;
};
if offset >= area.height {
break;
}
let y = area.y + offset;
overlay::blank_row(buffer, area.x, y, &blank, palette.ground());
buffer.set_line(area.x, y, line, area.width);
}
}
fn borderless_lines(
banks: &[&[Group]],
all_rows: &[Binding],
app: &App,
palette: Palette,
width: u16,
effective_height: u16,
shed: Shed,
) -> Vec<Line<'static>> {
let show_sheep = false;
let mut out = Vec::new();
for (index, bank) in banks.iter().enumerate() {
if index > 0 {
out.push(Line::default());
}
out.push(heading_line_for(bank, palette));
out.extend(
(0..ENTRY_ROWS).map(|row| entry_line_for(bank, all_rows, row, palette, show_sheep)),
);
}
match shed {
Shed::Boxed | Shed::Nothing => {
out.push(Line::default());
out.push(gate_line(app, palette, width));
out.extend(closing_lines(all_rows, palette, width));
}
Shed::Decoration => {
out.push(Line::default());
out.push(gate_line(app, palette, width));
out.push(quit_line(all_rows, palette, width));
}
Shed::Blank => {
out.push(gate_line(app, palette, width));
out.push(quit_line(all_rows, palette, width));
}
Shed::Gate => {
if effective_height > HEIGHT_FLOOR {
out.push(folded_gate_and_quit_line(app, all_rows, palette, width));
}
}
Shed::Refuse => unreachable!("draw_borderless already returned on Shed::Refuse"),
}
out
}
#[cfg(test)]
mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
use super::super::MIN_TERM_WIDTH;
use super::*;
use crate::lookout::app::{KeyPress, Msg};
use crate::lookout::frames::render_text;
use crate::lookout::view::fixtures;
use crate::output::width::visible_width;
#[test]
fn the_columns_sum_to_the_interior() {
assert_eq!((KEY_CELL, GAP, TEXT_CELL), (12, 1, 17));
assert_eq!(
COLUMN * COLUMN_COUNT + GUTTER * (COLUMN_COUNT - 1),
INTERIOR
);
assert_eq!(COLUMN, 30);
assert_eq!(INTERIOR, 126);
}
#[test]
fn every_row_is_the_interior_wide() {
let app = app_with_overlay();
for line in lines(&app, INTERIOR) {
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(
text.is_empty() || visible_width(&text) == usize::from(INTERIOR),
"a row is {} cells against {INTERIOR}: {text:?}",
visible_width(&text)
);
}
}
#[test]
fn the_box_is_nineteen_rows() {
let app = app_with_overlay();
assert_eq!(overlay::boxed_height(&lines(&app, INTERIOR)), 19);
}
#[test]
fn the_four_headings_sit_at_their_column_starts() {
assert_eq!(
Group::DRAWN,
[Group::Moving, Group::Looking, Group::Changing, Group::Doing]
);
let app = app_with_overlay();
let rendered = render_overlay(&app, 160, 48);
let heading_row = rendered
.lines()
.find(|row| row.contains(Group::Moving.heading()))
.expect("no heading row");
let chars: Vec<char> = heading_row.chars().collect();
const MARGIN: usize = 16;
assert_eq!(usize::from(160 - (INTERIOR + 2)) / 2, MARGIN);
for (index, group) in Group::DRAWN.iter().enumerate() {
let offset = usize::from(COLUMN + GUTTER) * index;
let start = MARGIN + 1 + offset;
let from_start: String = chars[start..].iter().collect();
assert!(
from_start.starts_with(group.heading()),
"{} is not at column {start}: {heading_row:?}",
group.heading()
);
}
}
#[test]
fn every_derived_row_is_drawn() {
let app = app_with_overlay();
let rendered = render_overlay(&app, 160, 48);
for row in crate::lookout::keymap::rows() {
let wanted = if row.group == Group::Closing {
row.keys
} else {
row.does
};
assert!(
rendered.contains(wanted),
"`{wanted}` ({}) never reached the screen",
row.keys
);
}
}
#[test]
fn the_gate_line_names_the_gate_and_then_the_link() {
let allowed = render_overlay(&app_with_overlay(), 160, 48);
assert!(allowed.contains("control enabled"), "{allowed}");
assert!(!allowed.contains("read-only"));
assert!(!allowed.contains("the link is down"));
let read_only = render_overlay(&read_only_app_with_overlay(), 160, 48);
assert!(read_only.contains("read-only"), "{read_only}");
assert!(!read_only.contains("control enabled"));
assert!(!read_only.contains("the link is down"));
let frozen = render_overlay(&frozen_app_with_overlay(), 160, 48);
assert!(frozen.contains("the link is down"), "{frozen}");
assert!(
!frozen.contains("control enabled") && !frozen.contains("read-only"),
"a frozen overlay still names the control gate: {frozen}"
);
let frozen_read_only = render_overlay(&frozen_read_only_app_with_overlay(), 160, 48);
assert!(
frozen_read_only.contains("the link is down"),
"{frozen_read_only}"
);
assert!(
!frozen_read_only.contains("control enabled")
&& !frozen_read_only.contains("read-only"),
"a frozen, read-only overlay names the control gate instead of the link: {frozen_read_only}"
);
}
#[test]
fn the_sheep_draws_in_the_doing_column() {
let app = app_with_overlay();
let rendered = render_overlay(&app, 160, 48);
assert!(rendered.contains(SHEEP[0]), "no sheep: {rendered}");
let row = rendered
.lines()
.find(|row| row.contains(SHEEP[0]))
.expect("no sheep row");
let column = row
.find(SHEEP[0])
.map(|at| row[..at].chars().count())
.expect("no sheep on the row that contains it");
let doing = 113..113 + usize::from(COLUMN);
assert!(
doing.contains(&column),
"the sheep starts at column {column}, outside DOING's {doing:?}: {row:?}"
);
const UNDER_THE_HEADING: usize = 5;
assert_eq!(1 + SHEEP_FIRST_ROW, UNDER_THE_HEADING);
let rows: Vec<&str> = rendered.lines().collect();
let heading_at = rows
.iter()
.position(|row| row.contains(Group::Moving.heading()))
.expect("no heading row");
let sheep_at = rows
.iter()
.position(|row| row.contains(SHEEP[0]))
.expect("no sheep row");
assert_eq!(
sheep_at - heading_at,
UNDER_THE_HEADING,
"the sheep is {} rows under the heading, not {UNDER_THE_HEADING}",
sheep_at - heading_at
);
}
#[test]
fn the_overlay_renders_over_the_four_bodies_a_test_can_reach() {
let sheep = {
let mut app = fixtures::full_app();
let _ = app.update(Msg::Key(KeyPress::Confirm));
assert!(app.sheep_pane().is_some(), "not in the sheep pane");
app
};
let bleats = {
let mut app = fixtures::full_app();
let _ = app.update(Msg::Key(KeyPress::Confirm));
let _ = app.update(Msg::Key(KeyPress::Bleats));
assert!(app.bleats_pane().is_some(), "not in the bleats pane");
app
};
let config = {
let app = fixtures::app_in_sheep_pane();
assert!(app.config_pane().is_some(), "not in the config pane");
app
};
let secrets = {
let app = fixtures::app_with_secrets();
assert!(app.secrets_pane_is_open(), "not in the secrets pane");
app
};
for (name, mut app) in [
("sheep", sheep),
("bleats", bleats),
("config", config),
("secrets", secrets),
] {
let _ = app.update(Msg::Key(KeyPress::Help));
assert!(app.keymap_open(), "{name}: the overlay did not open");
let rendered = render_overlay(&app, 160, 48);
for group in Group::DRAWN {
assert!(
rendered.contains(group.heading()),
"{name}: {} missing from the overlay",
group.heading()
);
}
assert!(
rendered.contains(&top_border_row()),
"{name}: no box border over this body"
);
}
}
#[test]
fn the_body_behind_is_dimmed() {
let app = app_with_overlay();
let mut terminal = Terminal::new(TestBackend::new(160, 48)).expect("terminal");
terminal
.draw(|frame| super::super::draw(&app, frame))
.expect("draw");
let behind = terminal.backend().buffer()[(2, 4)].style();
assert_eq!(behind.fg, fixtures::plain_dimmed().fg);
}
#[test]
fn the_interior_carries_the_paper_two_ground() {
let app = coloured_app_with_overlay();
let mut terminal = Terminal::new(TestBackend::new(160, 48)).expect("terminal");
terminal
.draw(|frame| super::super::draw(&app, frame))
.expect("draw");
let buffer = terminal.backend().buffer();
let ground = app.palette().ground().bg;
assert!(
!matches!(ground, None | Some(ratatui::style::Color::Reset)),
"fixtures::coloured() should give `Palette::ground` a real colour"
);
let inside = buffer[(20, 20)].style();
assert_eq!(
inside.bg, ground,
"the interior does not carry the paper-2 ground: {inside:?}"
);
let outside = buffer[(2, 20)].style();
assert_ne!(
outside.bg, ground,
"the dimmed body behind the box carries the interior's own ground: {outside:?}"
);
}
#[test]
fn the_column_ladder_has_a_boundary_on_each_side() {
assert_eq!(columns_for(126), 4);
assert_eq!(columns_for(125), 3);
assert_eq!(columns_for(94), 3);
assert_eq!(columns_for(93), 2);
assert_eq!(columns_for(62), 2);
assert_eq!(columns_for(61), 1);
assert_eq!(columns_for(MIN_TERM_WIDTH), 1);
assert_eq!(columns_for(158), COLUMN_COUNT, "five columns' room");
assert_eq!(columns_for(u16::MAX - GUTTER), COLUMN_COUNT);
}
fn top_border_row() -> String {
format!(
"{}{}{}",
overlay::BOX_TOP_LEFT,
overlay::BOX_TOP.to_string().repeat(usize::from(INTERIOR)),
overlay::BOX_TOP_RIGHT
)
}
#[test]
fn one_column_under_the_floor_keeps_four_columns_and_loses_the_border() {
let app = app_with_overlay();
let boxed = render_overlay(&app, 130, 48);
let bare = render_overlay(&app, 129, 48);
let border = top_border_row();
assert!(boxed.contains(&border), "130 must be boxed: {boxed}");
assert!(!bare.contains(&border), "129 must not be: {bare}");
for group in Group::DRAWN {
assert!(
bare.contains(group.heading()),
"{} is missing at 129 columns",
group.heading()
);
}
}
#[test]
fn three_columns_put_doing_on_its_own_bank() {
let rendered = render_overlay(&app_with_overlay(), 100, 48);
let heading_rows: Vec<&str> = rendered
.lines()
.filter(|row| Group::DRAWN.iter().any(|g| row.contains(g.heading())))
.collect();
assert_eq!(heading_rows.len(), 2, "{heading_rows:?}");
for group in [Group::Moving, Group::Looking, Group::Changing] {
assert!(
heading_rows[0].contains(group.heading()),
"{} missing from the first bank: {:?}",
group.heading(),
heading_rows[0]
);
}
assert!(heading_rows[1].contains(Group::Doing.heading()));
assert!(
!heading_rows[1].contains(Group::Moving.heading()),
"the second bank should hold DOING alone: {:?}",
heading_rows[1]
);
}
#[test]
fn the_height_ladder_shows_its_boundaries() {
assert_eq!(rows_for_height(19), Shed::Boxed);
assert_eq!(rows_for_height(18), Shed::Nothing);
assert_eq!(rows_for_height(17), Shed::Nothing);
assert_eq!(rows_for_height(16), Shed::Decoration);
assert_eq!(rows_for_height(15), Shed::Blank);
assert_eq!(rows_for_height(14), Shed::Gate);
assert_eq!(rows_for_height(13), Shed::Gate);
assert_eq!(rows_for_height(12), Shed::Refuse);
}
#[test]
fn a_short_terminal_sheds_the_decoration_and_keeps_the_keys() {
let app = app_with_overlay();
let rendered = render_overlay(&app, 160, 16);
assert!(
!rendered.contains(SHEEP[0]),
"the sheep survived: {rendered}"
);
assert!(
!rendered.contains("decoration only"),
"the NO_COLOR line survived: {rendered}"
);
assert_every_visible_row_drawn(&rendered, "shed with the decoration");
}
#[test]
fn too_short_refuses_instead_of_clipping() {
let rendered = render_overlay(&app_with_overlay(), 160, 12);
assert!(
rendered.contains("the keymap needs"),
"no refusal at 12 rows: {rendered}"
);
assert!(
!rendered.contains(Group::Moving.heading()),
"a partial list drew anyway: {rendered}"
);
let app = coloured_app_with_overlay();
let mut terminal = Terminal::new(TestBackend::new(160, 12)).expect("terminal");
terminal
.draw(|frame| super::super::draw(&app, frame))
.expect("draw");
let refusal = app.palette().refusal().fg;
assert!(
refusal.is_some(),
"fixtures::coloured() should give Palette::refusal a real colour"
);
assert_eq!(
terminal.backend().buffer()[(0, 0)].style().fg,
refusal,
"the refusal line does not carry palette.refusal()'s own colour"
);
}
#[test]
fn a_multi_bank_width_crosses_the_height_ladder_cleanly() {
let app = app_with_overlay();
let width = 100;
let bank_count =
u16::try_from(Group::DRAWN.chunks(usize::from(columns_for(width))).count())
.expect("four groups never chunk into more banks than fit a u16");
let boundary = bank_count.saturating_sub(1) * (HEIGHT_FLOOR + 1) + HEIGHT_FLOOR;
let mut refusals = 0;
let mut whole_draws = 0;
for height in boundary.saturating_sub(4)..=boundary + 4 {
let rendered = render_overlay(&app, width, height);
let headings_present = Group::DRAWN
.iter()
.filter(|group| rendered.contains(group.heading()))
.count();
let refused = rendered.contains("the keymap needs");
let whole = headings_present == Group::DRAWN.len();
assert!(
refused != whole,
"height {height} is neither a clean refusal nor a whole draw \
({headings_present} of {} headings): {rendered}",
Group::DRAWN.len()
);
assert_eq!(
refused,
height < boundary,
"height {height} against a boundary of {boundary}: {rendered}"
);
if refused {
refusals += 1;
assert_eq!(
headings_present, 0,
"height {height} refused but still drew a heading: {rendered}"
);
} else {
whole_draws += 1;
assert_every_visible_row_drawn(
&rendered,
&format!("height {height} drew the headings but a row went missing"),
);
}
}
assert!(
refusals > 0 && whole_draws > 0,
"the sweep never crossed the boundary of {boundary}: {refusals} refusals and {whole_draws} whole draws"
);
}
fn render_overlay(app: &App, width: u16, height: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
terminal
.draw(|frame| super::super::draw(app, frame))
.expect("draw");
render_text(terminal.backend().buffer())
}
fn assert_every_visible_row_drawn(rendered: &str, context: &str) {
for row in crate::lookout::keymap::rows() {
if row.group == Group::Closing {
continue;
}
assert!(
rendered.contains(row.does),
"{context}, but `{}` is missing: {rendered}",
row.does
);
}
}
fn healthy_app() -> App {
healthy_app_with_palette(fixtures::plain())
}
fn healthy_app_with_palette(palette: Palette) -> App {
let mut app = fixtures::app_with(
vec![
ProcessInfo::builder(1, "web", ProcStatus::Online)
.pid(Some(48_001))
.build(),
],
palette,
);
app.set_control_for_tests(Control::Allowed);
app
}
fn app_with_overlay() -> App {
let mut app = healthy_app();
let _ = app.update(Msg::Key(KeyPress::Help));
assert!(app.keymap_open(), "the overlay did not open");
app
}
fn coloured_app_with_overlay() -> App {
let mut app = healthy_app_with_palette(fixtures::coloured());
let _ = app.update(Msg::Key(KeyPress::Help));
assert!(app.keymap_open(), "the overlay did not open");
app
}
fn read_only_app_with_overlay() -> App {
let mut app = healthy_app();
app.set_control_for_tests(Control::ReadOnly);
let _ = app.update(Msg::Key(KeyPress::Help));
assert!(app.keymap_open(), "the overlay did not open");
app
}
fn frozen_app_with_overlay() -> App {
let mut app = healthy_app();
let _ = app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
why: fixtures::FROZEN_WHY.to_string(),
});
let _ = app.update(Msg::Key(KeyPress::Help));
assert!(app.keymap_open(), "the overlay did not open");
app
}
fn frozen_read_only_app_with_overlay() -> App {
let mut app = healthy_app();
app.set_control_for_tests(Control::ReadOnly);
let _ = app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
why: fixtures::FROZEN_WHY.to_string(),
});
let _ = app.update(Msg::Key(KeyPress::Help));
assert!(app.keymap_open(), "the overlay did not open");
app
}
}