mirror of
https://github.com/ratatui/ratatui.git
synced 2025-09-29 22:11:34 +00:00
feat(no_std)!: replace Backend
's io::Error
usages with associated Error
type (#1778)
Resolves #1775 BREAKING CHANGE: Custom backends now have to implement `Backend::Error` and `Backend::clear_region`. Additionally some generic `Backend` usage will have to explicitly set trait bounds for `Backend::Error`.
This commit is contained in:
parent
3745a67ba0
commit
007713e50a
@ -16,6 +16,9 @@ This is a quick summary of the sections below:
|
||||
- `List::highlight_symbol` now accepts `Into<Line>` instead of `&str`
|
||||
- 'layout::Alignment' is renamed to 'layout::HorizontalAlignment'
|
||||
- The MSRV is now 1.81.0
|
||||
- `Backend` now requires an associated `Error` type and `clear_region` method
|
||||
- `Backend` now uses `Self::Error` for error handling instead of `std::io::Error`
|
||||
- `Terminal<B>` now uses `B::Error` for error handling instead of `std::io::Error`
|
||||
- [v0.29.0](#v0290)
|
||||
- `Sparkline::data` takes `IntoIterator<Item = SparklineBar>` instead of `&[u64]` and is no longer const
|
||||
- Removed public fields from `Rect` iterators
|
||||
|
@ -47,7 +47,10 @@ fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut app: App,
|
||||
tick_rate: Duration,
|
||||
) -> io::Result<()> {
|
||||
) -> Result<(), Box<dyn Error>>
|
||||
where
|
||||
B::Error: 'static,
|
||||
{
|
||||
let mut last_tick = Instant::now();
|
||||
loop {
|
||||
terminal.draw(|frame| ui::draw(frame, &mut app))?;
|
||||
|
@ -36,7 +36,10 @@ fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut app: App,
|
||||
tick_rate: Duration,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
) -> Result<(), Box<dyn Error>>
|
||||
where
|
||||
B::Error: 'static,
|
||||
{
|
||||
let events = events(tick_rate);
|
||||
loop {
|
||||
terminal.draw(|frame| ui::draw(frame, &mut app))?;
|
||||
|
@ -158,12 +158,15 @@ fn downloads() -> Downloads {
|
||||
}
|
||||
|
||||
#[expect(clippy::needless_pass_by_value)]
|
||||
fn run(
|
||||
terminal: &mut Terminal<impl Backend>,
|
||||
fn run<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
workers: Vec<Worker>,
|
||||
mut downloads: Downloads,
|
||||
rx: mpsc::Receiver<Event>,
|
||||
) -> Result<()> {
|
||||
) -> Result<()>
|
||||
where
|
||||
B::Error: Send + Sync + 'static,
|
||||
{
|
||||
let mut redraw = true;
|
||||
loop {
|
||||
if redraw {
|
||||
|
@ -100,8 +100,6 @@
|
||||
//! [Examples]: https://github.com/ratatui/ratatui/tree/main/ratatui/examples/README.md
|
||||
//! [Backend Comparison]: https://ratatui.rs/concepts/backends/comparison/
|
||||
//! [Ratatui Website]: https://ratatui.rs
|
||||
use alloc::format;
|
||||
use std::io;
|
||||
|
||||
use strum::{Display, EnumString};
|
||||
|
||||
@ -148,19 +146,22 @@ pub struct WindowSize {
|
||||
///
|
||||
/// [`Terminal`]: https://docs.rs/ratatui/latest/ratatui/struct.Terminal.html
|
||||
pub trait Backend {
|
||||
/// Error type associated with this Backend.
|
||||
type Error: core::error::Error;
|
||||
|
||||
/// Draw the given content to the terminal screen.
|
||||
///
|
||||
/// The content is provided as an iterator over `(u16, u16, &Cell)` tuples, where the first two
|
||||
/// elements represent the x and y coordinates, and the third element is a reference to the
|
||||
/// [`Cell`] to be drawn.
|
||||
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
|
||||
fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
|
||||
where
|
||||
I: Iterator<Item = (u16, u16, &'a Cell)>;
|
||||
|
||||
/// Insert `n` line breaks to the terminal screen.
|
||||
///
|
||||
/// This method is optional and may not be implemented by all backends.
|
||||
fn append_lines(&mut self, _n: u16) -> io::Result<()> {
|
||||
fn append_lines(&mut self, _n: u16) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -182,14 +183,14 @@ pub trait Backend {
|
||||
/// ```
|
||||
///
|
||||
/// [`show_cursor`]: Self::show_cursor
|
||||
fn hide_cursor(&mut self) -> io::Result<()>;
|
||||
fn hide_cursor(&mut self) -> Result<(), Self::Error>;
|
||||
|
||||
/// Show the cursor on the terminal screen.
|
||||
///
|
||||
/// See [`hide_cursor`] for an example.
|
||||
///
|
||||
/// [`hide_cursor`]: Self::hide_cursor
|
||||
fn show_cursor(&mut self) -> io::Result<()>;
|
||||
fn show_cursor(&mut self) -> Result<(), Self::Error>;
|
||||
|
||||
/// Get the current cursor position on the terminal screen.
|
||||
///
|
||||
@ -199,7 +200,7 @@ pub trait Backend {
|
||||
/// See [`set_cursor_position`] for an example.
|
||||
///
|
||||
/// [`set_cursor_position`]: Self::set_cursor_position
|
||||
fn get_cursor_position(&mut self) -> io::Result<Position>;
|
||||
fn get_cursor_position(&mut self) -> Result<Position, Self::Error>;
|
||||
|
||||
/// Set the cursor position on the terminal screen to the given x and y coordinates.
|
||||
///
|
||||
@ -216,14 +217,14 @@ pub trait Backend {
|
||||
/// assert_eq!(backend.get_cursor_position()?, Position { x: 10, y: 20 });
|
||||
/// # std::io::Result::Ok(())
|
||||
/// ```
|
||||
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()>;
|
||||
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error>;
|
||||
|
||||
/// Get the current cursor position on the terminal screen.
|
||||
///
|
||||
/// The returned tuple contains the x and y coordinates of the cursor. The origin
|
||||
/// (0, 0) is at the top left corner of the screen.
|
||||
#[deprecated = "use `get_cursor_position()` instead which returns `Result<Position>`"]
|
||||
fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
|
||||
fn get_cursor(&mut self) -> Result<(u16, u16), Self::Error> {
|
||||
let Position { x, y } = self.get_cursor_position()?;
|
||||
Ok((x, y))
|
||||
}
|
||||
@ -232,7 +233,7 @@ pub trait Backend {
|
||||
///
|
||||
/// The origin (0, 0) is at the top left corner of the screen.
|
||||
#[deprecated = "use `set_cursor_position((x, y))` instead which takes `impl Into<Position>`"]
|
||||
fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
|
||||
fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), Self::Error> {
|
||||
self.set_cursor_position(Position { x, y })
|
||||
}
|
||||
|
||||
@ -248,7 +249,7 @@ pub trait Backend {
|
||||
/// backend.clear()?;
|
||||
/// # std::io::Result::Ok(())
|
||||
/// ```
|
||||
fn clear(&mut self) -> io::Result<()>;
|
||||
fn clear(&mut self) -> Result<(), Self::Error>;
|
||||
|
||||
/// Clears a specific region of the terminal specified by the [`ClearType`] parameter
|
||||
///
|
||||
@ -273,17 +274,7 @@ pub trait Backend {
|
||||
/// return an error if the `clear_type` is not supported by the backend.
|
||||
///
|
||||
/// [`clear`]: Self::clear
|
||||
fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
|
||||
match clear_type {
|
||||
ClearType::All => self.clear(),
|
||||
ClearType::AfterCursor
|
||||
| ClearType::BeforeCursor
|
||||
| ClearType::CurrentLine
|
||||
| ClearType::UntilNewLine => Err(io::Error::other(format!(
|
||||
"clear_type [{clear_type:?}] not supported with this backend"
|
||||
))),
|
||||
}
|
||||
}
|
||||
fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error>;
|
||||
|
||||
/// Get the size of the terminal screen in columns/rows as a [`Size`].
|
||||
///
|
||||
@ -297,19 +288,19 @@ pub trait Backend {
|
||||
/// use ratatui::{backend::Backend, layout::Size};
|
||||
///
|
||||
/// assert_eq!(backend.size()?, Size::new(80, 25));
|
||||
/// # std::io::Result::Ok(())
|
||||
/// # Result::Ok(())
|
||||
/// ```
|
||||
fn size(&self) -> io::Result<Size>;
|
||||
fn size(&self) -> Result<Size, Self::Error>;
|
||||
|
||||
/// Get the size of the terminal screen in columns/rows and pixels as a [`WindowSize`].
|
||||
///
|
||||
/// The reason for this not returning only the pixel size, given the redundancy with the
|
||||
/// `size()` method, is that the underlying backends most likely get both values with one
|
||||
/// syscall, and the user is also most likely to need columns and rows along with pixel size.
|
||||
fn window_size(&mut self) -> io::Result<WindowSize>;
|
||||
fn window_size(&mut self) -> Result<WindowSize, Self::Error>;
|
||||
|
||||
/// Flush any buffered content to the terminal screen.
|
||||
fn flush(&mut self) -> io::Result<()>;
|
||||
fn flush(&mut self) -> Result<(), Self::Error>;
|
||||
|
||||
/// Scroll a region of the screen upwards, where a region is specified by a (half-open) range
|
||||
/// of rows.
|
||||
@ -345,7 +336,7 @@ pub trait Backend {
|
||||
&mut self,
|
||||
region: core::ops::Range<u16>,
|
||||
line_count: u16,
|
||||
) -> io::Result<()>;
|
||||
) -> Result<(), Self::Error>;
|
||||
|
||||
/// Scroll a region of the screen downwards, where a region is specified by a (half-open) range
|
||||
/// of rows.
|
||||
@ -370,7 +361,7 @@ pub trait Backend {
|
||||
&mut self,
|
||||
region: core::ops::Range<u16>,
|
||||
line_count: u16,
|
||||
) -> io::Result<()>;
|
||||
) -> Result<(), Self::Error>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -234,6 +234,8 @@ impl fmt::Display for TestBackend {
|
||||
}
|
||||
|
||||
impl Backend for TestBackend {
|
||||
type Error = io::Error;
|
||||
|
||||
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
|
||||
where
|
||||
I: Iterator<Item = (u16, u16, &'a Cell)>,
|
||||
|
@ -1,4 +1,4 @@
|
||||
use std::{eprintln, io};
|
||||
use std::eprintln;
|
||||
|
||||
use crate::backend::{Backend, ClearType};
|
||||
use crate::buffer::{Buffer, Cell};
|
||||
@ -115,7 +115,7 @@ where
|
||||
/// let terminal = Terminal::new(backend)?;
|
||||
/// # std::io::Result::Ok(())
|
||||
/// ```
|
||||
pub fn new(backend: B) -> io::Result<Self> {
|
||||
pub fn new(backend: B) -> Result<Self, B::Error> {
|
||||
Self::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
@ -138,7 +138,7 @@ where
|
||||
/// let terminal = Terminal::with_options(backend, TerminalOptions { viewport })?;
|
||||
/// # std::io::Result::Ok(())
|
||||
/// ```
|
||||
pub fn with_options(mut backend: B, options: TerminalOptions) -> io::Result<Self> {
|
||||
pub fn with_options(mut backend: B, options: TerminalOptions) -> Result<Self, B::Error> {
|
||||
let area = match options.viewport {
|
||||
Viewport::Fullscreen | Viewport::Inline(_) => {
|
||||
Rect::from((Position::ORIGIN, backend.size()?))
|
||||
@ -193,7 +193,7 @@ where
|
||||
|
||||
/// Obtains a difference between the previous and the current buffer and passes it to the
|
||||
/// current backend for drawing.
|
||||
pub fn flush(&mut self) -> io::Result<()> {
|
||||
pub fn flush(&mut self) -> Result<(), B::Error> {
|
||||
let previous_buffer = &self.buffers[1 - self.current];
|
||||
let current_buffer = &self.buffers[self.current];
|
||||
let updates = previous_buffer.diff(current_buffer);
|
||||
@ -207,7 +207,7 @@ where
|
||||
///
|
||||
/// Requested area will be saved to remain consistent when rendering. This leads to a full clear
|
||||
/// of the screen.
|
||||
pub fn resize(&mut self, area: Rect) -> io::Result<()> {
|
||||
pub fn resize(&mut self, area: Rect) -> Result<(), B::Error> {
|
||||
let next_area = match self.viewport {
|
||||
Viewport::Inline(height) => {
|
||||
let offset_in_previous_viewport = self
|
||||
@ -238,7 +238,7 @@ where
|
||||
}
|
||||
|
||||
/// Queries the backend for size and resizes if it doesn't match the previous size.
|
||||
pub fn autoresize(&mut self) -> io::Result<()> {
|
||||
pub fn autoresize(&mut self) -> Result<(), B::Error> {
|
||||
// fixed viewports do not get autoresized
|
||||
if matches!(self.viewport, Viewport::Fullscreen | Viewport::Inline(_)) {
|
||||
let area = Rect::from((Position::ORIGIN, self.size()?));
|
||||
@ -299,13 +299,13 @@ where
|
||||
/// }
|
||||
/// # std::io::Result::Ok(())
|
||||
/// ```
|
||||
pub fn draw<F>(&mut self, render_callback: F) -> io::Result<CompletedFrame>
|
||||
pub fn draw<F>(&mut self, render_callback: F) -> Result<CompletedFrame, B::Error>
|
||||
where
|
||||
F: FnOnce(&mut Frame),
|
||||
{
|
||||
self.try_draw(|frame| {
|
||||
render_callback(frame);
|
||||
io::Result::Ok(())
|
||||
Ok::<(), B::Error>(())
|
||||
})
|
||||
}
|
||||
|
||||
@ -374,10 +374,10 @@ where
|
||||
/// }
|
||||
/// # io::Result::Ok(())
|
||||
/// ```
|
||||
pub fn try_draw<F, E>(&mut self, render_callback: F) -> io::Result<CompletedFrame>
|
||||
pub fn try_draw<F, E>(&mut self, render_callback: F) -> Result<CompletedFrame, B::Error>
|
||||
where
|
||||
F: FnOnce(&mut Frame) -> Result<(), E>,
|
||||
E: Into<io::Error>,
|
||||
E: Into<B::Error>,
|
||||
{
|
||||
// Autoresize - otherwise we get glitches if shrinking or potential desync between widgets
|
||||
// and the terminal (if growing), which may OOB.
|
||||
@ -421,14 +421,14 @@ where
|
||||
}
|
||||
|
||||
/// Hides the cursor.
|
||||
pub fn hide_cursor(&mut self) -> io::Result<()> {
|
||||
pub fn hide_cursor(&mut self) -> Result<(), B::Error> {
|
||||
self.backend.hide_cursor()?;
|
||||
self.hidden_cursor = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows the cursor.
|
||||
pub fn show_cursor(&mut self) -> io::Result<()> {
|
||||
pub fn show_cursor(&mut self) -> Result<(), B::Error> {
|
||||
self.backend.show_cursor()?;
|
||||
self.hidden_cursor = false;
|
||||
Ok(())
|
||||
@ -439,26 +439,26 @@ where
|
||||
/// This is the position of the cursor after the last draw call and is returned as a tuple of
|
||||
/// `(x, y)` coordinates.
|
||||
#[deprecated = "use `get_cursor_position()` instead which returns `Result<Position>`"]
|
||||
pub fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
|
||||
pub fn get_cursor(&mut self) -> Result<(u16, u16), B::Error> {
|
||||
let Position { x, y } = self.get_cursor_position()?;
|
||||
Ok((x, y))
|
||||
}
|
||||
|
||||
/// Sets the cursor position.
|
||||
#[deprecated = "use `set_cursor_position((x, y))` instead which takes `impl Into<Position>`"]
|
||||
pub fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
|
||||
pub fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), B::Error> {
|
||||
self.set_cursor_position(Position { x, y })
|
||||
}
|
||||
|
||||
/// Gets the current cursor position.
|
||||
///
|
||||
/// This is the position of the cursor after the last draw call.
|
||||
pub fn get_cursor_position(&mut self) -> io::Result<Position> {
|
||||
pub fn get_cursor_position(&mut self) -> Result<Position, B::Error> {
|
||||
self.backend.get_cursor_position()
|
||||
}
|
||||
|
||||
/// Sets the cursor position.
|
||||
pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
|
||||
pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), B::Error> {
|
||||
let position = position.into();
|
||||
self.backend.set_cursor_position(position)?;
|
||||
self.last_known_cursor_pos = position;
|
||||
@ -466,7 +466,7 @@ where
|
||||
}
|
||||
|
||||
/// Clear the terminal and force a full redraw on the next draw call.
|
||||
pub fn clear(&mut self) -> io::Result<()> {
|
||||
pub fn clear(&mut self) -> Result<(), B::Error> {
|
||||
match self.viewport {
|
||||
Viewport::Fullscreen => self.backend.clear_region(ClearType::All)?,
|
||||
Viewport::Inline(_) => {
|
||||
@ -494,7 +494,7 @@ where
|
||||
}
|
||||
|
||||
/// Queries the real size of the backend.
|
||||
pub fn size(&self) -> io::Result<Size> {
|
||||
pub fn size(&self) -> Result<Size, B::Error> {
|
||||
self.backend.size()
|
||||
}
|
||||
|
||||
@ -574,7 +574,7 @@ where
|
||||
/// .render(buf.area, buf);
|
||||
/// });
|
||||
/// ```
|
||||
pub fn insert_before<F>(&mut self, height: u16, draw_fn: F) -> io::Result<()>
|
||||
pub fn insert_before<F>(&mut self, height: u16, draw_fn: F) -> Result<(), B::Error>
|
||||
where
|
||||
F: FnOnce(&mut Buffer),
|
||||
{
|
||||
@ -593,7 +593,7 @@ where
|
||||
&mut self,
|
||||
height: u16,
|
||||
draw_fn: impl FnOnce(&mut Buffer),
|
||||
) -> io::Result<()> {
|
||||
) -> Result<(), B::Error> {
|
||||
// The approach of this function is to first render all of the lines to insert into a
|
||||
// temporary buffer, and then to loop drawing chunks from the buffer to the screen. drawing
|
||||
// this buffer onto the screen.
|
||||
@ -694,7 +694,7 @@ where
|
||||
&mut self,
|
||||
mut height: u16,
|
||||
draw_fn: impl FnOnce(&mut Buffer),
|
||||
) -> io::Result<()> {
|
||||
) -> Result<(), B::Error> {
|
||||
// The approach of this function is to first render all of the lines to insert into a
|
||||
// temporary buffer, and then to loop drawing chunks from the buffer to the screen. drawing
|
||||
// this buffer onto the screen.
|
||||
@ -766,7 +766,7 @@ where
|
||||
y_offset: u16,
|
||||
lines_to_draw: u16,
|
||||
cells: &'a [Cell],
|
||||
) -> io::Result<&'a [Cell]> {
|
||||
) -> Result<&'a [Cell], B::Error> {
|
||||
let width: usize = self.last_known_area.width.into();
|
||||
let (to_draw, remainder) = cells.split_at(width * lines_to_draw as usize);
|
||||
if lines_to_draw > 0 {
|
||||
@ -789,7 +789,7 @@ where
|
||||
y_offset: u16,
|
||||
lines_to_draw: u16,
|
||||
cells: &'a [Cell],
|
||||
) -> io::Result<&'a [Cell]> {
|
||||
) -> Result<&'a [Cell], B::Error> {
|
||||
let width: usize = self.last_known_area.width.into();
|
||||
let (to_draw, remainder) = cells.split_at(width * lines_to_draw as usize);
|
||||
if lines_to_draw > 0 {
|
||||
@ -807,7 +807,7 @@ where
|
||||
|
||||
/// Scroll the whole screen up by the given number of lines.
|
||||
#[cfg(not(feature = "scrolling-regions"))]
|
||||
fn scroll_up(&mut self, lines_to_scroll: u16) -> io::Result<()> {
|
||||
fn scroll_up(&mut self, lines_to_scroll: u16) -> Result<(), B::Error> {
|
||||
if lines_to_scroll > 0 {
|
||||
self.set_cursor_position(Position::new(
|
||||
0,
|
||||
@ -824,7 +824,7 @@ fn compute_inline_size<B: Backend>(
|
||||
height: u16,
|
||||
size: Size,
|
||||
offset_in_previous_viewport: u16,
|
||||
) -> io::Result<(Rect, Position)> {
|
||||
) -> Result<(Rect, Position), B::Error> {
|
||||
let pos = backend.get_cursor_position()?;
|
||||
let mut row = pos.y;
|
||||
|
||||
|
@ -157,6 +157,8 @@ impl<W> Backend for CrosstermBackend<W>
|
||||
where
|
||||
W: Write,
|
||||
{
|
||||
type Error = io::Error;
|
||||
|
||||
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
|
||||
where
|
||||
I: Iterator<Item = (u16, u16, &'a Cell)>,
|
||||
|
@ -140,6 +140,8 @@ impl<W> Backend for TermionBackend<W>
|
||||
where
|
||||
W: Write,
|
||||
{
|
||||
type Error = io::Error;
|
||||
|
||||
fn clear(&mut self) -> io::Result<()> {
|
||||
self.clear_region(ClearType::All)
|
||||
}
|
||||
|
@ -17,7 +17,7 @@
|
||||
use std::error::Error;
|
||||
use std::io;
|
||||
|
||||
use ratatui_core::backend::{Backend, WindowSize};
|
||||
use ratatui_core::backend::{Backend, ClearType, WindowSize};
|
||||
use ratatui_core::buffer::Cell;
|
||||
use ratatui_core::layout::{Position, Size};
|
||||
use ratatui_core::style::{Color, Modifier, Style};
|
||||
@ -119,6 +119,8 @@ impl TermwizBackend {
|
||||
}
|
||||
|
||||
impl Backend for TermwizBackend {
|
||||
type Error = io::Error;
|
||||
|
||||
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
|
||||
where
|
||||
I: Iterator<Item = (u16, u16, &'a Cell)>,
|
||||
@ -222,6 +224,18 @@ impl Backend for TermwizBackend {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
|
||||
match clear_type {
|
||||
ClearType::All => self.clear(),
|
||||
ClearType::AfterCursor
|
||||
| ClearType::BeforeCursor
|
||||
| ClearType::CurrentLine
|
||||
| ClearType::UntilNewLine => Err(io::Error::other(format!(
|
||||
"clear_type [{clear_type:?}] not supported with this backend"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> io::Result<Size> {
|
||||
let (cols, rows) = self.buffered_terminal.dimensions();
|
||||
Ok(Size::new(u16_max(cols), u16_max(rows)))
|
||||
|
Loading…
x
Reference in New Issue
Block a user