feat(linegauge): customized symbols (#1601)

With this PR any symbol (`&str`) can be used to render `filled` and
`unfilled` parts of `LineGauge` now. Before that change, only
[`symbols::line::Set`](https://docs.rs/ratatui/latest/ratatui/symbols/line/struct.Set.html)
was accepted.

Note: New methods are introduced to define those symbols:
`filled_symbol` and `unfilled_symbol`. The method
[`line_set`](https://docs.rs/ratatui/latest/ratatui/widgets/struct.LineGauge.html#method.line_set)
is still there, but marked as `deprecated`.

![line_gauge](https://github.com/user-attachments/assets/cae308b8-151b-461d-8af6-9a20012adf2f)
This commit is contained in:
Jens Krause
2025-01-23 04:56:22 +01:00
committed by GitHub
parent a0a37008da
commit 7ad9c29eac
9 changed files with 274 additions and 205 deletions

View File

@@ -84,10 +84,15 @@ fn draw_gauges(frame: &mut Frame, app: &mut App, area: Rect) {
let line_gauge = LineGauge::default()
.block(Block::new().title("LineGauge:"))
.filled_style(Style::default().fg(Color::Magenta))
.line_set(if app.enhanced_graphics {
symbols::line::THICK
.filled_symbol(if app.enhanced_graphics {
symbols::line::THICK_HORIZONTAL
} else {
symbols::line::NORMAL
symbols::line::HORIZONTAL
})
.unfilled_symbol(if app.enhanced_graphics {
symbols::line::THICK_HORIZONTAL
} else {
symbols::line::HORIZONTAL
})
.ratio(app.progress);
frame.render_widget(line_gauge, chunks[2]);

View File

@@ -159,6 +159,7 @@ fn render_line_gauge(percent: f64, area: Rect, buf: &mut Buffer) {
.style(Style::new().light_blue())
.filled_style(Style::new().fg(filled_color))
.unfilled_style(Style::new().fg(unfilled_color))
.line_set(symbols::line::THICK)
.filled_symbol(symbols::line::THICK_HORIZONTAL)
.unfilled_symbol(symbols::line::THICK_HORIZONTAL)
.render(area, buf);
}

View File

@@ -80,6 +80,7 @@ pub fn render_line_gauge(frame: &mut Frame, area: Rect) {
.unfilled_style(Style::new().gray().on_black())
.label("❤️ HP")
.ratio(0.42)
.line_set(symbols::line::THICK);
.filled_symbol(symbols::line::THICK_HORIZONTAL)
.unfilled_symbol(symbols::line::THICK_HORIZONTAL);
frame.render_widget(line_gauge, area);
}

View File

@@ -0,0 +1,161 @@
//! # [Ratatui] `LineGauge` example
//!
//! The latest version of this example is available in the [widget examples] folder in the
//! repository.
//!
//! Please note that the examples are designed to be run against the `main` branch of the Github
//! repository. This means that you may not be able to compile with the latest release version on
//! crates.io, or the one that you have installed locally.
//!
//! See the [examples readme] for more information on finding examples that match the version of the
//! library you are using.
//!
//! [Ratatui]: https://github.com/ratatui/ratatui
//! [widget examples]: https://github.com/ratatui/ratatui/blob/main/ratatui-widgets/examples
//! [examples readme]: https://github.com/ratatui/ratatui/blob/main/examples/README.md
use std::time::Duration;
use color_eyre::Result;
use ratatui::{
buffer::Buffer,
crossterm::event::{self, Event, KeyCode, KeyEventKind},
layout::{
Constraint::{Length, Min},
Layout, Rect,
},
style::{palette::tailwind, Style, Stylize},
widgets::{LineGauge, Paragraph, Widget},
DefaultTerminal,
};
fn main() -> Result<()> {
color_eyre::install()?;
let terminal = ratatui::init();
let app_result = App::default().run(terminal);
ratatui::restore();
app_result
}
#[derive(Debug, Default, Clone, Copy)]
struct App {
state: AppState,
progress_columns: u16,
progress: f64,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
enum AppState {
#[default]
Start,
Stop,
Quit,
}
impl App {
fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
while self.state != AppState::Quit {
terminal.draw(|frame| frame.render_widget(&self, frame.area()))?;
self.handle_events()?;
self.update(terminal.size()?.width);
}
Ok(())
}
fn update(&mut self, terminal_width: u16) {
if self.state != AppState::Start {
return;
}
self.progress_columns = (self.progress_columns + 1).clamp(0, terminal_width);
self.progress = f64::from(self.progress_columns) / f64::from(terminal_width);
}
fn handle_events(&mut self) -> Result<()> {
let timeout = Duration::from_secs_f32(1.0 / 20.0);
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char(' ') => {
// toggle start / stop
if self.state == AppState::Stop {
self.state = AppState::Start;
} else {
self.state = AppState::Stop;
}
}
KeyCode::Char('r') => self.reset(),
KeyCode::Char('q') => self.state = AppState::Quit,
_ => {}
}
}
}
}
Ok(())
}
fn reset(&mut self) {
self.progress = 0.0;
self.progress_columns = 0;
self.state = AppState::Stop;
}
}
impl Widget for &App {
fn render(self, area: Rect, buf: &mut Buffer) {
let layout = Layout::vertical([Length(3), Min(0)]);
let [header_area, main_area] = layout.areas(area);
let [gauge1_area, gauge4_area, gauge6_area] =
Layout::vertical([Length(2); 3]).areas(main_area);
header(header_area, buf);
self.render_gauge1(gauge1_area, buf);
self.render_gauge2(gauge4_area, buf);
self.render_gauge3(gauge6_area, buf);
}
}
fn header(area: Rect, buf: &mut Buffer) {
let [p1_area, p2_area] = Layout::vertical([Length(1), Min(1)]).areas(area);
Paragraph::new("LineGauge Example")
.bold()
.centered()
.render(p1_area, buf);
Paragraph::new("(Press 'SPACE' to start/stop progress, 'r' to reset progress, 'q' to quit)")
.centered()
.render(p2_area, buf);
}
impl App {
fn render_gauge1(&self, area: Rect, buf: &mut Buffer) {
LineGauge::default()
.filled_style(Style::default().fg(tailwind::LIME.c400))
.unfilled_style(Style::default().fg(tailwind::LIME.c800))
.ratio(self.progress)
.render(area, buf);
}
fn render_gauge2(&self, area: Rect, buf: &mut Buffer) {
LineGauge::default()
.filled_symbol("")
.unfilled_symbol("")
.filled_style(Style::default().fg(tailwind::CYAN.c400))
.unfilled_style(Style::default().fg(tailwind::CYAN.c800))
.ratio(self.progress)
.render(area, buf);
}
fn render_gauge3(&self, area: Rect, buf: &mut Buffer) {
LineGauge::default()
.filled_symbol("")
.unfilled_symbol("")
.filled_style(Style::default().fg(tailwind::BLUE.c400))
.unfilled_style(Style::default().fg(tailwind::BLUE.c800))
.ratio(self.progress)
.render(area, buf);
}
}

View File

@@ -234,7 +234,8 @@ fn get_unicode_block<'a>(frac: f64) -> &'a str {
///
/// This can be useful to indicate the progression of a task, like a download.
///
/// A `LineGauge` renders a thin line filled according to the value given to [`LineGauge::ratio`].
/// A `LineGauge` renders a line filled with symbols defined by [`LineGauge::filled_symbol`] and
/// [`LineGauge::unfilled_symbol`] according to the value given to [`LineGauge::ratio`].
/// Unlike [`Gauge`], only the width can be defined by the [rendering](Widget::render) [`Rect`]. The
/// height is always 1.
///
@@ -259,24 +260,40 @@ fn get_unicode_block<'a>(frac: f64) -> &'a str {
/// LineGauge::default()
/// .block(Block::bordered().title("Progress"))
/// .filled_style(Style::new().white().on_black().bold())
/// .line_set(symbols::line::THICK)
/// .filled_symbol(symbols::line::THICK_HORIZONTAL)
/// .ratio(0.4);
/// ```
///
/// # See also
///
/// - [`Gauge`] for bigger, higher precision and more configurable progress bar
#[derive(Debug, Default, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq)]
pub struct LineGauge<'a> {
block: Option<Block<'a>>,
ratio: f64,
label: Option<Line<'a>>,
line_set: symbols::line::Set,
style: Style,
filled_symbol: &'a str,
unfilled_symbol: &'a str,
filled_style: Style,
unfilled_style: Style,
}
impl Default for LineGauge<'_> {
fn default() -> Self {
Self {
block: None,
ratio: 0.0,
label: None,
style: Style::default(),
filled_symbol: symbols::line::HORIZONTAL,
unfilled_symbol: symbols::line::HORIZONTAL,
filled_style: Style::default(),
unfilled_style: Style::default(),
}
}
}
impl<'a> LineGauge<'a> {
/// Surrounds the `LineGauge` with a [`Block`].
#[must_use = "method moves the value of self and returns the modified value"]
@@ -311,8 +328,27 @@ impl<'a> LineGauge<'a> {
/// [`NORMAL`](symbols::line::NORMAL), [`DOUBLE`](symbols::line::DOUBLE) and
/// [`THICK`](symbols::line::THICK).
#[must_use = "method moves the value of self and returns the modified value"]
#[deprecated(
since = "0.30.0",
note = "You should use `LineGauge::filled_symbol` and `LineGauge::unfilled_symbol` instead."
)]
pub const fn line_set(mut self, set: symbols::line::Set) -> Self {
self.line_set = set;
self.filled_symbol = set.horizontal;
self.unfilled_symbol = set.horizontal;
self
}
/// Sets the symbol for the filled part of the gauge.
#[must_use = "method moves the value of self and returns the modified value"]
pub const fn filled_symbol(mut self, symbol: &'a str) -> Self {
self.filled_symbol = symbol;
self
}
/// Sets the symbol for the unfilled part of the gauge.
#[must_use = "method moves the value of self and returns the modified value"]
pub const fn unfilled_symbol(mut self, symbol: &'a str) -> Self {
self.unfilled_symbol = symbol;
self
}
@@ -412,12 +448,12 @@ impl Widget for &LineGauge<'_> {
+ (f64::from(gauge_area.right().saturating_sub(start)) * self.ratio).floor() as u16;
for col in start..end {
buf[(col, row)]
.set_symbol(self.line_set.horizontal)
.set_symbol(self.filled_symbol)
.set_style(self.filled_style);
}
for col in end..gauge_area.right() {
buf[(col, row)]
.set_symbol(self.line_set.horizontal)
.set_symbol(self.unfilled_symbol)
.set_style(self.unfilled_style);
}
}
@@ -520,6 +556,27 @@ mod tests {
);
}
#[test]
fn line_gauge_set_filled_symbol() {
assert_eq!(LineGauge::default().filled_symbol("").filled_symbol, "");
}
#[test]
fn line_gauge_set_unfilled_symbol() {
assert_eq!(
LineGauge::default().unfilled_symbol("").unfilled_symbol,
""
);
}
#[allow(deprecated)]
#[test]
fn line_gauge_deprecated_line_set() {
let gauge = LineGauge::default().line_set(symbols::line::DOUBLE);
assert_eq!(gauge.filled_symbol, symbols::line::DOUBLE.horizontal);
assert_eq!(gauge.unfilled_symbol, symbols::line::DOUBLE.horizontal);
}
#[test]
fn line_gauge_default() {
assert_eq!(
@@ -529,7 +586,8 @@ mod tests {
ratio: 0.0,
label: None,
style: Style::default(),
line_set: symbols::line::NORMAL,
filled_symbol: symbols::line::HORIZONTAL,
unfilled_symbol: symbols::line::HORIZONTAL,
filled_style: Style::default(),
unfilled_style: Style::default()
}

View File

@@ -192,11 +192,6 @@ name = "layout"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "line_gauge"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "hyperlink"
required-features = ["crossterm"]

View File

@@ -1,179 +0,0 @@
//! # [Ratatui] Line Gauge example
//!
//! The latest version of this example is available in the [examples] folder in the repository.
//!
//! Please note that the examples are designed to be run against the `main` branch of the Github
//! repository. This means that you may not be able to compile with the latest release version on
//! crates.io, or the one that you have installed locally.
//!
//! See the [examples readme] for more information on finding examples that match the version of the
//! library you are using.
//!
//! [Ratatui]: https://github.com/ratatui/ratatui
//! [examples]: https://github.com/ratatui/ratatui/blob/main/examples
//! [examples readme]: https://github.com/ratatui/ratatui/blob/main/examples/README.md
use std::time::Duration;
use color_eyre::Result;
use ratatui::{
buffer::Buffer,
crossterm::event::{self, Event, KeyCode, KeyEventKind},
layout::{Alignment, Constraint, Layout, Rect},
style::{palette::tailwind, Color, Style, Stylize},
text::Line,
widgets::{Block, Borders, LineGauge, Padding, Paragraph, Widget},
DefaultTerminal,
};
const CUSTOM_LABEL_COLOR: Color = tailwind::SLATE.c200;
fn main() -> Result<()> {
color_eyre::install()?;
let terminal = ratatui::init();
let app_result = App::default().run(terminal);
ratatui::restore();
app_result
}
#[derive(Debug, Default, Clone, Copy)]
struct App {
state: AppState,
progress_columns: u16,
progress: f64,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
enum AppState {
#[default]
Running,
Started,
Quitting,
}
impl App {
fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
while self.state != AppState::Quitting {
terminal.draw(|frame| frame.render_widget(&self, frame.area()))?;
self.handle_events()?;
self.update(terminal.size()?.width);
}
Ok(())
}
fn update(&mut self, terminal_width: u16) {
if self.state != AppState::Started {
return;
}
self.progress_columns = (self.progress_columns + 1).clamp(0, terminal_width);
self.progress = f64::from(self.progress_columns) / f64::from(terminal_width);
}
fn handle_events(&mut self) -> Result<()> {
let timeout = Duration::from_secs_f32(1.0 / 20.0);
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char(' ') | KeyCode::Enter => self.start(),
KeyCode::Char('q') | KeyCode::Esc => self.quit(),
_ => {}
}
}
}
}
Ok(())
}
fn start(&mut self) {
self.state = AppState::Started;
}
fn quit(&mut self) {
self.state = AppState::Quitting;
}
}
impl Widget for &App {
fn render(self, area: Rect, buf: &mut Buffer) {
use Constraint::{Length, Min, Ratio};
let layout = Layout::vertical([Length(2), Min(0), Length(1)]);
let [header_area, main_area, footer_area] = layout.areas(area);
let layout = Layout::vertical([Ratio(1, 3); 3]);
let [gauge1_area, gauge2_area, gauge3_area] = layout.areas(main_area);
header().render(header_area, buf);
footer().render(footer_area, buf);
self.render_gauge1(gauge1_area, buf);
self.render_gauge2(gauge2_area, buf);
self.render_gauge3(gauge3_area, buf);
}
}
fn header() -> impl Widget {
Paragraph::new("Ratatui Line Gauge Example")
.bold()
.alignment(Alignment::Center)
.fg(CUSTOM_LABEL_COLOR)
}
fn footer() -> impl Widget {
Paragraph::new("Press ENTER / SPACE to start")
.alignment(Alignment::Center)
.fg(CUSTOM_LABEL_COLOR)
.bold()
}
impl App {
fn render_gauge1(&self, area: Rect, buf: &mut Buffer) {
let title = title_block("Blue / red only foreground");
LineGauge::default()
.block(title)
.filled_style(Style::default().fg(Color::Blue))
.unfilled_style(Style::default().fg(Color::Red))
.label("Foreground:")
.ratio(self.progress)
.render(area, buf);
}
fn render_gauge2(&self, area: Rect, buf: &mut Buffer) {
let title = title_block("Blue / red only background");
LineGauge::default()
.block(title)
.filled_style(Style::default().fg(Color::Blue).bg(Color::Blue))
.unfilled_style(Style::default().fg(Color::Red).bg(Color::Red))
.label("Background:")
.ratio(self.progress)
.render(area, buf);
}
fn render_gauge3(&self, area: Rect, buf: &mut Buffer) {
let title = title_block("Fully styled with background");
LineGauge::default()
.block(title)
.filled_style(
Style::default()
.fg(tailwind::BLUE.c400)
.bg(tailwind::BLUE.c600),
)
.unfilled_style(
Style::default()
.fg(tailwind::RED.c400)
.bg(tailwind::RED.c800),
)
.label("Both:")
.ratio(self.progress)
.render(area, buf);
}
}
fn title_block(title: &str) -> Block {
Block::default()
.title(Line::from(title).centered())
.borders(Borders::NONE)
.fg(CUSTOM_LABEL_COLOR)
.padding(Padding::vertical(1))
}

