Skip to content

Collapse borders in a layout

A common layout for applications is to split up the screen into panes, with borders around each pane. Often this leads to making UIs that look disconnected. E.g., the following layout:

problem

Created by the following code:

fn draw(frame: &mut Frame) {
// create a layout that splits the screen into 2 equal columns and the right column
// into 2 equal rows
let [left, right] = Layout::horizontal([Constraint::Fill(1); 2]).areas(frame.area());
let [top_right, bottom_right] = Layout::vertical([Constraint::Fill(1); 2]).areas(right);
frame.render_widget(Block::bordered().title("Left Block"), left);
frame.render_widget(Block::bordered().title("Top Right Block"), top_right);
frame.render_widget(Block::bordered().title("Bottom Right Block"), bottom_right);
}

We can do better though, by collapsing borders. E.g.:

solution

The first thing we need to do is work out which borders to collapse. Because in the layout above we want to connect the bottom right block to the middle vertical border, we’re going to need this to be rendered by the top left and bottom left blocks rather than the right block.

We need to use the symbols module to achieve this so we add this to the imports:

use ratatui::{
layout::{Constraint, Layout},
symbols,
widgets::{Block, Borders},
DefaultTerminal, Frame,
};

Our first change is to the left block where we remove the right border:

let left_block = Block::new()
// don't render the right border because it will be rendered by the right block
.borders(Borders::TOP | Borders::LEFT | Borders::BOTTOM)
.title("Left Block");

Next, we see that the top left corner of the top right block joins with the top right corner of the left block, so we need to replace that with a T shape. We also see omit the bottom border as that will be rendered by the bottom right block. We use a custom symbols::border::Set to achieve this.

// top right block must render the top left border to join with the left block
let top_right_border_set = symbols::border::Set {
top_left: symbols::line::NORMAL.horizontal_down,
..symbols::border::PLAIN
};
let top_right_block = Block::new()
.border_set(top_right_border_set)
// don't render the bottom border because it will be rendered by the bottom block
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.title("Top Right Block");

In the bottom right block, we see that the top right corner joins the left block’s right border and so we need to rend this with a horizontal T shape pointing to the right. We need to do the same for the top right corner and the bottom left corner.

// bottom right block must render:
// - top left border to join with the left block and top right block
// - top right border to join with the top right block
// - bottom left border to join with the left block
let collapsed_top_and_left_border_set = symbols::border::Set {
top_left: symbols::line::NORMAL.vertical_right,
top_right: symbols::line::NORMAL.vertical_left,
bottom_left: symbols::line::NORMAL.horizontal_up,
..symbols::border::PLAIN
};
let bottom_right_block = Block::new()
.border_set(collapsed_top_and_left_border_set)
.borders(Borders::ALL)
.title("Bottom Right Block");

Finally, render the blocks:

frame.render_widget(left_block, left);
frame.render_widget(top_right_block, top_right);
frame.render_widget(bottom_right_block, bottom_right);

If we left it here, then we’d be mostly fine, but in small areas we’d notice that the 50/50 split no longer looks right. This is due to the fact that by default we round up when splitting an odd number of rows or columns in 2 (e.g. 5 rows => 2.5/2.5 => 3/2). This is fine normally, but when we collapse borders between blocks, the first block has one extra row (or columns) already as it does not have the collapsed block. We can easily work around this issue by allocating a small amount of extra space to the last layout item (e.g. by using 49/51 or 33/33/34).

// use a 49/51 split instead of 50/50 to ensure that any extra space is on the right
// side of the screen. This is important because the right side of the screen is
// where the borders are collapsed.
let [left, right] =
Layout::horizontal([Constraint::Percentage(49), Constraint::Percentage(51)])
.areas(frame.area());
// use a 49/51 split to ensure that any extra space is on the bottom
let [top_right, bottom_right] =
Layout::vertical([Constraint::Percentage(49), Constraint::Percentage(51)]).areas(right);

The full code for this example is available at https://github.com/ratatui/ratatui-website/blob/main/code/how-to-collapse-borders

collapse-borders.rs
use std::time::Duration;
use color_eyre::Result;
use crossterm::event::{self, Event};
use ratatui::{
layout::{Constraint, Layout},
symbols,
widgets::{Block, Borders},
DefaultTerminal, Frame,
};
/// This example shows how to use custom borders to collapse borders between widgets.
/// See https://ratatui.rs/how-to/layout/collapse-borders for more info
fn main() -> Result<()> {
color_eyre::install()?;
let terminal = ratatui::init();
let result = run(terminal);
ratatui::restore();
result
}
fn run(mut terminal: DefaultTerminal) -> Result<()> {
loop {
terminal.draw(draw)?;
if key_pressed()? {
return Ok(());
}
}
}
fn key_pressed() -> Result<bool> {
Ok(event::poll(Duration::from_millis(16))? && matches!(event::read()?, Event::Key(_)))
}
fn draw(frame: &mut Frame) {
// create a layout that splits the screen into 2 equal columns and the right column
// into 2 equal rows
// use a 49/51 split instead of 50/50 to ensure that any extra space is on the right
// side of the screen. This is important because the right side of the screen is
// where the borders are collapsed.
let [left, right] =
Layout::horizontal([Constraint::Percentage(49), Constraint::Percentage(51)])
.areas(frame.area());
// use a 49/51 split to ensure that any extra space is on the bottom
let [top_right, bottom_right] =
Layout::vertical([Constraint::Percentage(49), Constraint::Percentage(51)]).areas(right);
let left_block = Block::new()
// don't render the right border because it will be rendered by the right block
.borders(Borders::TOP | Borders::LEFT | Borders::BOTTOM)
.title("Left Block");
// top right block must render the top left border to join with the left block
let top_right_border_set = symbols::border::Set {
top_left: symbols::line::NORMAL.horizontal_down,
..symbols::border::PLAIN
};
let top_right_block = Block::new()
.border_set(top_right_border_set)
// don't render the bottom border because it will be rendered by the bottom block
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.title("Top Right Block");
// bottom right block must render:
// - top left border to join with the left block and top right block
// - top right border to join with the top right block
// - bottom left border to join with the left block
let collapsed_top_and_left_border_set = symbols::border::Set {
top_left: symbols::line::NORMAL.vertical_right,
top_right: symbols::line::NORMAL.vertical_left,
bottom_left: symbols::line::NORMAL.horizontal_up,
..symbols::border::PLAIN
};
let bottom_right_block = Block::new()
.border_set(collapsed_top_and_left_border_set)
.borders(Borders::ALL)
.title("Bottom Right Block");
frame.render_widget(left_block, left);
frame.render_widget(top_right_block, top_right);
frame.render_widget(bottom_right_block, bottom_right);
}