From 1875cfc74ad01312106b010315642dc492875ce3 Mon Sep 17 00:00:00 2001 From: keinsell Date: Mon, 10 Feb 2025 20:00:29 +0000 Subject: [PATCH] disable terminal user interface --- Cargo.lock | 154 ++++++-------------------- Cargo.toml | 11 +- docs/tui/terminal.md | 4 + src/main.rs | 19 ++-- src/tui/app.rs | 43 ++++++++ src/tui/components/footer.rs | 27 +++++ src/tui/components/header.rs | 33 ++++++ src/tui/components/mod.rs | 7 ++ src/tui/components/sidebar.rs | 69 ++++++++++++ src/tui/mod.rs | 198 ++++++++++++---------------------- src/tui/ui.rs | 38 +++++++ 11 files changed, 337 insertions(+), 266 deletions(-) create mode 100644 docs/tui/terminal.md create mode 100644 src/tui/app.rs create mode 100644 src/tui/components/footer.rs create mode 100644 src/tui/components/header.rs create mode 100644 src/tui/components/sidebar.rs create mode 100644 src/tui/ui.rs diff --git a/Cargo.lock b/Cargo.lock index cb6e7fdf..a0dfed1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -850,13 +850,14 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ "castaway", "cfg-if", "itoa", + "rustversion", "ryu", "static_assertions", ] @@ -988,19 +989,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - [[package]] name = "crossbeam-channel" version = "0.5.14" @@ -1046,15 +1034,15 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" -version = "0.27.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.8.0", "crossterm_winapi", - "libc", - "mio 0.8.11", + "mio 1.0.3", "parking_lot 0.12.3", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -2134,6 +2122,19 @@ dependencies = [ "libc", ] +[[package]] +name = "instability" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "instant" version = "0.1.13" @@ -2186,24 +2187,6 @@ dependencies = [ "nom", ] -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -2485,6 +2468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -2507,8 +2491,6 @@ dependencies = [ "clap_mangen", "colog", "confy", - "console", - "crossbeam", "crossterm", "date_time_parser", "delegate", @@ -2531,9 +2513,7 @@ dependencies = [ "predicates", "pretty_env_logger", "pubchem", - "ratatui 0.26.3", - "ratatui-textarea", - "regex", + "ratatui", "rlg 0.0.6", "rust-embed", "sea-orm", @@ -2541,9 +2521,8 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "strum 0.26.3", + "strum", "tabled", - "terminal-link", "textplots", "thiserror 2.0.11", "tokio", @@ -3233,50 +3212,23 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.24.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ebc917cfb527a566c37ecb94c7e3fd098353516fb4eb6bea17015ade0182425" -dependencies = [ - "bitflags 2.8.0", - "cassowary", - "crossterm", - "indoc", - "itertools 0.11.0", - "lru", - "paste", - "strum 0.25.0", - "unicode-segmentation", - "unicode-width 0.1.14", -] - -[[package]] -name = "ratatui" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags 2.8.0", "cassowary", "compact_str", "crossterm", - "itertools 0.12.1", + "indoc", + "instability", + "itertools", "lru", "paste", - "stability", - "strum 0.26.3", + "strum", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.1.14", -] - -[[package]] -name = "ratatui-textarea" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802cc8229dab704f3dcbc97799186a5b4b7aea63ecc928ffc7d17753152527b8" -dependencies = [ - "crossterm", - "ratatui 0.24.0", + "unicode-width 0.2.0", ] [[package]] @@ -3749,7 +3701,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum 0.26.3", + "strum", "thiserror 1.0.69", "time", "tracing", @@ -4012,7 +3964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio 0.8.11", + "mio 1.0.3", "signal-hook", ] @@ -4323,16 +4275,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "stability" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" -dependencies = [ - "quote", - "syn 2.0.98", -] - [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -4362,35 +4304,13 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" -dependencies = [ - "strum_macros 0.25.3", -] - [[package]] name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros 0.26.4", -] - -[[package]] -name = "strum_macros" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.98", + "strum_macros", ] [[package]] @@ -4548,12 +4468,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "terminal-link" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "253bcead4f3aa96243b0f8fa46f9010e87ca23bd5d0c723d474ff1d2417bbdf8" - [[package]] name = "terminal_size" version = "0.4.1" @@ -4931,7 +4845,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools 0.13.0", + "itertools", "unicode-segmentation", "unicode-width 0.1.14", ] diff --git a/Cargo.toml b/Cargo.toml index 84f00cca..f6562008 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,8 +62,6 @@ sea-orm-migration = { version = "1.1.0", features = [ serde_json = "1.0.134" owo-colors = "4.1.0" chrono-humanize = "0.2.3" -ratatui = "0.26.1" -crossterm = "0.27.0" async-trait = "0.1.85" indicatif = "0.17.9" tracing-subscriber = "0.3.18" @@ -72,13 +70,10 @@ tracing = "0.1.40" predicates = "3.1.3" serde_derive = "1.0.217" thiserror = "2.0.11" -ratatui-textarea = "0.4.1" -regex = "1.11.1" cached = {version = "0.54.0", features = ["disk_store", "async"]} derive_more = {version = "1.0.0", features = ["full"]} strum = "0.26.3" human-panic = "2.0.2" -crossbeam = { version = "0.8.4", features = ["crossbeam-channel"] } uuid = { version = "1.12.1", features = ["v4"] } etcetera = "0.8.0" tracing-appender = "0.2.3" @@ -89,11 +84,11 @@ pretty_env_logger = "0.5.0" textplots = "0.8.6" valuable = "0.1.1" derive = "1.0.0" -console = "0.15" -terminal-link = "0.1" +crossterm = "0.28.1" +ratatui = "0.29.0" [features] -default = ["tui"] +default = [] tui = [] [expand] diff --git a/docs/tui/terminal.md b/docs/tui/terminal.md new file mode 100644 index 00000000..50c44d86 --- /dev/null +++ b/docs/tui/terminal.md @@ -0,0 +1,4 @@ +Header Bar: Displays the application title, version, and system status (like current date/time). +Navigation Sidebar / Tab Bar: Allows users to switch between different views (Dashboard, Medications, History, Alerts, Settings). +Main Content Area: The primary region for displaying dosage schedules, logs, or forms. +Footer / Status Bar: Shows context-sensitive hints, key bindings, and error or status messages. \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 6aa19c9b..47de9c05 100755 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,7 @@ use clap::Parser; use core::CommandHandler; use miette::Result; use std::env; - +use tracing_subscriber::util::SubscriberInitExt; mod cli; mod core; @@ -41,14 +41,21 @@ async fn main() -> Result<()> // https://apple.github.io/swift-argument-parser/documentation/argumentparser/installingcompletionscripts/ // https://unix.stackexchange.com/a/605051 - let no_args_provided = env::args().len() == 1; - let is_interactive_terminal = atty::is(Stream::Stdout); - - if no_args_provided && is_interactive_terminal + #[cfg(feature = "tui")] { - return tui::tui().await.map_err(|e| miette::miette!(e.to_string())); + let no_args_provided = env::args().len() == 1; + let is_interactive_terminal = atty::is(Stream::Stdout); + + if no_args_provided && is_interactive_terminal + { + let terminal = tui::init()?; + let result = tui::run(terminal); + tui::restore()?; + return result; + } } + let cli = Cli::parse(); let context = AppContext { diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 00000000..cf3d1981 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,43 @@ +use miette::Result; +use ratatui::prelude::*; +use std::time::Instant; + +use super::components::Footer; +use super::components::Header; +use super::components::Sidebar; + +pub struct App +{ + pub running: bool, + pub last_tick: Instant, + pub header: Header, + pub footer: Footer, + pub sidebar: Sidebar, +} + +impl Default for App +{ + fn default() -> Self + { + Self { + running: true, + last_tick: Instant::now(), + header: Header::new(), + footer: Footer::new(), + sidebar: Sidebar::new(), + } + } +} + +impl App +{ + pub fn new() -> Self { Self::default() } + + pub fn tick(&mut self) { self.last_tick = Instant::now(); } + + pub fn quit(&mut self) { self.running = false; } + + pub fn next_item(&mut self) { self.sidebar.next(); } + + pub fn previous_item(&mut self) { self.sidebar.previous(); } +} diff --git a/src/tui/components/footer.rs b/src/tui/components/footer.rs new file mode 100644 index 00000000..eca65ea0 --- /dev/null +++ b/src/tui/components/footer.rs @@ -0,0 +1,27 @@ +use ratatui::prelude::*; +use ratatui::widgets::Block; +use ratatui::widgets::Borders; +use ratatui::widgets::Paragraph; + +pub struct Footer; + +impl Footer +{ + pub fn new() -> Self { Self } + + pub fn render(&self, frame: &mut Frame, area: Rect) + { + let footer = Block::default().borders(Borders::ALL); + + let status = format!( + "Status: Active | Time: {}", + chrono::Local::now().format("%H:%M:%S") + ); + + let paragraph = Paragraph::new(status) + .block(footer) + .alignment(Alignment::Left); + + frame.render_widget(paragraph, area); + } +} diff --git a/src/tui/components/header.rs b/src/tui/components/header.rs new file mode 100644 index 00000000..6c2ab5c4 --- /dev/null +++ b/src/tui/components/header.rs @@ -0,0 +1,33 @@ +use ratatui::prelude::*; +use ratatui::widgets::Block; +use ratatui::widgets::Borders; +use ratatui::widgets::Paragraph; + +pub struct Header; + +impl Header +{ + pub fn new() -> Self { Self } + + pub fn render(&self, frame: &mut Frame, area: Rect) + { + let header = Block::default() + .title("Neuronek - Drug Information System") + .title_alignment(Alignment::Center) + .borders(Borders::ALL); + + let text = vec![Line::from(vec![ + "Press ".into(), + "'q'".bold(), + " to quit | ".into(), + "'h'".bold(), + " for help".into(), + ])]; + + let paragraph = Paragraph::new(text) + .block(header) + .alignment(Alignment::Center); + + frame.render_widget(paragraph, area); + } +} diff --git a/src/tui/components/mod.rs b/src/tui/components/mod.rs index e4ca8f32..ed60c98e 100644 --- a/src/tui/components/mod.rs +++ b/src/tui/components/mod.rs @@ -1 +1,8 @@ +pub mod footer; +pub mod header; pub mod intensity_plot; +pub mod sidebar; + +pub use footer::Footer; +pub use header::Header; +pub use sidebar::Sidebar; diff --git a/src/tui/components/sidebar.rs b/src/tui/components/sidebar.rs new file mode 100644 index 00000000..e8cb772c --- /dev/null +++ b/src/tui/components/sidebar.rs @@ -0,0 +1,69 @@ +use ratatui::prelude::*; +use ratatui::widgets::Block; +use ratatui::widgets::Borders; +use ratatui::widgets::List; +use ratatui::widgets::ListItem; + +pub struct Sidebar +{ + items: Vec, + selected: usize, +} + +impl Sidebar +{ + pub fn new() -> Self + { + Self { + items: vec![ + "Dashboard".to_string(), + "Substances".to_string(), + "Ingestions".to_string(), + "Statistics".to_string(), + "Settings".to_string(), + ], + selected: 0, + } + } + + pub fn next(&mut self) { self.selected = (self.selected + 1) % self.items.len(); } + + pub fn previous(&mut self) + { + if self.selected > 0 + { + self.selected -= 1; + } + else + { + self.selected = self.items.len() - 1; + } + } + + pub fn render(&self, frame: &mut Frame, area: Rect) + { + let items: Vec = self + .items + .iter() + .enumerate() + .map(|(i, item)| { + let style = if i == self.selected + { + Style::default().fg(Color::Yellow) + } + else + { + Style::default() + }; + ListItem::new(item.as_str()).style(style) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().title("Navigation").borders(Borders::ALL)) + .highlight_style(Style::default().fg(Color::Yellow)) + .highlight_symbol(">"); + + frame.render_widget(list, area); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f0fe9bd5..f5b2b3f3 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,158 +1,92 @@ -mod components; - -use crate::core::QueryHandler; -use crate::database::Ingestion; -use crate::tui::components::intensity_plot::IntensityPlot; -use chrono::Timelike; +use std::io::Stdout; +use std::io::stdout; +use std::time::Duration; +use std::time::Instant; + +use crossterm::event::DisableBracketedPaste; +use crossterm::event::DisableMouseCapture; +use crossterm::event::EnableBracketedPaste; +use crossterm::event::EnableMouseCapture; use crossterm::event::Event; use crossterm::event::KeyCode; +use crossterm::event::KeyEventKind; use crossterm::event::{self}; use crossterm::execute; -use crossterm::terminal::EnterAlternateScreen; -use crossterm::terminal::LeaveAlternateScreen; -use crossterm::terminal::disable_raw_mode; -use crossterm::terminal::enable_raw_mode; -use ratatui::Frame; -use ratatui::Terminal; -use ratatui::backend::Backend; -use ratatui::layout::Constraint; -use ratatui::layout::Direction; -use ratatui::layout::Layout; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::style::Style; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::text::Text; -use ratatui::widgets::Block; -use ratatui::widgets::Borders; -use ratatui::widgets::Paragraph; -use std::io; +use crossterm::terminal::*; +use miette::IntoDiagnostic; +use miette::Result; +use ratatui::prelude::*; -pub struct IngestionTui -{ - ingestions: Vec, -} +mod app; +mod components; +mod ui; -impl IngestionTui -{ - pub fn new(ingestions: Vec) -> Self { Self { ingestions } } +pub use app::App; - fn ui(&self, frame: &mut Frame) - { - let size = frame.size(); +const TICK_RATE: Duration = Duration::from_millis(100); - // Create main layout - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Title - Constraint::Min(0), // Main content - Constraint::Length(3), // Footer - ]) - .split(size); +pub type Tui = Terminal>; - // Render title - let title = Paragraph::new(Text::styled( - "🧬 Neuronek - Active Ingestions Monitor", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ); - frame.render_widget(title, chunks[0]); +pub fn init() -> Result +{ + enable_raw_mode().into_diagnostic()?; + execute!(stdout(), EnterAlternateScreen).into_diagnostic()?; + execute!(stdout(), EnableMouseCapture).into_diagnostic()?; + execute!(stdout(), EnableBracketedPaste).into_diagnostic()?; + let mut terminal = Terminal::new(CrosstermBackend::new(stdout())).into_diagnostic()?; + terminal.clear().into_diagnostic()?; + terminal.hide_cursor().into_diagnostic()?; + Ok(terminal) +} - // Render intensity plot - let plot = IntensityPlot::new(&self.ingestions); - frame.render_widget(plot.render(), chunks[1]); +pub fn restore() -> Result<()> +{ + execute!(stdout(), DisableBracketedPaste).into_diagnostic()?; + execute!(stdout(), DisableMouseCapture).into_diagnostic()?; + execute!(stdout(), LeaveAlternateScreen).into_diagnostic()?; + disable_raw_mode().into_diagnostic()?; + Ok(()) +} - // Render footer - let footer = Paragraph::new(Text::from(vec![Line::from(vec![ - Span::styled("q", Style::default().fg(Color::Yellow)), - Span::raw(" to quit"), - ])])) - .alignment(ratatui::layout::Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - frame.render_widget(footer, chunks[2]); - } +pub fn run(mut terminal: Tui) -> Result<()> +{ + let mut app = App::new(); + let mut last_tick = Instant::now(); - pub fn run(&mut self) -> std::io::Result<()> + while app.running { - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = ratatui::backend::CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - let result = self.render_loop(&mut terminal); + terminal + .draw(|frame| ui::render(frame, &app)) + .into_diagnostic()?; - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; + let timeout = TICK_RATE + .checked_sub(last_tick.elapsed()) + .unwrap_or(Duration::from_secs(0)); - result - } - - fn render_loop( - &mut self, - terminal: &mut Terminal>, - ) -> std::io::Result<()> - { - loop + if event::poll(timeout).into_diagnostic()? { - terminal.draw(|frame| self.ui(frame))?; - - if let Event::Key(key) = event::read()? + if let Event::Key(key) = event::read().into_diagnostic()? { - if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) + if key.kind == KeyEventKind::Press { - break; + match key.code + { + | KeyCode::Char('q') => app.quit(), + | KeyCode::Down | KeyCode::Char('j') => app.next_item(), + | KeyCode::Up | KeyCode::Char('k') => app.previous_item(), + | _ => + {} + } } } } - Ok(()) - } -} -pub async fn tui() -> std::io::Result<()> -{ - let ingestions = match crate::ingestion::query::ListIngestion::default() - .query() - .await - { - | Ok(ingestions) => + if last_tick.elapsed() >= TICK_RATE { - let analyzed_ingestions = Vec::new(); - for ingestion in ingestions - { - match crate::substance::repository::get_substance( - &ingestion.substance_name, - &crate::utils::DATABASE_CONNECTION, - ) - .await - { - | Ok(Some(_substance)) => - {} - | Ok(None) => eprintln!( - "Substance not found for ingestion: {}", - ingestion.substance_name - ), - | Err(e) => eprintln!("Error fetching substance: {}", e), - } - } - analyzed_ingestions + app.tick(); + last_tick = Instant::now(); } - | Err(e) => - { - eprintln!("Failed to load ingestions: {}", e); - Vec::new() - } - }; + } - let mut tui = IngestionTui::new(ingestions); - tui.run() + Ok(()) } diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 00000000..992b9184 --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,38 @@ +use ratatui::Frame; +use ratatui::prelude::*; +use ratatui::widgets::Block; +use ratatui::widgets::Borders; +use ratatui::widgets::Paragraph; + +use super::App; + +pub fn render(frame: &mut Frame, app: &App) +{ + // Create the layout + let main_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(0), // Body + Constraint::Length(3), // Footer + ]) + .split(frame.area()); + + let body_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(20), // Sidebar + Constraint::Percentage(80), // Main content + ]) + .split(main_layout[1]); + + // Render components + app.header.render(frame, main_layout[0]); + app.sidebar.render(frame, body_layout[0]); + + // Render main content + let main_content = Block::default().title("Content").borders(Borders::ALL); + frame.render_widget(main_content, body_layout[1]); + + app.footer.render(frame, main_layout[2]); +}