feat(block): support for having more than one title (#232)

This commit is contained in:
Samy Rahmani 2023-06-19 04:24:36 -04:00 committed by GitHub
parent e869869462
commit a04b190251
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 510 additions and 82 deletions

View file

@ -10,7 +10,7 @@ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, BorderType, Borders, Padding, Paragraph},
widgets::{block::title::Title, Block, BorderType, Borders, Padding, Paragraph},
Frame, Terminal,
};
@ -62,8 +62,7 @@ fn ui<B: Backend>(f: &mut Frame<B>) {
// Surrounding block
let block = Block::default()
.borders(Borders::ALL)
.title("Main block with round corners")
.title_alignment(Alignment::Center)
.title(Title::from("Main block with round corners").alignment(Alignment::Center))
.border_type(BorderType::Rounded);
f.render_widget(block, size);
@ -89,15 +88,16 @@ fn ui<B: Backend>(f: &mut Frame<B>) {
f.render_widget(block, top_chunks[0]);
// Top right inner block with styled title aligned to the right
let block = Block::default()
.title(Span::styled(
let block = Block::default().title(
Title::from(Span::styled(
"Styled title",
Style::default()
.fg(Color::White)
.bg(Color::Red)
.add_modifier(Modifier::BOLD),
))
.title_alignment(Alignment::Right);
.alignment(Alignment::Right),
);
f.render_widget(block, top_chunks[1]);
// Bottom two inner blocks

View file

@ -14,7 +14,7 @@ use ratatui::{
style::{Color, Modifier, Style},
symbols,
text::{Line, Span},
widgets::{Block, Gauge, LineGauge, List, ListItem, Paragraph, Widget},
widgets::{block::title::Title, Block, Gauge, LineGauge, List, ListItem, Paragraph, Widget},
Frame, Terminal, TerminalOptions, Viewport,
};
@ -227,9 +227,7 @@ fn run_app<B: Backend>(
fn ui<B: Backend>(f: &mut Frame<B>, downloads: &Downloads) {
let size = f.size();
let block = Block::default()
.title("Progress")
.title_alignment(Alignment::Center);
let block = Block::default().title(Title::from("Progress").alignment(Alignment::Center));
f.render_widget(block, size);
let chunks = Layout::default()

View file

@ -62,7 +62,7 @@ pub struct Margin {
pub horizontal: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Alignment {
Left,
Center,

57
src/title.rs Normal file
View file

@ -0,0 +1,57 @@
use crate::{layout::Alignment, text::Line};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Title<'a> {
pub content: Line<'a>,
/// Defaults to Left if unset
pub alignment: Option<Alignment>,
/// Defaults to Top if unset
pub position: Option<Position>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Position {
#[default]
Top,
Bottom,
}
impl<'a> Title<'a> {
pub fn content<T>(mut self, content: T) -> Title<'a>
where
T: Into<Line<'a>>,
{
self.content = content.into();
self
}
pub fn alignment(mut self, alignment: Alignment) -> Title<'a> {
self.alignment = Some(alignment);
self
}
pub fn position(mut self, position: Position) -> Title<'a> {
self.position = Some(position);
self
}
}
impl<'a, T> From<T> for Title<'a>
where
T: Into<Line<'a>>,
{
fn from(value: T) -> Self {
Self::default().content(value.into())
}
}
impl<'a> Default for Title<'a> {
fn default() -> Self {
Self {
content: Line::from(""),
alignment: Some(Alignment::Left),
position: Some(Position::Top),
}
}
}

View file

@ -1,12 +1,16 @@
#[path = "../title.rs"]
pub mod title;
use crate::{
buffer::Buffer,
layout::{Alignment, Rect},
style::Style,
symbols::line,
text::{Line, Span},
widgets::{Borders, Widget},
};
use self::title::{Position, Title};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BorderType {
Plain,
@ -96,22 +100,38 @@ impl Padding {
/// .border_type(BorderType::Rounded)
/// .style(Style::default().bg(Color::Black));
/// ```
///
/// You may also use multiple titles like in the following:
/// ```
/// # use ratatui::widgets::{Block, BorderType, Borders, block::title::{Position, Title}};
/// # use ratatui::style::{Style, Color};
/// Block::default()
/// .title("Title 1")
/// .title(Title::from("Title 2").position(Position::Bottom))
/// .borders(Borders::LEFT | Borders::RIGHT)
/// .border_style(Style::default().fg(Color::White))
/// .border_type(BorderType::Rounded)
/// .style(Style::default().bg(Color::Black));
/// ```
///
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Block<'a> {
/// Optional title place on the upper left of the block
title: Option<Line<'a>>,
/// Title alignment. The default is top left of the block, but one can choose to place
/// title in the top middle, or top right of the block
title_alignment: Alignment,
/// Whether or not title goes on top or bottom row of the block
title_on_bottom: bool,
/// List of titles
titles: Vec<Title<'a>>,
/// The style to be patched to all titles of the block
titles_style: Style,
/// The default alignment of the titles that don't have one
titles_alignment: Alignment,
/// The default position of the titles that don't have one
titles_position: Position,
/// Visible borders
borders: Borders,
/// Border style
border_style: Style,
/// Type of the border. The default is plain lines but one can choose to have rounded corners
/// or doubled lines instead.
/// Type of the border. The default is plain lines but one can choose to have rounded or doubled lines instead.
border_type: BorderType,
/// Widget style
style: Style,
/// Block padding
@ -121,9 +141,10 @@ pub struct Block<'a> {
impl<'a> Default for Block<'a> {
fn default() -> Block<'a> {
Block {
title: None,
title_alignment: Alignment::Left,
title_on_bottom: false,
titles: Vec::new(),
titles_style: Style::default(),
titles_alignment: Alignment::Left,
titles_position: Position::default(),
borders: Borders::NONE,
border_style: Style::default(),
border_type: BorderType::Plain,
@ -134,33 +155,81 @@ impl<'a> Default for Block<'a> {
}
impl<'a> Block<'a> {
/// # Example
/// ```
/// # use ratatui::widgets::{Block, block::title::Title};
/// # use ratatui::layout::Alignment;
/// Block::default()
/// .title("Title") // By default in the top right corner
/// .title(Title::from("Left").alignment(Alignment::Left))
/// .title(
/// Title::from("Center")
/// .alignment(Alignment::Center),
/// );
///```
/// Adds a title to the block.
///
/// The `title` function allows you to add a title to the block. You can call this function multiple times to add multiple titles.
///
/// Each title will be rendered with a single space separating titles that are in the same position or alignment. When both centered and non-centered titles are rendered, the centered space is calculated based on the full width of the block, rather than the leftover width.
///
/// You can provide various types as the title, including strings, string slices, borrowed strings (`Cow<str>`), spans, or vectors of spans (`Vec<Span>`).
///
/// By default, the titles will avoid being rendered in the corners of the block but will align against the left or right edge of the block if there is no border on that edge.
///
/// Note: If the block is too small and multiple titles overlap, the border might get cut off at a corner.
pub fn title<T>(mut self, title: T) -> Block<'a>
where
T: Into<Line<'a>>,
T: Into<Title<'a>>,
{
self.title = Some(title.into());
self.titles.push(title.into());
self
}
#[deprecated(
since = "0.10.0",
note = "You should use styling capabilities of `text::Line` given as argument of the `title` method to apply styling to the title."
)]
/// Applies the style to all titles. If a title already has a style, it will add on top of it.
pub fn title_style(mut self, style: Style) -> Block<'a> {
if let Some(t) = self.title {
let title = String::from(t);
self.title = Some(Line::from(Span::styled(title, style)));
}
self.titles_style = style;
self
}
/// Aligns all elements that don't have an alignment
/// # Example
/// This example aligns all titles in the center except "right" title
/// ```
/// # use ratatui::widgets::{Block, block::title::Title};
/// # use ratatui::layout::Alignment;
/// Block::default()
/// // This title won't be aligned in the center
/// .title(Title::from("right").alignment(Alignment::Right))
/// .title("foo")
/// .title("bar")
/// .title_alignment(Alignment::Center);
/// ```
pub fn title_alignment(mut self, alignment: Alignment) -> Block<'a> {
self.title_alignment = alignment;
self.titles_alignment = alignment;
self
}
pub fn title_on_bottom(mut self) -> Block<'a> {
self.title_on_bottom = true;
#[deprecated(since = "0.22.0", note = "You should use a `title_position` instead.")]
/// This method just calls `title_position` with Position::Bottom
pub fn title_on_bottom(self) -> Block<'a> {
self.title_position(Position::Bottom)
}
/// Positions all titles that don't have a position
/// # Example
/// This example position all titles on the bottom except "top" title
/// ```
/// # use ratatui::widgets::{Block, BorderType, Borders, block::title::{Position, Title}};
/// Block::default()
/// // This title won't be aligned in the center
/// .title(Title::from("top").position(Position::Top))
/// .title("foo")
/// .title("bar")
/// .title_position(Position::Bottom);
/// ```
pub fn title_position(mut self, position: Position) -> Block<'a> {
self.titles_position = position;
self
}
@ -219,7 +288,7 @@ impl<'a> Block<'a> {
inner.x = inner.x.saturating_add(1).min(inner.right());
inner.width = inner.width.saturating_sub(1);
}
if self.borders.intersects(Borders::TOP) || self.title.is_some() {
if self.borders.intersects(Borders::TOP) || !self.titles.is_empty() {
inner.y = inner.y.saturating_add(1).min(inner.bottom());
inner.height = inner.height.saturating_sub(1);
}
@ -247,13 +316,8 @@ impl<'a> Block<'a> {
self.padding = padding;
self
}
}
impl<'a> Widget for Block<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.area() == 0 {
return;
}
fn render_borders(&self, area: Rect, buf: &mut Buffer) {
buf.set_style(area, self.style);
let symbols = BorderType::line_symbols(self.border_type);
@ -310,36 +374,121 @@ impl<'a> Widget for Block<'a> {
.set_symbol(symbols.top_left)
.set_style(self.border_style);
}
}
// Title
if let Some(title) = self.title {
let left_border_dx = u16::from(self.borders.intersects(Borders::LEFT));
let right_border_dx = u16::from(self.borders.intersects(Borders::RIGHT));
let title_area_width = area
.width
.saturating_sub(left_border_dx)
.saturating_sub(right_border_dx);
let title_dx = match self.title_alignment {
Alignment::Left => left_border_dx,
Alignment::Center => area.width.saturating_sub(title.width() as u16) / 2,
Alignment::Right => area
.width
.saturating_sub(title.width() as u16)
.saturating_sub(right_border_dx),
};
let title_x = area.left() + title_dx;
let title_y = if self.title_on_bottom {
area.bottom() - 1
} else {
area.top()
};
buf.set_line(title_x, title_y, &title, title_area_width);
/* Titles Rendering */
fn get_title_y(&self, position: Position, area: Rect) -> u16 {
match position {
Position::Bottom => area.bottom() - 1,
Position::Top => area.top(),
}
}
fn title_filter(&self, title: &Title, alignment: Alignment, position: Position) -> bool {
title.alignment.unwrap_or(self.titles_alignment) == alignment
&& title.position.unwrap_or(self.titles_position) == position
}
fn calculate_title_area_offsets(&self, area: Rect) -> (u16, u16, u16) {
let left_border_dx = u16::from(self.borders.intersects(Borders::LEFT));
let right_border_dx = u16::from(self.borders.intersects(Borders::RIGHT));
let title_area_width = area
.width
.saturating_sub(left_border_dx)
.saturating_sub(right_border_dx);
(left_border_dx, right_border_dx, title_area_width)
}
fn render_left_titles(&self, position: Position, area: Rect, buf: &mut Buffer) {
let (left_border_dx, _, title_area_width) = self.calculate_title_area_offsets(area);
let mut current_offset = left_border_dx;
self.titles
.iter()
.filter(|title| self.title_filter(title, Alignment::Left, position))
.for_each(|title| {
let title_x = current_offset;
current_offset += title.content.width() as u16 + 1;
buf.set_line(
title_x + area.left(),
self.get_title_y(position, area),
&title.content,
title_area_width,
);
});
}
fn render_center_titles(&self, position: Position, area: Rect, buf: &mut Buffer) {
let (_, _, title_area_width) = self.calculate_title_area_offsets(area);
let titles = self
.titles
.iter()
.filter(|title| self.title_filter(title, Alignment::Center, position));
let titles_sum = titles
.clone()
.fold(-1, |acc, f| acc + f.content.width() as i16 + 1); // First element isn't spaced
let mut current_offset = area.width.saturating_sub(titles_sum as u16) / 2;
titles.for_each(|title| {
let title_x = current_offset;
current_offset += title.content.width() as u16 + 1;
buf.set_line(
title_x + area.left(),
self.get_title_y(position, area),
&title.content,
title_area_width,
);
});
}
fn render_right_titles(&self, position: Position, area: Rect, buf: &mut Buffer) {
let (_, right_border_dx, title_area_width) = self.calculate_title_area_offsets(area);
let mut current_offset = right_border_dx;
self.titles
.iter()
.filter(|title| self.title_filter(title, Alignment::Right, position))
.rev() // so that the titles appear in the order they have been set
.for_each(|title| {
current_offset += title.content.width() as u16 + 1;
let title_x = current_offset - 1; // First element isn't spaced
buf.set_line(
area.width.saturating_sub(title_x) + area.left(),
self.get_title_y(position, area),
&title.content,
title_area_width,
);
});
}
fn render_title_position(&self, position: Position, area: Rect, buf: &mut Buffer) {
// Note: the order in which these functions are called define the overlapping behavior
self.render_right_titles(position, area, buf);
self.render_center_titles(position, area, buf);
self.render_left_titles(position, area, buf);
}
fn render_titles(&self, area: Rect, buf: &mut Buffer) {
self.render_title_position(Position::Top, area, buf);
self.render_title_position(Position::Bottom, area, buf);
}
}
impl<'a> Widget for Block<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.area() == 0 {
return;
}
self.render_borders(area, buf);
self.render_titles(area, buf);
}
}
#[cfg(test)]
@ -642,8 +791,7 @@ mod tests {
);
assert_eq!(
Block::default()
.title("Test")
.title_alignment(Alignment::Center)
.title(Title::from("Test").alignment(Alignment::Center))
.inner(Rect {
x: 0,
y: 0,
@ -659,8 +807,7 @@ mod tests {
);
assert_eq!(
Block::default()
.title("Test")
.title_alignment(Alignment::Right)
.title(Title::from("Test").alignment(Alignment::Right))
.inner(Rect {
x: 0,
y: 0,

View file

@ -17,7 +17,7 @@
//! - [`Clear`]
mod barchart;
mod block;
pub mod block;
#[cfg(feature = "widget-calendar")]
pub mod calendar;
pub mod canvas;

View file

@ -4,7 +4,10 @@ use ratatui::{
layout::{Alignment, Rect},
style::{Color, Style},
text::Span,
widgets::{Block, Borders},
widgets::{
block::title::{Position, Title},
Block, Borders,
},
Terminal,
};
@ -46,6 +49,80 @@ fn widgets_block_renders() {
terminal.backend().assert_buffer(&expected);
}
#[test]
fn widgets_block_titles_overlap() {
let test_case = |block, area: Rect, expected| {
let backend = TestBackend::new(area.width, area.height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
f.render_widget(block, area);
})
.unwrap();
terminal.backend().assert_buffer(&expected);
};
// Left overrides the center
test_case(
Block::default()
.title(Title::from("aaaaa").alignment(Alignment::Left))
.title(Title::from("bbb").alignment(Alignment::Center))
.title(Title::from("ccc").alignment(Alignment::Right)),
Rect {
x: 0,
y: 0,
width: 10,
height: 1,
},
Buffer::with_lines(vec!["aaaaab ccc"]),
);
// Left alignment overrides the center alignment which overrides the right alignment
test_case(
Block::default()
.title(Title::from("aaaaa").alignment(Alignment::Left))
.title(Title::from("bbbbb").alignment(Alignment::Center))
.title(Title::from("ccccc").alignment(Alignment::Right)),
Rect {
x: 0,
y: 0,
width: 11,
height: 1,
},
Buffer::with_lines(vec!["aaaaabbbccc"]),
);
// Multiple left alignment overrides the center alignment and the right alignment
test_case(
Block::default()
.title(Title::from("aaaaa").alignment(Alignment::Left))
.title(Title::from("aaaaa").alignment(Alignment::Left))
.title(Title::from("bbbbb").alignment(Alignment::Center))
.title(Title::from("ccccc").alignment(Alignment::Right)),
Rect {
x: 0,
y: 0,
width: 11,
height: 1,
},
Buffer::with_lines(vec!["aaaaabaaaaa"]),
);
// The right alignment doesn't override the center alignment, but pierces through it
test_case(
Block::default()
.title(Title::from("bbbbb").alignment(Alignment::Center))
.title(Title::from("ccccccccccc").alignment(Alignment::Right)),
Rect {
x: 0,
y: 0,
width: 11,
height: 1,
},
Buffer::with_lines(vec!["cccbbbbbccc"]),
);
}
#[test]
fn widgets_block_renders_on_small_areas() {
let test_case = |block, area: Rect, expected| {
@ -219,8 +296,7 @@ fn widgets_block_title_alignment() {
let mut terminal = Terminal::new(backend).unwrap();
let block = Block::default()
.title(Span::styled("Title", Style::default()))
.title_alignment(alignment)
.title(Title::from(Span::styled("Title", Style::default())).alignment(alignment))
.borders(borders);
let area = Rect {
@ -352,9 +428,11 @@ fn widgets_block_title_alignment_bottom() {
let mut terminal = Terminal::new(backend).unwrap();
let block = Block::default()
.title(Span::styled("Title", Style::default()))
.title_alignment(alignment)
.title_on_bottom()
.title(
Title::from(Span::styled("Title", Style::default()))
.alignment(alignment)
.position(Position::Bottom),
)
.borders(borders);
let area = Rect {
@ -478,3 +556,151 @@ fn widgets_block_title_alignment_bottom() {
Buffer::with_lines(vec![" ", " Title "]),
);
}
#[test]
fn widgets_block_multiple_titles() {
let test_case = |title_a, title_b, borders, expected| {
let backend = TestBackend::new(15, 2);
let mut terminal = Terminal::new(backend).unwrap();
let block = Block::default()
.title(title_a)
.title(title_b)
.borders(borders);
let area = Rect {
x: 1,
y: 0,
width: 13,
height: 2,
};
terminal
.draw(|f| {
f.render_widget(block, area);
})
.unwrap();
terminal.backend().assert_buffer(&expected);
};
// title bottom-left with all borders
test_case(
Title::from("foo"),
Title::from("bar"),
Borders::ALL,
Buffer::with_lines(vec![" ┌foo─bar────┐ ", " └───────────┘ "]),
);
// title top-left without top border
test_case(
Title::from("foo"),
Title::from("bar"),
Borders::LEFT | Borders::BOTTOM | Borders::RIGHT,
Buffer::with_lines(vec![" │foo bar │ ", " └───────────┘ "]),
);
// title top-left with no left border
test_case(
Title::from("foo"),
Title::from("bar"),
Borders::TOP | Borders::RIGHT | Borders::BOTTOM,
Buffer::with_lines(vec![" foo─bar─────┐ ", " ────────────┘ "]),
);
// title top-left without right border
test_case(
Title::from("foo"),
Title::from("bar"),
Borders::LEFT | Borders::TOP | Borders::BOTTOM,
Buffer::with_lines(vec![" ┌foo─bar───── ", " └──────────── "]),
);
// title top-left without borders
test_case(
Title::from("foo"),
Title::from("bar"),
Borders::NONE,
Buffer::with_lines(vec![" foo bar ", " "]),
);
// title center with all borders
test_case(
Title::from("foo").alignment(Alignment::Center),
Title::from("bar").alignment(Alignment::Center),
Borders::ALL,
Buffer::with_lines(vec![" ┌──foo─bar──┐ ", " └───────────┘ "]),
);
// title center without top border
test_case(
Title::from("foo").alignment(Alignment::Center),
Title::from("bar").alignment(Alignment::Center),
Borders::LEFT | Borders::BOTTOM | Borders::RIGHT,
Buffer::with_lines(vec![" │ foo bar │ ", " └───────────┘ "]),
);
// title center with no left border
test_case(
Title::from("foo").alignment(Alignment::Center),
Title::from("bar").alignment(Alignment::Center),
Borders::TOP | Borders::RIGHT | Borders::BOTTOM,
Buffer::with_lines(vec![" ───foo─bar──┐ ", " ────────────┘ "]),
);
// title center without right border
test_case(
Title::from("foo").alignment(Alignment::Center),
Title::from("bar").alignment(Alignment::Center),
Borders::LEFT | Borders::TOP | Borders::BOTTOM,
Buffer::with_lines(vec![" ┌──foo─bar─── ", " └──────────── "]),
);
// title center without borders
test_case(
Title::from("foo").alignment(Alignment::Center),
Title::from("bar").alignment(Alignment::Center),
Borders::NONE,
Buffer::with_lines(vec![" foo bar ", " "]),
);
// title top-right with all borders
test_case(
Title::from("foo").alignment(Alignment::Right),
Title::from("bar").alignment(Alignment::Right),
Borders::ALL,
Buffer::with_lines(vec![" ┌────foo─bar┐ ", " └───────────┘ "]),
);
// title top-right without top border
test_case(
Title::from("foo").alignment(Alignment::Right),
Title::from("bar").alignment(Alignment::Right),
Borders::LEFT | Borders::BOTTOM | Borders::RIGHT,
Buffer::with_lines(vec![" │ foo bar│ ", " └───────────┘ "]),
);
// title top-right with no left border
test_case(
Title::from("foo").alignment(Alignment::Right),
Title::from("bar").alignment(Alignment::Right),
Borders::TOP | Borders::RIGHT | Borders::BOTTOM,
Buffer::with_lines(vec![" ─────foo─bar┐ ", " ────────────┘ "]),
);
// title top-right without right border
test_case(
Title::from("foo").alignment(Alignment::Right),
Title::from("bar").alignment(Alignment::Right),
Borders::LEFT | Borders::TOP | Borders::BOTTOM,
Buffer::with_lines(vec![" ┌─────foo─bar ", " └──────────── "]),
);
// title top-right without borders
test_case(
Title::from("foo").alignment(Alignment::Right),
Title::from("bar").alignment(Alignment::Right),
Borders::NONE,
Buffer::with_lines(vec![" foo bar ", " "]),
);
}