use std::sync::{Arc, Mutex};
use ratatui_core::backend::Backend;
use ratatui_core::buffer::Buffer;
use ratatui_core::terminal::{Terminal, Viewport};
use ratatui_core::text::Line;
use crate::components::Text;
use crate::geometry::Size;
use crate::style::Theme;
use crate::surface::Surface;
use crate::view::{Element, RenderCtx, View, element};
pub const DEFAULT_FOOTER_HEIGHT: u16 = 12;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ScreenMode {
#[default]
Alternate,
SplitFooter {
height: u16,
mouse_capture: bool,
},
}
impl ScreenMode {
pub fn split_footer(height: u16) -> Self {
Self::SplitFooter {
height: height.max(1),
mouse_capture: false,
}
}
pub fn default_split_footer() -> Self {
Self::split_footer(DEFAULT_FOOTER_HEIGHT)
}
pub fn with_mouse_capture(self) -> Self {
match self {
Self::Alternate => Self::Alternate,
Self::SplitFooter { height, .. } => Self::SplitFooter {
height,
mouse_capture: true,
},
}
}
pub fn is_alternate(self) -> bool {
matches!(self, Self::Alternate)
}
pub fn footer_height(self) -> Option<u16> {
match self {
Self::Alternate => None,
Self::SplitFooter { height, .. } => Some(height),
}
}
pub fn captures_mouse(self) -> bool {
match self {
Self::Alternate => true,
Self::SplitFooter { mouse_capture, .. } => mouse_capture,
}
}
pub fn viewport(self) -> Viewport {
match self {
Self::Alternate => Viewport::Fullscreen,
Self::SplitFooter { height, .. } => Viewport::Inline(height),
}
}
}
type Build = Box<dyn FnOnce(u16) -> Element + Send>;
struct Entry {
rows: Option<u16>,
build: Build,
}
#[derive(Clone, Default)]
pub struct Scrollback {
queue: Arc<Mutex<Vec<Entry>>>,
}
impl Scrollback {
pub const MAX_BLOCK_ROWS: u16 = 4096;
pub fn new() -> Self {
Self::default()
}
pub fn write(&self, build: impl FnOnce(u16) -> Element + Send + 'static) {
self.push(Entry {
rows: None,
build: Box::new(build),
});
}
pub fn write_rows(&self, rows: u16, build: impl FnOnce(u16) -> Element + Send + 'static) {
self.push(Entry {
rows: Some(rows),
build: Box::new(build),
});
}
pub fn write_lines(&self, lines: Vec<Line<'static>>) {
self.write(move |_width| element(Text::new(lines)));
}
pub fn is_empty(&self) -> bool {
self.lock().is_empty()
}
pub fn clear(&self) {
self.lock().clear();
}
pub fn flush<B: Backend>(
&self,
terminal: &mut Terminal<B>,
theme: &Theme,
) -> Result<bool, B::Error> {
let entries = std::mem::take(&mut *self.lock());
if entries.is_empty() {
return Ok(false);
}
let width = terminal.get_frame().area().width;
if width == 0 || terminal.size()?.height == 0 {
return Ok(false);
}
let ctx = RenderCtx::new(theme);
for entry in entries {
let view = (entry.build)(width);
commit(terminal, view.as_ref(), entry.rows, &ctx)?;
}
Ok(true)
}
fn push(&self, entry: Entry) {
self.lock().push(entry);
}
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Entry>> {
self.queue.lock().unwrap_or_else(|error| error.into_inner())
}
}
pub fn publish_block<B: Backend>(
terminal: &mut Terminal<B>,
view: &dyn View,
ctx: &RenderCtx,
) -> Result<(), B::Error> {
commit(terminal, view, None, ctx)
}
fn commit<B: Backend>(
terminal: &mut Terminal<B>,
view: &dyn View,
rows: Option<u16>,
ctx: &RenderCtx,
) -> Result<(), B::Error> {
let width = terminal.get_frame().area().width;
if width == 0 || terminal.size()?.height == 0 {
return Ok(());
}
let rows = block_rows(rows, view, width);
terminal.insert_before(rows, |buffer| paint_block(buffer, view, ctx))
}
fn block_rows(requested: Option<u16>, view: &dyn View, width: u16) -> u16 {
requested
.unwrap_or_else(|| {
view.measure(Size::new(width, Scrollback::MAX_BLOCK_ROWS))
.height
})
.clamp(1, Scrollback::MAX_BLOCK_ROWS)
}
fn paint_block(buffer: &mut Buffer, view: &dyn View, ctx: &RenderCtx) {
let area = buffer.area;
let mut surface = Surface::new(buffer, area);
view.render(area, &mut surface, ctx);
}
pub fn pin_footer<B: Backend>(terminal: &mut Terminal<B>) -> Result<(), B::Error> {
let screen = terminal.size()?.height;
let viewport = terminal.get_frame().area();
let gap = screen.saturating_sub(viewport.bottom());
if gap > 0 {
terminal.insert_before(gap, |_| {})?;
}
Ok(())
}
pub fn close_footer<B: Backend>(terminal: &mut Terminal<B>) -> Result<(), B::Error> {
let area = terminal.get_frame().area();
if area.is_empty() {
return Ok(());
}
terminal.clear()?;
terminal.set_cursor_position(area.as_position())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::StyleSheet;
use crate::tests::support::rainbow_theme;
use crate::view::element;
use ratatui_core::backend::TestBackend;
use ratatui_core::layout::{Position, Rect};
use ratatui_core::style::{Color, Style};
use ratatui_core::terminal::TerminalOptions;
use ratatui_core::text::Span;
fn footer_terminal(
width: u16,
height: u16,
footer: u16,
cursor_row: u16,
) -> Terminal<TestBackend> {
let mut backend = TestBackend::new(width, height);
backend
.set_cursor_position(Position::new(0, cursor_row))
.expect("place cursor");
Terminal::with_options(
backend,
TerminalOptions {
viewport: ScreenMode::split_footer(footer).viewport(),
},
)
.expect("inline terminal")
}
fn pinned(width: u16, height: u16, footer: u16) -> Terminal<TestBackend> {
let mut terminal = footer_terminal(width, height, footer, 0);
pin_footer(&mut terminal).expect("pin");
terminal
}
fn draw_footer(terminal: &mut Terminal<TestBackend>, text: &str) {
terminal
.draw(|frame| {
let area = frame.area();
let theme = Theme::default();
let lines = vec![Line::from(text.to_string()); area.height as usize];
let view = element(Text::new(lines));
crate::paint(frame.buffer_mut(), area, &theme, view.as_ref(), &[]);
})
.expect("draw footer");
}
fn screen_lines(terminal: &Terminal<TestBackend>) -> Vec<String> {
let buffer = terminal.backend().buffer();
(buffer.area.top()..buffer.area.bottom())
.map(|y| crate::tests::support::row(buffer, y))
.collect()
}
fn write_text(scrollback: &Scrollback, text: &'static str) {
scrollback.write(move |_width| element(Text::raw(text)));
}
#[test]
fn split_footer_clamps_zero_height_and_defaults_off_mouse_capture() {
let mode = ScreenMode::split_footer(0);
assert_eq!(mode.footer_height(), Some(1));
assert!(!mode.captures_mouse());
assert!(!mode.is_alternate());
}
#[test]
fn mouse_capture_is_opt_in_for_a_footer_and_keeps_its_height() {
let mode = ScreenMode::split_footer(4).with_mouse_capture();
assert!(mode.captures_mouse());
assert_eq!(mode.footer_height(), Some(4));
assert_eq!(mode.with_mouse_capture(), mode);
assert!(!mode.is_alternate());
}
#[test]
fn alternate_is_the_default_and_always_captures_the_mouse() {
let mode = ScreenMode::default();
assert_eq!(mode, ScreenMode::Alternate);
assert_eq!(mode.viewport(), Viewport::Fullscreen);
assert_eq!(mode.footer_height(), None);
assert!(mode.captures_mouse());
assert!(mode.is_alternate());
assert_eq!(mode.with_mouse_capture(), ScreenMode::Alternate);
}
#[test]
fn split_footer_renders_into_an_inline_viewport_of_its_height() {
assert_eq!(ScreenMode::split_footer(6).viewport(), Viewport::Inline(6));
assert_eq!(
ScreenMode::default_split_footer().footer_height(),
Some(DEFAULT_FOOTER_HEIGHT)
);
assert_eq!(
ScreenMode::default_split_footer().viewport(),
Viewport::Inline(DEFAULT_FOOTER_HEIGHT)
);
}
#[test]
fn a_measured_block_takes_the_rows_its_view_asks_for() {
let view = element(Text::new(vec![Line::from("a"), Line::from("b")]));
assert_eq!(block_rows(None, view.as_ref(), 10), 2);
}
#[test]
fn a_zero_row_block_is_clamped_to_one_row() {
let view = element(Text::raw("x"));
assert_eq!(block_rows(Some(0), view.as_ref(), 10), 1);
}
#[test]
fn an_oversized_block_is_clamped_to_the_row_ceiling() {
struct Tall;
impl View for Tall {
fn measure(&self, _available: Size) -> Size {
Size::new(1, u16::MAX)
}
fn render(&self, _area: Rect, _surface: &mut Surface, _ctx: &RenderCtx) {}
}
assert_eq!(
block_rows(None, &Tall, 10),
Scrollback::MAX_BLOCK_ROWS,
"a measured height is clamped"
);
assert_eq!(
block_rows(Some(u16::MAX), &Tall, 10),
Scrollback::MAX_BLOCK_ROWS,
"a requested height is clamped"
);
}
#[test]
fn pin_footer_moves_the_viewport_to_the_bottom_and_stays_there() {
let mut terminal = footer_terminal(12, 10, 3, 0);
assert_eq!(terminal.get_frame().area(), Rect::new(0, 0, 12, 3));
pin_footer(&mut terminal).expect("pin");
assert_eq!(
terminal.get_frame().area(),
Rect::new(0, 7, 12, 3),
"the footer occupies the last three rows"
);
pin_footer(&mut terminal).expect("re-pin");
assert_eq!(terminal.get_frame().area(), Rect::new(0, 7, 12, 3));
}
#[test]
fn pin_footer_repins_to_the_new_bottom_after_a_resize() {
let mut terminal = pinned(12, 10, 3);
assert_eq!(terminal.get_frame().area(), Rect::new(0, 7, 12, 3));
terminal.backend_mut().resize(12, 16);
terminal.autoresize().expect("autoresize");
pin_footer(&mut terminal).expect("re-pin");
assert_eq!(terminal.get_frame().area().bottom(), 16);
assert_eq!(terminal.get_frame().area().height, 3);
terminal.backend_mut().resize(12, 8);
terminal.autoresize().expect("autoresize");
pin_footer(&mut terminal).expect("re-pin");
assert_eq!(terminal.get_frame().area().bottom(), 8);
assert_eq!(terminal.get_frame().area().height, 3);
}
#[test]
fn a_footer_taller_than_the_screen_is_clamped_to_it() {
let theme = Theme::default();
let mut terminal = footer_terminal(8, 3, 40, 0);
assert_eq!(terminal.get_frame().area(), Rect::new(0, 0, 8, 3));
pin_footer(&mut terminal).expect("pin");
assert_eq!(terminal.get_frame().area(), Rect::new(0, 0, 8, 3));
let scrollback = Scrollback::new();
write_text(&scrollback, "note");
assert!(scrollback.flush(&mut terminal, &theme).expect("flush"));
}
#[test]
fn flushed_blocks_land_above_the_footer_in_order() {
let theme = Theme::default();
let mut terminal = pinned(12, 8, 2);
draw_footer(&mut terminal, "FOOTER");
let scrollback = Scrollback::new();
write_text(&scrollback, "first");
scrollback.write_lines(vec![Line::from("second"), Line::from("third")]);
assert!(
scrollback
.flush(&mut terminal, &theme)
.expect("flush blocks"),
"flushing published blocks reports that the footer needs a repaint"
);
assert!(scrollback.is_empty(), "the queue is drained by a flush");
draw_footer(&mut terminal, "FOOTER");
let lines = screen_lines(&terminal);
assert_eq!(
&lines[3..],
&["first", "second", "third", "FOOTER", "FOOTER"],
"blocks stack above the footer in publication order: {lines:?}"
);
}
#[test]
fn a_pinned_row_count_wins_over_the_measurement() {
let theme = Theme::default();
let mut terminal = pinned(12, 8, 2);
fn lines(texts: [&str; 3]) -> Vec<Line<'static>> {
texts.map(|text| Line::from(text.to_string())).to_vec()
}
let scrollback = Scrollback::new();
scrollback.write(|_width| element(Text::new(lines(["a", "b", "c"]))));
scrollback.write_rows(1, |_width| element(Text::new(lines(["x", "y", "z"]))));
scrollback.flush(&mut terminal, &theme).expect("flush");
draw_footer(&mut terminal, "F");
let lines = screen_lines(&terminal);
assert_eq!(&lines[2..6], &["a", "b", "c", "x"], "{lines:?}");
}
#[test]
fn a_block_is_built_at_the_terminal_width_and_clipped_to_it() {
let theme = Theme::default();
let mut terminal = pinned(10, 6, 2);
let scrollback = Scrollback::new();
scrollback.write(|width| element(Text::raw(format!("w={width}"))));
scrollback.write(|_width| element(Text::raw("0123456789ABCDEF")));
scrollback.flush(&mut terminal, &theme).expect("flush");
let lines = screen_lines(&terminal);
assert_eq!(lines[2], "w=10", "{lines:?}");
assert_eq!(lines[3], "0123456789", "{lines:?}");
}
#[test]
fn a_block_taller_than_the_screen_still_publishes_its_tail() {
let theme = Theme::default();
let mut terminal = pinned(8, 6, 2);
let scrollback = Scrollback::new();
scrollback.write(|_width| {
element(Text::new(
(0..10)
.map(|i| Line::from(format!("r{i}")))
.collect::<Vec<_>>(),
))
});
assert!(scrollback.flush(&mut terminal, &theme).expect("flush"));
draw_footer(&mut terminal, "F");
let lines = screen_lines(&terminal);
assert_eq!(&lines[..4], &["r6", "r7", "r8", "r9"], "{lines:?}");
assert_eq!(&lines[4..], &["F", "F"], "{lines:?}");
}
#[test]
fn published_style_survives_into_the_scrollback() {
let theme = rainbow_theme();
let mut terminal = pinned(12, 6, 2);
let scrollback = Scrollback::new();
let accent = theme.accent;
scrollback.write(move |_width| {
element(Text::new(vec![Line::from(Span::styled(
"hi",
Style::default().fg(accent),
))]))
});
scrollback.flush(&mut terminal, &theme).expect("flush");
let buffer = terminal.backend().buffer();
let row = buffer.area.bottom() - 3;
assert_eq!(
buffer[(0, row)].fg,
accent,
"styled spans reach the terminal"
);
}
#[test]
fn blocks_keep_the_terminal_background_outside_painted_cells() {
let theme = rainbow_theme();
let mut terminal = pinned(12, 6, 2);
let scrollback = Scrollback::new();
write_text(&scrollback, "hi");
scrollback.flush(&mut terminal, &theme).expect("flush");
let buffer = terminal.backend().buffer();
let row = buffer.area.bottom() - 3;
assert_eq!(
buffer[(11, row)].bg,
Color::Reset,
"unpainted cells in a block keep the terminal's own background"
);
assert_ne!(
theme.background,
Color::Reset,
"the theme would have painted a background if the block used one"
);
}
#[test]
fn flushing_an_empty_queue_writes_nothing() {
let theme = Theme::default();
let mut terminal = pinned(12, 6, 2);
draw_footer(&mut terminal, "FOOTER");
let before = screen_lines(&terminal);
let scrollback = Scrollback::new();
assert!(scrollback.is_empty());
assert!(!scrollback.flush(&mut terminal, &theme).expect("flush"));
assert_eq!(screen_lines(&terminal), before);
}
#[test]
fn a_zero_width_terminal_publishes_nothing() {
let theme = Theme::default();
let mut terminal = footer_terminal(0, 4, 2, 0);
let scrollback = Scrollback::new();
write_text(&scrollback, "nowhere");
assert!(
!scrollback.flush(&mut terminal, &theme).expect("flush"),
"there is no column to publish into"
);
}
#[test]
fn cleared_blocks_are_never_published() {
let theme = Theme::default();
let mut terminal = pinned(12, 6, 2);
let scrollback = Scrollback::new();
write_text(&scrollback, "dropped");
assert!(!scrollback.is_empty());
scrollback.clear();
assert!(scrollback.is_empty());
assert!(!scrollback.flush(&mut terminal, &theme).expect("flush"));
assert!(
screen_lines(&terminal).iter().all(|line| line.is_empty()),
"a discarded block never reaches the terminal"
);
}
#[test]
fn publish_block_commits_immediately_and_in_order() {
let theme = Theme::default();
let mut terminal = pinned(12, 8, 2);
draw_footer(&mut terminal, "FOOTER");
let ctx = RenderCtx::new(&theme);
publish_block(&mut terminal, &Text::raw("one"), &ctx).expect("publish");
publish_block(&mut terminal, &Text::raw("two"), &ctx).expect("publish");
draw_footer(&mut terminal, "FOOTER");
let lines = screen_lines(&terminal);
assert_eq!(
&lines[4..],
&["one", "two", "FOOTER", "FOOTER"],
"{lines:?}"
);
}
#[test]
fn publish_block_uses_the_hosts_own_stylesheet() {
let theme = rainbow_theme();
let mut sheet = StyleSheet::from_theme(&theme);
sheet.panel = sheet.panel.fg(Color::Indexed(200));
let ctx = RenderCtx::new(&theme).with_sheet(sheet);
let mut terminal = pinned(12, 6, 2);
publish_block(
&mut terminal,
&crate::components::Boxed::new(element(Text::raw("x"))),
&ctx,
)
.expect("publish");
let buffer = terminal.backend().buffer();
let row = buffer.area.bottom() - 5;
assert_eq!(buffer[(0, row)].symbol(), "╭");
assert_eq!(
buffer[(0, row)].fg,
Color::Indexed(200),
"the host's panel style, not the theme's border color"
);
assert_ne!(Color::Indexed(200), theme.border);
}
#[test]
fn publish_block_may_own_state_that_cannot_cross_a_thread() {
let theme = Theme::default();
let ctx = RenderCtx::new(&theme);
let mut terminal = pinned(20, 8, 2);
let mut state = crate::components::MarkdownState::new();
state.set("**done** in 12ms");
let lines = state
.lines(
20,
&theme,
&ctx.sheet,
crate::highlight::CodeHighlighter::Plain,
)
.to_vec();
publish_block(&mut terminal, &Text::new(lines), &ctx).expect("publish");
let lines = screen_lines(&terminal);
assert!(
lines.iter().any(|line| line.contains("done in 12ms")),
"rendered markdown reached the scrollback: {lines:?}"
);
}
#[test]
fn publish_block_is_a_no_op_on_a_degenerate_screen() {
let theme = Theme::default();
let ctx = RenderCtx::new(&theme);
let mut narrow = footer_terminal(0, 4, 2, 0);
publish_block(&mut narrow, &Text::raw("nowhere"), &ctx).expect("publish");
let mut flat = footer_terminal(10, 0, 2, 0);
publish_block(&mut flat, &Text::raw("nowhere"), &ctx).expect("publish");
}
#[test]
fn a_scrollback_handle_is_shared_and_thread_safe() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Scrollback>();
let theme = Theme::default();
let mut terminal = pinned(12, 8, 2);
let scrollback = Scrollback::new();
let handles: Vec<_> = ["alpha", "beta", "gamma"]
.into_iter()
.map(|name| {
let producer = scrollback.clone();
std::thread::spawn(move || {
producer.write_lines(vec![
Line::from(format!("{name}-1")),
Line::from(format!("{name}-2")),
]);
})
})
.collect();
for handle in handles {
handle.join().expect("producer thread");
}
assert!(scrollback.flush(&mut terminal, &theme).expect("flush"));
let lines = screen_lines(&terminal);
for name in ["alpha", "beta", "gamma"] {
let first = lines
.iter()
.position(|line| line == &format!("{name}-1"))
.unwrap_or_else(|| panic!("{name} published: {lines:?}"));
assert_eq!(
lines[first + 1],
format!("{name}-2"),
"a block is committed whole: {lines:?}"
);
}
}
#[test]
fn a_producer_panicking_mid_publish_leaves_the_queue_usable() {
let theme = Theme::default();
let mut terminal = pinned(12, 6, 2);
let scrollback = Scrollback::new();
let poisoner = scrollback.clone();
let panicked = std::thread::spawn(move || {
let _guard = poisoner.queue.lock().expect("lock");
panic!("producer died holding the queue");
})
.join();
assert!(panicked.is_err(), "the producer really did panic");
write_text(&scrollback, "after");
assert!(scrollback.flush(&mut terminal, &theme).expect("flush"));
assert!(screen_lines(&terminal).contains(&"after".to_string()));
}
#[test]
fn close_footer_clears_the_region_and_parks_the_cursor() {
let mut terminal = pinned(12, 6, 2);
draw_footer(&mut terminal, "FOOTER");
close_footer(&mut terminal).expect("close");
let lines = screen_lines(&terminal);
assert!(
lines[4..].iter().all(|line| line.is_empty()),
"the footer rows are given back blank: {lines:?}"
);
assert_eq!(
terminal.backend().cursor_position(),
Position::new(0, 4),
"the prompt resumes where the footer started"
);
}
#[test]
fn close_footer_leaves_the_published_scrollback_alone() {
let theme = Theme::default();
let mut terminal = pinned(12, 6, 2);
let scrollback = Scrollback::new();
write_text(&scrollback, "kept");
scrollback.flush(&mut terminal, &theme).expect("flush");
draw_footer(&mut terminal, "FOOTER");
close_footer(&mut terminal).expect("close");
assert!(
screen_lines(&terminal).contains(&"kept".to_string()),
"published output is the user's, not ours to clear"
);
}
#[test]
fn close_footer_on_a_full_screen_viewport_clears_the_screen() {
let mut terminal = Terminal::new(TestBackend::new(6, 3)).expect("terminal");
terminal
.draw(|frame| {
frame
.buffer_mut()
.set_string(0, 0, "hello", Style::default());
})
.expect("draw");
close_footer(&mut terminal).expect("close");
assert!(
screen_lines(&terminal).iter().all(|line| line.is_empty()),
"with the viewport at the origin this is a plain clear"
);
assert_eq!(terminal.backend().cursor_position(), Position::new(0, 0));
}
#[test]
fn the_footer_lifecycle_survives_degenerate_screens() {
let theme = Theme::default();
for &width in &[1u16, 2, 5, 40] {
for &height in &[0u16, 1, 2, 3, 9] {
for &footer in &[1u16, 2, 12] {
let mut terminal = footer_terminal(width, height, footer, 0);
pin_footer(&mut terminal).expect("pin");
let scrollback = Scrollback::new();
write_text(&scrollback, "block");
scrollback.flush(&mut terminal, &theme).expect("flush");
draw_footer(&mut terminal, "F");
close_footer(&mut terminal).expect("close");
let area = terminal.get_frame().area();
assert!(
area.bottom() <= height && area.right() <= width,
"viewport {area:?} escaped a {width}x{height} screen"
);
}
}
}
}
#[test]
#[cfg(feature = "scrolling-regions")]
fn publishing_leaves_the_painted_footer_on_screen() {
let theme = Theme::default();
let mut terminal = pinned(12, 6, 2);
draw_footer(&mut terminal, "FOOTER");
let scrollback = Scrollback::new();
write_text(&scrollback, "block");
scrollback.flush(&mut terminal, &theme).expect("flush");
let lines = screen_lines(&terminal);
assert_eq!(
&lines[4..],
&["FOOTER", "FOOTER"],
"scrolling regions move only the rows above the footer: {lines:?}"
);
}
#[test]
#[cfg(not(feature = "scrolling-regions"))]
fn publishing_clears_the_footer_for_the_next_repaint() {
let theme = Theme::default();
let mut terminal = pinned(12, 6, 2);
draw_footer(&mut terminal, "FOOTER");
let scrollback = Scrollback::new();
write_text(&scrollback, "block");
scrollback.flush(&mut terminal, &theme).expect("flush");
let lines = screen_lines(&terminal);
assert!(
lines[4..].iter().all(|line| line.is_empty()),
"the portable path clears the viewport, which is why `flush` \
reporting `true` obliges the caller to repaint: {lines:?}"
);
draw_footer(&mut terminal, "FOOTER");
assert_eq!(&screen_lines(&terminal)[4..], &["FOOTER", "FOOTER"]);
}
}