diff --git a/src/flux/ui/widget.rs b/src/flux/ui/widget.rs
index 485d962d..4323b474 100644
--- a/src/flux/ui/widget.rs
+++ b/src/flux/ui/widget.rs
@@ -1,5 +1,6 @@
pub mod container;
pub mod input;
+pub mod text;
use std::cmp;
use std::fmt::Debug;
diff --git a/src/flux/ui/widget/text.rs b/src/flux/ui/widget/text.rs
new file mode 100644
index 00000000..c1a40d12
--- /dev/null
+++ b/src/flux/ui/widget/text.rs
@@ -0,0 +1,137 @@
+use tokio::sync::mpsc::UnboundedSender;
+
+use termion::event::Key;
+
+use ratatui::backend::Backend;
+use ratatui::layout::{Constraint, Layout, Rect};
+use ratatui::text::Text;
+use ratatui::widgets::{Block, BorderType, Borders};
+
+use crate::flux::ui::theme::style;
+
+use super::{Render, Widget};
+
+pub struct ParagraphProps<'a> {
+ pub content: Text<'a>,
+ pub focus: bool,
+ pub has_header: bool,
+ pub has_footer: bool,
+}
+
+pub struct Paragraph {
+ /// Sending actions to the state store
+ pub action_tx: UnboundedSender,
+ /// Internal offset
+ offset: usize,
+ /// Internal progress
+ progress: usize,
+}
+
+impl Paragraph {
+ pub fn scroll(&self) -> (u16, u16) {
+ (self.offset as u16, 0)
+ }
+
+ pub fn prev(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+ self.offset = self.offset.saturating_sub(1);
+ self.progress = Self::scroll_percent(self.offset, len, page_size);
+ self.scroll()
+ }
+
+ pub fn next(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+ if self.progress < 100 {
+ self.offset = self.offset.saturating_add(1);
+ self.progress = Self::scroll_percent(self.offset, len, page_size);
+ }
+
+ self.scroll()
+ }
+
+ pub fn prev_page(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+ self.offset = self.offset.saturating_sub(page_size);
+ self.progress = Self::scroll_percent(self.offset, len, page_size);
+ self.scroll()
+ }
+
+ pub fn next_page(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+ let end = len.saturating_sub(page_size);
+
+ self.offset = std::cmp::min(self.offset.saturating_add(page_size), end);
+ self.progress = Self::scroll_percent(self.offset, len, page_size);
+ self.scroll()
+ }
+
+ pub fn begin(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+ self.offset = 0;
+ self.progress = Self::scroll_percent(self.offset, len, page_size);
+ self.scroll()
+ }
+
+ pub fn end(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+ self.offset = len.saturating_sub(page_size);
+ self.progress = Self::scroll_percent(self.offset, len, page_size);
+ self.scroll()
+ }
+
+ pub fn progress(&self) -> usize {
+ self.progress
+ }
+
+ fn scroll_percent(offset: usize, len: usize, height: usize) -> usize {
+ if height >= len {
+ 100
+ } else {
+ let y = offset as f64;
+ let h = height as f64;
+ let t = len.saturating_sub(1) as f64;
+ let v = y / (t - h) * 100_f64;
+
+ std::cmp::max(0, std::cmp::min(100, v as usize))
+ }
+ }
+}
+
+impl Widget for Paragraph {
+ fn new(state: &S, action_tx: UnboundedSender) -> Self
+ where
+ Self: Sized,
+ {
+ Self {
+ action_tx: action_tx.clone(),
+ offset: 0,
+ progress: 0,
+ }
+ .move_with_state(state)
+ }
+
+ fn move_with_state(self, _state: &S) -> Self
+ where
+ Self: Sized,
+ {
+ Self { ..self }
+ }
+
+ fn name(&self) -> &str {
+ "paragraph"
+ }
+
+ fn handle_key_event(&mut self, _key: Key) {}
+}
+
+impl<'a, A> Render> for Paragraph {
+ fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: ParagraphProps) {
+ let block = Block::default()
+ .borders(Borders::LEFT | Borders::RIGHT)
+ .border_type(BorderType::Rounded)
+ .border_style(style::border(props.focus));
+ frame.render_widget(block, area);
+
+ let [content_area] = Layout::horizontal([Constraint::Min(1)])
+ .horizontal_margin(2)
+ .areas(area);
+ let content =
+ ratatui::widgets::Paragraph::new(props.content.clone()).scroll((self.offset as u16, 0));
+
+ frame.render_widget(content, content_area);
+ }
+}