View File

@@ -3,12 +3,10 @@
Output "target/line_gauge.gif"
Set Theme "Aardvark Blue"
Set Width 1200
Set Height 850
Set Height 400
Hide
Type "cargo run --example=line_gauge --features=crossterm"
Type "cargo run -p ratatui-widgets --example line_gauge"
Enter
Sleep 2s
Sleep 1s
Show
Sleep 2s
Enter 1
Sleep 15s
Sleep 6s

View File

@@ -175,7 +175,7 @@ fn widgets_gauge_supports_large_labels() {
#[test]
fn widgets_line_gauge_renders() {
let backend = TestBackend::new(20, 4);
let backend = TestBackend::new(20, 6);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
@@ -192,10 +192,12 @@ fn widgets_line_gauge_renders() {
height: 1,
},
);
// custom (same) symbols for filled and unfilled parts
let gauge = LineGauge::default()
.block(Block::bordered().title("Gauge 2"))
.filled_style(Style::default().fg(Color::Green))
.line_set(symbols::line::THICK)
.filled_symbol(symbols::line::THICK_HORIZONTAL)
.unfilled_symbol(symbols::line::THICK_HORIZONTAL)
.ratio(0.211_313_934_313_1);
f.render_widget(
gauge,
@@ -206,6 +208,31 @@ fn widgets_line_gauge_renders() {
height: 3,
},
);
// default symbol for filled part, but empty for unfilled part
let gauge = LineGauge::default().unfilled_symbol(" ").ratio(0.50);
f.render_widget(
gauge,
Rect {
x: 0,
y: 4,
width: 20,
height: 1,
},
);
// different custom symbols for filled unfilled parts
let gauge = LineGauge::default()
.filled_symbol("") // similar to `symbols::bar::FULL`
.unfilled_symbol("") // similar to `symbols::shade::LIGHT`
.ratio(0.80);
f.render_widget(
gauge,
Rect {
x: 0,
y: 5,
width: 20,
height: 1,
},
);
})
.unwrap();
let mut expected = Buffer::with_lines([
@@ -213,6 +240,8 @@ fn widgets_line_gauge_renders() {
"┌Gauge 2───────────┐",
"│21% ━━━━━━━━━━━━━━│",
"└──────────────────┘",
"50% ──────── ",
"80% ████████████░░░░",
]);
for col in 4..10 {
expected[(col, 0)].set_fg(Color::Green);