diff --git a/Cargo.lock b/Cargo.lock index 321a43f..aff02b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,6 +314,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "indexmap" version = "2.2.6" @@ -357,6 +366,12 @@ version = "0.2.154" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + [[package]] name = "libredox" version = "0.1.3" @@ -460,6 +475,7 @@ dependencies = [ "clap", "crossterm", "dirs", + "humansize", "paste", "ratatui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 5fee041..67d1e24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ anyhow = "1.0.86" clap = { version = "4.5.4", features = ["derive"] } crossterm = "0.27.0" dirs = "5.0.1" +humansize = "2.1.3" paste = "1.0.15" ratatui = "0.26.2" serde = { version = "1.0.202", features = ["derive"] } diff --git a/src/cmd.rs b/src/cmd.rs index 1b576ab..a25e923 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -21,6 +21,14 @@ pub struct CommandArgs { #[clap(short, long)] pub content_type: Option, + /// Force to no render the header. + #[clap(long)] + pub disable_header: bool, + + /// Force to use the header format. + #[clap(short = 'f', long)] + pub header_format: Option, + /// Force to use vertical layout. #[clap(short = 'V', long)] pub vertical: bool, diff --git a/src/config/colors.rs b/src/config/colors.rs index 2745a4d..f9e25f8 100644 --- a/src/config/colors.rs +++ b/src/config/colors.rs @@ -15,6 +15,9 @@ macro_rules! generate_colors_parse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Colors { + #[serde(default = "Colors::default_header")] + pub header: Color, + #[serde(default = "TreeColors::default")] pub tree: TreeColors, @@ -28,11 +31,12 @@ pub struct Colors { pub focus_border: Color, } -generate_colors_parse!(Colors, tree, item, data, focus_border); +generate_colors_parse!(Colors, header, tree, item, data, focus_border); impl Colors { pub fn default() -> Self { Self { + header: Self::default_header(), tree: TreeColors::default(), item: ItemColors::default(), data: DataColors::default(), @@ -40,6 +44,10 @@ impl Colors { } } + fn default_header() -> Color { + Color::new("", "", true, false) + } + fn default_focus_boder() -> Color { Color::new("magenta", "", true, false) } diff --git a/src/config/mod.rs b/src/config/mod.rs index 28d244b..4200405 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -17,6 +17,9 @@ pub struct Config { #[serde(default = "Layout::default")] pub layout: Layout, + #[serde(default = "Header::default")] + pub header: Header, + #[serde(default = "Colors::default")] pub colors: Colors, @@ -44,6 +47,15 @@ pub enum LayoutDirection { Horizontal, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Header { + #[serde(default = "Header::default_disable")] + pub disable: bool, + + #[serde(default = "Header::default_format")] + pub format: String, +} + impl Config { pub const MIN_LAYOUT_TREE_SIZE: u16 = 10; pub const MAX_LAYOUT_TREE_SIZE: u16 = 80; @@ -105,6 +117,7 @@ impl Config { pub fn default() -> Self { Self { layout: Layout::default(), + header: Header::default(), colors: Colors::default(), types: Types::default(), keys: Keys::default(), @@ -134,3 +147,20 @@ impl Layout { 40 } } + +impl Header { + fn default() -> Self { + Self { + disable: Self::default_disable(), + format: Self::default_format(), + } + } + + fn default_disable() -> bool { + false + } + + fn default_format() -> String { + "{version} - {data_source} ({content_type}) - {data_size}".to_string() + } +} diff --git a/src/main.rs b/src/main.rs index a2438df..aa549b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ use crate::cmd::CommandArgs; use crate::config::Config; use crate::config::LayoutDirection; use crate::tree::{ContentType, Tree}; -use crate::ui::app::App; +use crate::ui::{App, HeaderContext}; // Forbid large data size to ensure TUI performance const MAX_DATA_SIZE: usize = 10 * 1024 * 1024; @@ -50,8 +50,7 @@ fn run() -> Result<()> { let mut cfg = if args.ignore_config { Config::default() } else { - let config_path = args.config.clone(); - Config::load(config_path)? + Config::load(args.config)? }; if args.vertical && args.horizontal { @@ -64,6 +63,14 @@ fn run() -> Result<()> { cfg.layout.direction = LayoutDirection::Horizontal; } + if args.disable_header { + cfg.header.disable = true; + } + + if let Some(format) = args.header_format { + cfg.header.format = format; + } + if let Some(size) = args.size { cfg.layout.tree_size = size; } @@ -126,6 +133,12 @@ fn run() -> Result<()> { let tree = Tree::parse(&cfg, &data, content_type).context("parse file")?; let mut app = App::new(&cfg, tree); + + if !cfg.header.disable { + let header_ctx = HeaderContext::new(args.path, content_type, data.len()); + app.set_header(header_ctx); + } + app.show().context("show tui")?; Ok(()) diff --git a/src/ui/app.rs b/src/ui/app.rs index b778096..0917788 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -11,6 +11,7 @@ use crate::config::keys::Action; use crate::config::{Config, LayoutDirection}; use crate::tree::Tree; use crate::ui::data_block::DataBlock; +use crate::ui::header::{Header, HeaderContext}; use crate::ui::tree_overview::TreeOverview; enum Refresh { @@ -48,9 +49,15 @@ pub struct App<'a> { layout_direction: LayoutDirection, layout_tree_size: u16, + + header: Option>, + header_area: Rect, + skip_header: bool, } impl<'a> App<'a> { + const HEADER_HEIGHT: u16 = 1; + pub fn new(cfg: &'a Config, tree: Tree<'a>) -> Self { Self { cfg, @@ -62,6 +69,9 @@ impl<'a> App<'a> { data_block_area: Rect::default(), layout_direction: cfg.layout.direction, layout_tree_size: cfg.layout.tree_size, + header: None, + header_area: Rect::default(), + skip_header: false, } } @@ -114,6 +124,10 @@ impl<'a> App<'a> { } } + pub fn set_header(&mut self, ctx: HeaderContext) { + self.header = Some(Header::new(self.cfg, ctx)); + } + fn draw(&mut self, frame: &mut Frame) { self.refresh_area(frame); @@ -125,6 +139,12 @@ impl<'a> App<'a> { // TODO: When we cannot find data, should warn user (maybe message in data block?) } + if let Some(header) = self.header.as_ref() { + if !self.skip_header { + header.draw(frame, self.header_area); + } + } + let tree_focus = matches!(self.focus, ElementInFocus::TreeOverview); self.tree_overview .draw(frame, self.tree_overview_area, tree_focus); @@ -136,7 +156,37 @@ impl<'a> App<'a> { fn refresh_area(&mut self, frame: &Frame) { let tree_size = self.layout_tree_size; - let data_size = 100 - tree_size; + let data_size = 100_u16.saturating_sub(tree_size); + + // These checks should be done in config validation. + debug_assert_ne!(tree_size, 0); + debug_assert_ne!(data_size, 0); + + let frame_area = frame.size(); + let main_area = match self.header { + Some(_) => { + let Rect { height, .. } = frame_area; + if height <= Self::HEADER_HEIGHT + 1 { + // God knows under what circumstances such a small terminal would appear! + // We will not render the header. + self.skip_header = true; + frame_area + } else { + self.skip_header = false; + self.header_area = Rect { + height: Self::HEADER_HEIGHT, + y: 0, + ..frame_area + }; + Rect { + height: height.saturating_sub(Self::HEADER_HEIGHT), + y: Self::HEADER_HEIGHT, + ..frame_area + } + } + } + None => frame_area, + }; match self.layout_direction { LayoutDirection::Vertical => { @@ -144,14 +194,14 @@ impl<'a> App<'a> { Constraint::Percentage(tree_size), Constraint::Percentage(data_size), ]); - [self.tree_overview_area, self.data_block_area] = vertical.areas(frame.size()); + [self.tree_overview_area, self.data_block_area] = vertical.areas(main_area); } LayoutDirection::Horizontal => { let horizontal = Layout::horizontal([ Constraint::Percentage(tree_size), Constraint::Percentage(data_size), ]); - [self.tree_overview_area, self.data_block_area] = horizontal.areas(frame.size()); + [self.tree_overview_area, self.data_block_area] = horizontal.areas(main_area); } } } diff --git a/src/ui/data_block.rs b/src/ui/data_block.rs index bd686db..83b2d97 100644 --- a/src/ui/data_block.rs +++ b/src/ui/data_block.rs @@ -9,7 +9,7 @@ use crate::config::keys::Action; use crate::config::Config; use crate::ui::app::ScrollDirection; -pub struct DataBlock<'a> { +pub(super) struct DataBlock<'a> { cfg: &'a Config, data: String, @@ -29,7 +29,7 @@ pub struct DataBlock<'a> { impl<'a> DataBlock<'a> { const SCROLL_RETAIN: usize = 5; - pub fn new(cfg: &'a Config) -> Self { + pub(super) fn new(cfg: &'a Config) -> Self { Self { cfg, data: String::new(), @@ -45,7 +45,7 @@ impl<'a> DataBlock<'a> { } } - pub fn on_key(&mut self, action: Action) -> bool { + pub(super) fn on_key(&mut self, action: Action) -> bool { match action { Action::MoveDown => self.scroll_down(1), Action::MoveUp => self.scroll_up(1), @@ -57,14 +57,14 @@ impl<'a> DataBlock<'a> { } } - pub fn on_scroll(&mut self, direction: ScrollDirection) -> bool { + pub(super) fn on_scroll(&mut self, direction: ScrollDirection) -> bool { match direction { ScrollDirection::Up => self.scroll_up(3), ScrollDirection::Down => self.scroll_down(3), } } - pub fn scroll_first(&mut self) -> bool { + pub(super) fn scroll_first(&mut self) -> bool { let can_scroll = self.can_vertical_scroll || self.can_horizontal_scroll; let scroll_first = self.vertical_scroll == 0 && self.horizontal_scroll == 0; @@ -81,7 +81,7 @@ impl<'a> DataBlock<'a> { true } - pub fn scroll_last(&mut self) -> bool { + pub(super) fn scroll_last(&mut self) -> bool { if !self.can_vertical_scroll || self.vertical_scroll == self.vertical_scroll_last { return false; } @@ -94,7 +94,7 @@ impl<'a> DataBlock<'a> { true } - pub fn scroll_down(&mut self, lines: usize) -> bool { + pub(super) fn scroll_down(&mut self, lines: usize) -> bool { if !self.can_vertical_scroll || self.vertical_scroll == self.vertical_scroll_last { return false; } @@ -107,7 +107,7 @@ impl<'a> DataBlock<'a> { true } - pub fn scroll_up(&mut self, lines: usize) -> bool { + pub(super) fn scroll_up(&mut self, lines: usize) -> bool { if !self.can_vertical_scroll || self.vertical_scroll == 0 { return false; } @@ -117,7 +117,7 @@ impl<'a> DataBlock<'a> { true } - pub fn scroll_right(&mut self, lines: usize) -> bool { + pub(super) fn scroll_right(&mut self, lines: usize) -> bool { if !self.can_horizontal_scroll || self.horizontal_scroll == self.horizontal_scroll_last { return false; } @@ -131,7 +131,7 @@ impl<'a> DataBlock<'a> { true } - pub fn scroll_left(&mut self, lines: usize) -> bool { + pub(super) fn scroll_left(&mut self, lines: usize) -> bool { if self.horizontal_scroll == 0 { return false; } @@ -142,7 +142,7 @@ impl<'a> DataBlock<'a> { true } - pub fn update_data(&mut self, data: String, area: Rect) { + pub(super) fn update_data(&mut self, data: String, area: Rect) { if self.data == data.as_str() && self.last_area == area { // No need to update data and scroll state. return; @@ -187,7 +187,7 @@ impl<'a> DataBlock<'a> { self.last_area = area; } - pub fn draw(&mut self, frame: &mut Frame, area: Rect, focus: bool) { + pub(super) fn draw(&mut self, frame: &mut Frame, area: Rect, focus: bool) { let (border_style, border_type) = super::get_border_style( &self.cfg.colors.focus_border, &self.cfg.colors.data.border, diff --git a/src/ui/header.rs b/src/ui/header.rs new file mode 100644 index 0000000..36dbfeb --- /dev/null +++ b/src/ui/header.rs @@ -0,0 +1,65 @@ +use std::borrow::Cow; + +use ratatui::layout::{Alignment, Rect}; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::config::Config; +use crate::tree::ContentType; + +pub struct HeaderContext { + version: String, + data_source: Cow<'static, str>, + content_type: &'static str, + data_size: String, +} + +impl HeaderContext { + pub fn new(source: Option, content_type: ContentType, size: usize) -> Self { + let version = format!("otree {}", env!("CARGO_PKG_VERSION")); + let source = source.map(Cow::Owned).unwrap_or(Cow::Borrowed("stdin")); + let content_type = match content_type { + ContentType::Toml => "toml", + ContentType::Yaml => "yaml", + ContentType::Json => "json", + }; + + let data_size = humansize::format_size(size, humansize::BINARY); + + Self { + version, + data_source: source, + content_type, + data_size, + } + } + + fn format(&self, s: &str) -> String { + let s = s.replace("{version}", &self.version); + let s = s.replace("{data_source}", &self.data_source); + let s = s.replace("{content_type}", self.content_type); + s.replace("{data_size}", &self.data_size) + } +} + +pub(super) struct Header<'a> { + cfg: &'a Config, + data: String, +} + +impl<'a> Header<'a> { + pub(super) fn new(cfg: &'a Config, ctx: HeaderContext) -> Self { + Self { + cfg, + data: ctx.format(&cfg.header.format), + } + } + + pub(super) fn draw(&self, frame: &mut Frame, area: Rect) { + let span = Span::styled(self.data.as_str(), self.cfg.colors.header.style); + // TODO: Allow user to customize alignment. + let paragraph = Paragraph::new(span).alignment(Alignment::Center); + frame.render_widget(paragraph, area); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 19626f9..3b181ff 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,9 +3,13 @@ use ratatui::widgets::BorderType; use crate::config::colors::Color; -pub mod app; -pub mod data_block; -pub mod tree_overview; +mod app; +mod data_block; +mod header; +mod tree_overview; + +pub use app::App; +pub use header::HeaderContext; fn get_border_style(focus_color: &Color, normal_color: &Color, focus: bool) -> (Style, BorderType) { let color = if focus { focus_color } else { normal_color }; diff --git a/src/ui/tree_overview.rs b/src/ui/tree_overview.rs index d607594..9d0c03f 100644 --- a/src/ui/tree_overview.rs +++ b/src/ui/tree_overview.rs @@ -10,7 +10,7 @@ use crate::config::Config; use crate::tree::Tree; use crate::ui::app::ScrollDirection; -pub struct TreeOverview<'a> { +pub(super) struct TreeOverview<'a> { cfg: &'a Config, state: TreeState, tree: Option>, @@ -19,7 +19,7 @@ pub struct TreeOverview<'a> { } impl<'a> TreeOverview<'a> { - pub fn new(cfg: &'a Config, tree: Tree<'a>) -> Self { + pub(super) fn new(cfg: &'a Config, tree: Tree<'a>) -> Self { Self { cfg, state: TreeState::default(), @@ -29,7 +29,7 @@ impl<'a> TreeOverview<'a> { } } - pub fn get_selected(&self) -> Option { + pub(super) fn get_selected(&self) -> Option { let selected = self.state.get_selected(); if selected.is_empty() { return None; @@ -38,11 +38,11 @@ impl<'a> TreeOverview<'a> { Some(selected.join("/")) } - pub fn get_data(&self, id: &str) -> Option { + pub(super) fn get_data(&self, id: &str) -> Option { self.tree().details.get(id).map(|d| d.value.clone()) } - pub fn on_key(&mut self, action: Action) -> bool { + pub(super) fn on_key(&mut self, action: Action) -> bool { match action { Action::MoveUp => self.state.key_up(), Action::MoveDown => self.state.key_down(), @@ -140,7 +140,7 @@ impl<'a> TreeOverview<'a> { Some(parent) } - pub fn on_click(&mut self, index: u16) { + pub(super) fn on_click(&mut self, index: u16) { let offset = self.state.get_offset(); let index = (index as usize) + offset; @@ -150,14 +150,14 @@ impl<'a> TreeOverview<'a> { } } - pub fn on_scroll(&mut self, direction: ScrollDirection) -> bool { + pub(super) fn on_scroll(&mut self, direction: ScrollDirection) -> bool { match direction { ScrollDirection::Up => self.state.scroll_up(1), ScrollDirection::Down => self.state.scroll_down(1), } } - pub fn draw(&mut self, frame: &mut Frame, area: Rect, focus: bool) { + pub(super) fn draw(&mut self, frame: &mut Frame, area: Rect, focus: bool) { let (border_style, border_type) = super::get_border_style( &self.cfg.colors.focus_border, &self.cfg.colors.tree.border,