diff --git a/Cargo.lock b/Cargo.lock index d2eaa47..24699df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "bgraph" +version = "0.1.0" +source = "git+https://github.com/5ocworkshop/bgraph.git#0fdbcd9456e38d3e0204428d464c6bd59b7b59e9" +dependencies = [ + "ratatui", + "unicode-width 0.1.14", +] + [[package]] name = "bit_field" version = "0.10.2" @@ -600,6 +609,7 @@ name = "framework-tool-tui" version = "0.7.6" dependencies = [ "anyhow", + "bgraph", "color-eyre", "crossterm", "dirs", diff --git a/Cargo.toml b/Cargo.toml index a70d26b..8d2e4d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ authors = ["Michael Nedokushev "] ratatui = "0.29" crossterm = { version = "0.28.1", features = ["event-stream"] } anyhow = "1.0" +bgraph = { git = "https://github.com/5ocworkshop/bgraph.git" } framework_lib = { git = "https://github.com/FrameworkComputer/framework-system.git", package = "framework_lib" } smbios-lib = { git = "https://github.com/FrameworkComputer/smbios-lib.git", branch = "no-std", default-features = false } color-eyre = "0.6.5" diff --git a/docs/GRAPH_IMPLEMENTATION_PLAN.md b/docs/GRAPH_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..317e364 --- /dev/null +++ b/docs/GRAPH_IMPLEMENTATION_PLAN.md @@ -0,0 +1,119 @@ +# Graph Implementation Plan + +## Overview + +Add mirrored dual graph for voltage and current using the bgraph library (btop-style). Create a charge graph panel component displaying battery voltage and charger current as mirrored graphs. Build a composite charge panels component with the dual graph (left, fills remaining space) and charge panel (right, fixed narrower width, aligned to right edge). All panels stick to the right side with the graph taking remaining left space. Remove viewport constraints for full-terminal layout. + +## Implementation Steps + +### 1. Add bgraph dependency + +Add `bgraph = "0.1"` to root `Cargo.toml` dependencies section + +### 2. Remove viewport size constraints + +In `src/tui.rs` (lines 143-156): +- Eliminate the centered `Max(140×49)` constraints +- Replace with direct vertical split of `frame.area()` into: + - Title: `Length(3)` + - Main: `Fill(1)` + - Footer: `Length(3)` +- Title and footer span full terminal width at top and bottom + +### 3. Create charge graph panel component + +Create `src/tui/component/charge_graph_panel.rs`: +- Implement `Component` trait named `ChargeGraphPanelComponent` +- Add two `VecDeque` buffers for voltage and current history +- Render using bgraph's dual graph widget with mirrored layout +- Apply `ColorGradient::three_point` (blue→orange→red) with `GradientMode::Position` +- Fixed voltage range: 0-20V +- Auto-scaled current range from buffer min/max +- Add `Min` width constraint (e.g., Min 40-50 columns) to ensure usability in narrow terminals + +### 4. Create charge panels composite component + +Create `src/tui/component/charge_panels.rs`: +- Owns `ChargeGraphPanelComponent` and `ChargePanelComponent` +- Implements `Component` and `AdjustableComponent` traits +- Allocates more than half available height to dual graph +- Keeps charge panel fixed at current vertical position using dynamic padding calculation: `(available_height - charge_panel_height) / 2` +- Uses horizontal `Layout`: + - Graph: `Fill(1)` (left side) + - Charge panel: `Max(~55 columns)` (right-aligned, no gap) + +### 5. Adjust main component layout for right-aligned panels + +In `src/tui/component/main.rs`: +- Modify layout to right-align all panels +- Use horizontal `Layout`: + - Left content: `Fill(1)` or `Min` constraint for graph + - Panels column: `Max(~55-60 columns)` (slightly narrower than current) +- Replace `ChargePanelComponent` field with `ChargePanels` +- Update module declarations in `src/tui/component.rs` for both: + - `charge_graph_panel` module + - `charge_panels` module + +### 6. Update graph data with zero defaults + +In `ChargeGraphPanelComponent`'s `render` method: +- Extract `charger_voltage` and `charger_current` from `FrameworkInfo` +- Convert mV→V and mA→A using `.unwrap_or(0.0)` for None values +- Push both to history buffers together (bounded to ~150-200 samples) +- Render: + - Voltage graph on top with fixed 0-20V range + - Current graph mirrored on bottom with auto-scaled range + +## Design Decisions + +### Layout Specifications + +1. **Right panel column width**: Make panels slightly narrower (~55-60 columns) to give more space to the graph +2. **Graph fill behavior**: Use `Min` width constraint (40-50 columns) to ensure usability in narrow terminals +3. **Dual graph height scaling**: Graph takes more than half of available height, charge panel remains fixed at current vertical position +4. **Panel spacing**: No gap between graph area and right-aligned panel column + +### Data Handling + +1. **History buffer**: 150-200 samples for smooth visualization +2. **Y-axis scaling**: + - Voltage: Fixed 0-20V range for stable visualization + - Current: Auto-scaled from buffer min/max for better visibility +3. **Missing data**: Use 0.0 as default value when voltage or current is None +4. **Buffer synchronization**: Add voltage and current samples together to maintain time alignment + +### Graph Appearance + +1. **Gradient**: `ColorGradient::three_point` with blue→orange→red colors +2. **Gradient mode**: `GradientMode::Position` for btop-style appearance +3. **Layout**: Mirrored dual graph with voltage on top, current on bottom +4. **Rendering**: Braille mode for high-resolution smooth graphs + +## Files to Create + +1. `src/tui/component/charge_graph_panel.rs` - Graph panel component +2. `src/tui/component/charge_panels.rs` - Composite panel component + +## Files to Modify + +1. `Cargo.toml` - Add bgraph dependency +2. `src/tui.rs` - Remove viewport size constraints +3. `src/tui/component.rs` - Add module declarations +4. `src/tui/component/main.rs` - Replace ChargePanelComponent with ChargePanels, adjust layout +5. `src/tui/component/charge_panel.rs` - May need minor adjustments for integration + +## Dependencies + +- `bgraph = "0.1"` - For btop-style Braille graphing +- `ratatui = "0.29"` - Already present, compatible with bgraph + +## Testing Considerations + +1. Test with various terminal sizes (narrow, wide, tall, short) +2. Verify graph scales properly with terminal height +3. Confirm charge panel maintains fixed position and size +4. Validate data conversion (mV→V, mA→A) +5. Check gradient colors match btop-style appearance +6. Test with missing data (None values) +7. Verify panel navigation (Tab key) still works correctly +8. Test adjustable controls on charge panel still function diff --git a/docs/THERMAL_PANEL_PLAN.md b/docs/THERMAL_PANEL_PLAN.md new file mode 100644 index 0000000..ac82846 --- /dev/null +++ b/docs/THERMAL_PANEL_PLAN.md @@ -0,0 +1,332 @@ +# Plan: Add Thermal Panel with FAN Speed Graph + +Add a new "Thermal" panel positioned to the left of the PD Ports panel, displaying first FAN's RPM with a historical graph (auto-scaled Y-axis with 10% padding). The FAN data (`fan_rpm`) is already available in `FrameworkInfo`. + +--- + +## Step 1: Create Graph Component + +**File:** `src/tui/component/thermal_graph_panel.rs` + +Create a new component that displays a historical graph of FAN RPM using the `bgraph` crate. + +### Structure + +```rust +use bgraph::{ColorGradient, GradientMode, Graph, TimeSeriesState}; +use ratatui::{ + layout::{Constraint, Flex, Layout, Rect}, + widgets::{Block, Borders}, + Frame, +}; + +use crate::{ + framework::info::FrameworkInfo, + tui::{component::Component, theme::Theme}, +}; + +const HISTORY_SIZE: usize = 200; + +pub struct ThermalGraphPanelComponent { + fan_rpm_series: TimeSeriesState, +} +``` + +### Key Implementation Details + +1. **Auto-scaling with 10% padding:** + - Track the maximum value in the series + - Compute `y_max = max_rpm * 1.1` (10% padding above max) + - Use a minimum floor (e.g., 100.0) to avoid division issues when fan is off + +2. **Constructor:** + ```rust + impl ThermalGraphPanelComponent { + pub fn new() -> Self { + Self { + // Initial range doesn't matter much since we auto-scale + fan_rpm_series: TimeSeriesState::with_range(HISTORY_SIZE, 0.0, 6000.0), + } + } + } + ``` + +3. **Update history from FrameworkInfo:** + ```rust + fn update_history(&mut self, info: &FrameworkInfo) { + let rpm = info.fan_rpm + .as_ref() + .and_then(|rpms| rpms.first()) + .map(|&r| r as f32) + .unwrap_or(0.0); + self.fan_rpm_series.push(rpm); + } + ``` + +4. **Auto-scale calculation:** + ```rust + fn get_y_range(&self) -> (f32, f32) { + let max_rpm = self.fan_rpm_series.iter() + .fold(0.0_f32, |acc, &v| acc.max(v)); + let y_max = (max_rpm * 1.1).max(100.0); // 10% padding, minimum 100 + (0.0, y_max) + } + ``` + +5. **Render implementation:** + - Use `theme.thermal_graph_light` and `theme.thermal_graph_dark` for gradient + - Single graph (unlike charge panel which has voltage + current) + +--- + +## Step 2: Create Thermal Info Panel + +**File:** `src/tui/component/thermal_panel.rs` + +Create a panel displaying current FAN RPM value with a title. + +### Structure + +```rust +use ratatui::{ + layout::{Constraint, Flex, Layout, Rect}, + prelude::*, + widgets::{Block, BorderType, Borders, Paragraph}, + Frame, +}; + +use crate::{ + framework::info::FrameworkInfo, + tui::{component::Component, theme::Theme}, +}; + +pub struct ThermalPanelComponent; +``` + +### Key Implementation Details + +1. **Simple read-only display** (no adjustable controls for now) + +2. **Display format:** + ``` + ┌─ Thermal ─────────────┐ + │ Fan Speed 3200 RPM│ + └───────────────────────┘ + ``` + +3. **Render implementation:** + ```rust + impl Component for ThermalPanelComponent { + fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme, info: &FrameworkInfo) { + let fan_rpm_text = match info.fan_rpm.as_ref().and_then(|rpms| rpms.first()) { + Some(&rpm) => format!("{} RPM", rpm), + None => "N/A".to_string(), + }; + + // Render key-value pair similar to charge_panel + frame.render_widget(Paragraph::new(" Fan Speed"), key_area); + frame.render_widget( + Paragraph::new(fan_rpm_text).alignment(Alignment::Right), + value_area + ); + } + } + ``` + +--- + +## Step 3: Create Composite Panel + +**File:** `src/tui/component/thermal_panels.rs` + +Create a container that combines the graph and info panel. + +### Structure + +```rust +use ratatui::{ + crossterm::event::Event, + layout::{Constraint, Layout, Rect}, + style::Style, + widgets::{Block, BorderType, Borders}, + Frame, +}; + +use crate::{ + app::AppEvent, + framework::info::FrameworkInfo, + tui::{ + component::{ + thermal_graph_panel::ThermalGraphPanelComponent, + thermal_panel::ThermalPanelComponent, + Component, + }, + theme::Theme, + }, +}; + +pub struct ThermalPanelsComponent { + graph_panel: ThermalGraphPanelComponent, + thermal_panel: ThermalPanelComponent, +} +``` + +### Key Implementation Details + +1. **Layout:** Vertical split with graph on top (filling space) and info panel on bottom (fixed height) + ```rust + let [graph_area, thermal_panel_area] = + Layout::vertical([Constraint::Min(0), Constraint::Max(5)]) + .areas(inner_area); + ``` + +2. **Border:** Rounded border with `theme.border` color (matches other panels) + +3. **No input handling needed** (thermal panel is read-only for now) + +--- + +## Step 4: Register Modules + +**File:** `src/tui/component.rs` + +Add module declarations and re-exports: + +```rust +// Add after existing mod declarations +pub mod thermal_graph_panel; +pub mod thermal_panel; +pub mod thermal_panels; + +// The thermal_panels module will be used by main.rs +``` + +--- + +## Step 5: Update Main Layout + +**File:** `src/tui/component/main.rs` + +### Changes Required + +1. **Add import:** + ```rust + use crate::tui::component::thermal_panels::ThermalPanelsComponent; + ``` + +2. **Add field to `MainComponent`:** + ```rust + pub struct MainComponent { + privacy_panel: PrivacyPanelComponent, + smbios_panel: SmbiosPanelComponent, + thermal_panels: ThermalPanelsComponent, // NEW + pd_ports_panel: PdPortsPanelComponent, + adjustable_panels: Vec>, + selected_panel: Option, + } + ``` + +3. **Initialize in `new()`:** + ```rust + Self { + privacy_panel: PrivacyPanelComponent, + smbios_panel: SmbiosPanelComponent, + thermal_panels: ThermalPanelsComponent::new(), // NEW + pd_ports_panel: PdPortsPanelComponent::new(), + adjustable_panels, + selected_panel: None, + } + ``` + +4. **Update `render()` layout:** + + Current: + ```rust + let [pd_ports_panel_area] = Layout::vertical([Constraint::Min(0)]).areas(bottom_area); + ``` + + New: + ```rust + // Split bottom area: thermal panels (40%) | PD ports (60%) + let [thermal_panels_area, pd_ports_panel_area] = + Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]) + .areas(bottom_area); + + // Render thermal panels + self.thermal_panels.render(frame, thermal_panels_area, theme, info); + ``` + +--- + +## Step 6: Add Theme Colors + +**File:** `src/tui/theme.rs` + +### Changes Required + +1. **Add fields to `Theme` struct:** + ```rust + pub struct Theme { + // ... existing fields ... + pub thermal_graph_light: Color, + pub thermal_graph_dark: Color, + } + ``` + +2. **Add values to each theme variant** (15 themes total): + - `framework()`: Use a distinct color (e.g., orange/red tones for "heat") + - Example for Framework theme: + ```rust + thermal_graph_light: Color::from_str("#FF7043").unwrap(), // Deep Orange 400 + thermal_graph_dark: Color::from_str("#BF360C").unwrap(), // Deep Orange 900 + ``` + +3. **Theme color suggestions** (heat-related palette): + - Framework: `#FF7043` / `#BF360C` (deep orange) + - Dark themes: Orange/red gradients + - Light themes: Lighter orange variants + - Monochrome: Use existing text colors + +--- + +## File Summary + +| File | Action | Description | +|------|--------|-------------| +| `src/tui/component/thermal_graph_panel.rs` | Create | FAN RPM graph with auto-scaling | +| `src/tui/component/thermal_panel.rs` | Create | Current FAN RPM display | +| `src/tui/component/thermal_panels.rs` | Create | Container combining graph + panel | +| `src/tui/component.rs` | Modify | Add 3 module declarations | +| `src/tui/component/main.rs` | Modify | Add field, update layout | +| `src/tui/theme.rs` | Modify | Add 2 color fields to struct + all 15 variants | + +--- + +## Visual Layout (After Implementation) + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Title Bar │ +├────────────────────────────────────────┬────────────────────────────────┤ +│ │ │ +│ Charge Panels (60%) │ Right Panels (40%) │ +│ ┌──────────────────────────────┐ │ ┌────────────────────────┐ │ +│ │ Voltage/Current Graph │ │ │ Brightness Panel │ │ +│ │ │ │ └────────────────────────┘ │ +│ │ │ │ ┌────────────────────────┐ │ +│ │ Charge Info Panel │ │ │ Privacy | SMBIOS │ │ +│ └──────────────────────────────┘ │ └────────────────────────┘ │ +├─────────────────────┬──────────────────┴────────────────────────────────┤ +│ │ │ +│ Thermal Panels │ PD Ports Panel (60%) │ +│ (40%) │ │ +│ ┌───────────────┐ │ ┌───────────────────────────────────────────┐ │ +│ │ FAN RPM │ │ │ Port 1 │ Port 2 │ Port 3 │ Port 4 │ │ +│ │ Graph │ │ │ ... │ ... │ ... │ ... │ │ +│ │ │ │ └───────────────────────────────────────────┘ │ +│ │ Fan: 3200RPM │ │ │ +│ └───────────────┘ │ │ +├─────────────────────┴───────────────────────────────────────────────────┤ +│ Footer │ +└─────────────────────────────────────────────────────────────────────────┘ +``` diff --git a/src/framework.rs b/src/framework.rs index 4cafff8..559a5f4 100644 --- a/src/framework.rs +++ b/src/framework.rs @@ -20,6 +20,8 @@ const EC_FAN_SPEED_ENTRIES: usize = 4; /// Used on old EC firmware (before 2023) const EC_FAN_SPEED_NOT_PRESENT: u16 = 0xFFFF; +const EC_MEMMAP_TEMP_SENSOR: u16 = 0x00; + pub struct Framework { ec: CrosEc, fingerprint: Arc, @@ -78,6 +80,7 @@ impl Framework { .collect(); let fan_rpm = self.get_fan_rpm().ok(); let platform = smbios::get_platform(); + let temps = self.get_temps().ok(); FrameworkInfo::new( &power, @@ -89,6 +92,7 @@ impl Framework { pd_ports, fan_rpm, platform, + temps, ) } @@ -111,4 +115,13 @@ impl Framework { Ok(rpms) } + + fn get_temps(&self) -> color_eyre::Result> { + let temps = self + .ec + .read_memory(EC_MEMMAP_TEMP_SENSOR, 0x08) + .ok_or(Report::msg("Couldn't read temperature sensor info"))?; + + Ok(temps) + } } diff --git a/src/framework/info.rs b/src/framework/info.rs index 6e73158..2d004ef 100644 --- a/src/framework/info.rs +++ b/src/framework/info.rs @@ -5,9 +5,36 @@ use framework_lib::power::UsbPdPowerInfo; use framework_lib::power::UsbPowerRoles; use framework_lib::smbios; use framework_lib::smbios::Platform; +use framework_lib::smbios::PlatformFamily; use smbioslib::DefinedStruct; use smbioslib::SMBiosData; +#[derive(Debug, Clone)] +pub struct TempSensorInfo { + pub name: String, + /// Temperature in Celsius, None if sensor is not present/error/not powered/not calibrated + pub temp_celsius: Option, + /// Raw value from EC memory + pub raw_value: u8, +} + +impl TempSensorInfo { + fn new(name: &str, raw_value: u8) -> Self { + let temp_celsius = match raw_value { + 0xFF => None, // Not present + 0xFE => None, // Error + 0xFD => None, // Not powered + 0xFC => None, // Not calibrated + t => Some(t as i16 - 73), + }; + Self { + name: name.to_string(), + temp_celsius, + raw_value, + } + } +} + #[derive(Default)] pub struct FrameworkInfo { pub charge_percentage: Option, @@ -33,6 +60,7 @@ pub struct FrameworkInfo { pub pd_ports: PdPortsInfo, pub fan_rpm: Option>, pub platform: Option, + pub temp_sensors: Vec, } impl FrameworkInfo { @@ -47,7 +75,9 @@ impl FrameworkInfo { pd_ports: Vec>, fan_rpm: Option>, platform: Option, + temps: Option>, ) -> Self { + let family = platform.and_then(|p| p.which_family()); Self { charge_percentage: charge_percentage(power), charger_voltage: charger_voltage(power), @@ -72,6 +102,7 @@ impl FrameworkInfo { pd_ports: pd_ports_info(pd_ports), fan_rpm, platform, + temp_sensors: temp_sensors_info(temps.as_deref(), platform, family), } } } @@ -275,6 +306,141 @@ fn pd_ports_info(pd_ports: Vec>) -> PdPortsInfo { } } +pub fn temp_sensors_info( + temps: Option<&[u8]>, + platform: Option, + _family: Option, +) -> Vec { + let Some(temps) = temps else { + return Vec::new(); + }; + + let mut sensors = Vec::new(); + + match platform { + Some(Platform::IntelGen11) | Some(Platform::IntelGen12) | Some(Platform::IntelGen13) => { + if let Some(&t) = temps.get(0) { + sensors.push(TempSensorInfo::new("F75303_Local", t)); + } + if let Some(&t) = temps.get(1) { + sensors.push(TempSensorInfo::new("F75303_CPU", t)); + } + if let Some(&t) = temps.get(2) { + sensors.push(TempSensorInfo::new("F75303_DDR", t)); + } + if let Some(&t) = temps.get(3) { + sensors.push(TempSensorInfo::new("Battery", t)); + } + if let Some(&t) = temps.get(4) { + sensors.push(TempSensorInfo::new("PECI", t)); + } + if matches!( + platform, + Some(Platform::IntelGen12) | Some(Platform::IntelGen13) + ) { + if let Some(&t) = temps.get(5) { + sensors.push(TempSensorInfo::new("F57397_VCCGT", t)); + } + } + } + + Some(Platform::IntelCoreUltra1) => { + if let Some(&t) = temps.get(0) { + sensors.push(TempSensorInfo::new("F75303_Local", t)); + } + if let Some(&t) = temps.get(1) { + sensors.push(TempSensorInfo::new("F75303_CPU", t)); + } + if let Some(&t) = temps.get(2) { + sensors.push(TempSensorInfo::new("Battery", t)); + } + if let Some(&t) = temps.get(3) { + sensors.push(TempSensorInfo::new("F75303_DDR", t)); + } + if let Some(&t) = temps.get(4) { + sensors.push(TempSensorInfo::new("PECI", t)); + } + } + + Some(Platform::Framework12IntelGen13) => { + if let Some(&t) = temps.get(0) { + sensors.push(TempSensorInfo::new("F75303_CPU", t)); + } + if let Some(&t) = temps.get(1) { + sensors.push(TempSensorInfo::new("F75303_Skin", t)); + } + if let Some(&t) = temps.get(2) { + sensors.push(TempSensorInfo::new("F75303_Local", t)); + } + if let Some(&t) = temps.get(3) { + sensors.push(TempSensorInfo::new("Battery", t)); + } + if let Some(&t) = temps.get(4) { + sensors.push(TempSensorInfo::new("PECI", t)); + } + if let Some(&t) = temps.get(5) { + sensors.push(TempSensorInfo::new("Charger IC", t)); + } + } + + Some( + Platform::Framework13Amd7080 + | Platform::Framework13AmdAi300 + | Platform::Framework16Amd7080, + ) => { + if let Some(&t) = temps.get(0) { + sensors.push(TempSensorInfo::new("F75303_Local", t)); + } + if let Some(&t) = temps.get(1) { + sensors.push(TempSensorInfo::new("F75303_CPU", t)); + } + if let Some(&t) = temps.get(2) { + sensors.push(TempSensorInfo::new("F75303_DDR", t)); + } + if let Some(&t) = temps.get(3) { + sensors.push(TempSensorInfo::new("APU", t)); + } + if matches!(platform, Some(Platform::Framework16Amd7080)) { + if let Some(&t) = temps.get(4) { + sensors.push(TempSensorInfo::new("dGPU VR", t)); + } + if let Some(&t) = temps.get(5) { + sensors.push(TempSensorInfo::new("dGPU VRAM", t)); + } + if let Some(&t) = temps.get(6) { + sensors.push(TempSensorInfo::new("dGPU AMB", t)); + } + if let Some(&t) = temps.get(7) { + sensors.push(TempSensorInfo::new("dGPU temp", t)); + } + } + } + + Some(Platform::FrameworkDesktopAmdAiMax300) => { + if let Some(&t) = temps.get(0) { + sensors.push(TempSensorInfo::new("F75303_APU", t)); + } + if let Some(&t) = temps.get(1) { + sensors.push(TempSensorInfo::new("F75303_DDR", t)); + } + if let Some(&t) = temps.get(2) { + sensors.push(TempSensorInfo::new("F75303_AMB", t)); + } + if let Some(&t) = temps.get(3) { + sensors.push(TempSensorInfo::new("APU", t)); + } + } + + _ => { + for (i, &t) in temps.iter().enumerate().take(8) { + sensors.push(TempSensorInfo::new(&format!("Temp {}", i), t)); + } + } + } + + sensors +} + fn pd_port_info(pd_port: &UsbPdPowerInfo) -> PdPortInfo { let role = match pd_port.role { UsbPowerRoles::Disconnected => "Disconnected".to_string(), diff --git a/src/tui.rs b/src/tui.rs index 746b899..13e2832 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use ratatui::{ crossterm::event::{Event, KeyCode, KeyEventKind}, - layout::{Constraint, Flex, Layout}, + layout::{Constraint, Layout}, prelude::Backend, style::Style, text::Text, @@ -139,17 +139,13 @@ impl Tui { frame.render_widget(block, frame.area()); let area = frame.area(); - let [area] = Layout::vertical([Constraint::Max(49)]) - .flex(Flex::Center) - .areas(area); - let [area] = Layout::horizontal([Constraint::Max(140)]) - .flex(Flex::Center) - .areas(area); - - let [title_area, main_area, footer_area] = - Layout::vertical([Constraint::Max(3), Constraint::Max(44), Constraint::Max(3)]) - .flex(Flex::Center) - .areas(area); + + let [title_area, main_area, footer_area] = Layout::vertical([ + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(3), + ]) + .areas(area); // Title self.title diff --git a/src/tui/component.rs b/src/tui/component.rs index 590fd2c..d457e3f 100644 --- a/src/tui/component.rs +++ b/src/tui/component.rs @@ -8,11 +8,16 @@ use crate::{ }; pub mod brightness_panel; +pub mod charge_graph_panel; pub mod charge_panel; +pub mod charge_panels; pub mod footer; pub mod main; pub mod privacy_panel; pub mod smbios_panel; +pub mod thermal_graph_panel; +pub mod thermal_panel; +pub mod thermal_panels; pub mod title; pub trait Component { diff --git a/src/tui/component/brightness_panel.rs b/src/tui/component/brightness_panel.rs index c9bb2b8..f128a28 100644 --- a/src/tui/component/brightness_panel.rs +++ b/src/tui/component/brightness_panel.rs @@ -254,7 +254,7 @@ impl Component for BrightnessPanelComponent { let [keys_area, values_area] = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]) - .horizontal_margin(2) + .horizontal_margin(1) .vertical_margin(1) .areas(block.inner(area)); diff --git a/src/tui/component/charge_graph_panel.rs b/src/tui/component/charge_graph_panel.rs new file mode 100644 index 0000000..4c27587 --- /dev/null +++ b/src/tui/component/charge_graph_panel.rs @@ -0,0 +1,103 @@ +use bgraph::{ColorGradient, GradientMode, Graph, TimeSeriesState}; +use ratatui::{ + layout::{Constraint, Flex, Layout, Rect}, + widgets::{Block, Borders}, + Frame, +}; + +use crate::{ + framework::info::FrameworkInfo, + tui::{component::Component, theme::Theme}, +}; + +const HISTORY_SIZE: usize = 200; + +const VOLTAGE_MAX: f32 = 20.0; // Max voltage in volts + +const CURRENT_MAX: f32 = 5.0; // Max current in amps + +pub struct ChargeGraphPanelComponent { + voltage_series: TimeSeriesState, + current_series: TimeSeriesState, +} + +impl Default for ChargeGraphPanelComponent { + fn default() -> Self { + Self::new() + } +} + +impl ChargeGraphPanelComponent { + pub fn new() -> Self { + Self { + voltage_series: TimeSeriesState::with_range(HISTORY_SIZE, 0.0, VOLTAGE_MAX), + current_series: TimeSeriesState::with_range(HISTORY_SIZE, 0.0, CURRENT_MAX), + } + } + + fn update_history(&mut self, info: &FrameworkInfo) { + // Convert mV to V, use 0.0 as default + let voltage = info + .charger_voltage + .map(|v| v as f32 / 1000.0) + .unwrap_or(0.0); + + // Convert mA to A, use 0.0 as default + let current = info + .charger_current + .map(|c| c as f32 / 1000.0) + .unwrap_or(0.0); + + // Add to series + self.voltage_series.push(voltage); + self.current_series.push(current); + } +} + +impl Component for ChargeGraphPanelComponent { + fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme, info: &FrameworkInfo) { + // Update series with current data + self.update_history(info); + + let [area] = Layout::vertical([Constraint::Min(0)]) + .flex(Flex::Center) + .areas(area); + + let block = Block::default().borders(Borders::NONE); + let inner_area = block.inner(area); + + frame.render_widget(block, area); + + // Split area for voltage (top) and current (bottom) graphs + let [voltage_area, current_area] = + Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(inner_area); + + let voltage_gradient = ColorGradient::two_point( + theme.charge_voltage_graph_light, + theme.charge_voltage_graph_dark, + ); + let current_gradient = ColorGradient::two_point( + theme.charge_current_graph_dark, + theme.charge_current_graph_light, + ); + + // Render voltage graph (top) + let voltage_graph = Graph::new(&self.voltage_series) + .x_range(0.0, 1.0) + .y_range(0.0, VOLTAGE_MAX) + .gradient(voltage_gradient) + .gradient_mode(GradientMode::Position); + + // Render current graph (bottom, mirrored using invert_y) + let current_graph = Graph::new(&self.current_series) + .x_range(0.0, 1.0) + .y_range(0.0, CURRENT_MAX) + .gradient(current_gradient) + .gradient_mode(GradientMode::Position) + .invert_y(true); + + frame.render_widget(voltage_graph, voltage_area); + frame.render_widget(current_graph, current_area); + } +} diff --git a/src/tui/component/charge_panel.rs b/src/tui/component/charge_panel.rs index 2882a68..c8731da 100644 --- a/src/tui/component/charge_panel.rs +++ b/src/tui/component/charge_panel.rs @@ -1,6 +1,6 @@ use ratatui::{ crossterm::event::{Event, KeyCode, KeyEventKind}, - layout::{Constraint, Layout, Rect}, + layout::{Constraint, Flex, Layout, Rect}, prelude::*, style::Styled, widgets::{Block, BorderType, Borders, Gauge, Paragraph}, @@ -18,6 +18,7 @@ use crate::{ }; const NORMAL_CAPACITY_LOSS_MAX: f32 = 0.048; + const MAX_CHARGE_LIMIT_CONTROL_INDEX: usize = 0; pub struct ChargePanelComponent(AdjustablePanel); @@ -66,7 +67,7 @@ impl ChargePanelComponent { None => Gauge::default().percent(0).label("N/A"), }; - frame.render_widget(Paragraph::new("Charge level"), key_area); + frame.render_widget(Paragraph::new(" Charge level"), key_area); frame.render_widget(gauge, value_area); } @@ -127,7 +128,7 @@ impl ChargePanelComponent { }; frame.render_widget( - Paragraph::new("Max charge limit").set_style(style), + Paragraph::new(" Max charge limit").set_style(style), key_area, ); frame.render_widget(gauge, value_area); @@ -146,7 +147,7 @@ impl ChargePanelComponent { None => "N/A".to_string(), }; - frame.render_widget(Paragraph::new("Charger voltage"), key_area); + frame.render_widget(Paragraph::new("▼ Charger voltage"), key_area); frame.render_widget( Paragraph::new(charger_voltage_text).style(Style::default().fg(theme.informative_text)), value_area, @@ -166,7 +167,7 @@ impl ChargePanelComponent { None => "N/A".to_string(), }; - frame.render_widget(Paragraph::new("Charger current"), key_area); + frame.render_widget(Paragraph::new("▲ Charger current"), key_area); frame.render_widget( Paragraph::new(charger_current_text).style(Style::default().fg(theme.informative_text)), value_area, @@ -186,7 +187,7 @@ impl ChargePanelComponent { None => "N/A".to_string(), }; - frame.render_widget(Paragraph::new("Design capacity"), key_area); + frame.render_widget(Paragraph::new(" Design capacity"), key_area); frame.render_widget( Paragraph::new(design_capacity_text).style(Style::default().fg(theme.informative_text)), value_area, @@ -206,7 +207,7 @@ impl ChargePanelComponent { None => "N/A".to_string(), }; - frame.render_widget(Paragraph::new("Last full capacity"), key_area); + frame.render_widget(Paragraph::new(" Last full capacity"), key_area); frame.render_widget( Paragraph::new(last_full_charge_capacity_text) .style(Style::default().fg(theme.informative_text)), @@ -231,7 +232,7 @@ impl ChargePanelComponent { _ => "N/A".to_string(), }; - frame.render_widget(Paragraph::new("Capacity loss"), key_area); + frame.render_widget(Paragraph::new(" Capacity loss"), key_area); frame.render_widget( Paragraph::new(capacity_loss_text).style(Style::default().fg(theme.informative_text)), value_area, @@ -251,7 +252,7 @@ impl ChargePanelComponent { None => "N/A".to_string(), }; - frame.render_widget(Paragraph::new("Cycle count"), key_area); + frame.render_widget(Paragraph::new(" Cycle count"), key_area); frame.render_widget( Paragraph::new(cycle_count_text).style(Style::default().fg(theme.informative_text)), value_area, @@ -291,7 +292,7 @@ impl ChargePanelComponent { _ => "N/A".to_string(), }; - frame.render_widget(Paragraph::new("Capacity loss per cycle"), key_area); + frame.render_widget(Paragraph::new(" Capacity loss per cycle"), key_area); frame.render_widget( Paragraph::new(capacity_loss_per_cycle_text).style(capacity_loss_per_cycle_style), value_area, @@ -343,6 +344,9 @@ impl Component for ChargePanelComponent { } fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme, info: &FrameworkInfo) { + let [area] = Layout::vertical([Constraint::Max(15)]) + .flex(Flex::Center) + .areas(area); let block = Block::default() .title(" Charge ") .borders(Borders::ALL) @@ -351,7 +355,6 @@ impl Component for ChargePanelComponent { let [keys_area, values_area] = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]) - .horizontal_margin(2) .vertical_margin(1) .areas(block.inner(area)); diff --git a/src/tui/component/charge_panels.rs b/src/tui/component/charge_panels.rs new file mode 100644 index 0000000..78f0d61 --- /dev/null +++ b/src/tui/component/charge_panels.rs @@ -0,0 +1,75 @@ +use ratatui::{ + crossterm::event::Event, + layout::{Constraint, Layout, Rect}, + style::Style, + widgets::{Block, BorderType, Borders}, + Frame, +}; + +use crate::{ + app::AppEvent, + framework::info::FrameworkInfo, + tui::{ + component::{ + charge_graph_panel::ChargeGraphPanelComponent, charge_panel::ChargePanelComponent, + AdjustableComponent, AdjustablePanel, Component, + }, + theme::Theme, + }, +}; + +pub struct ChargePanelsComponent { + graph_panel: ChargeGraphPanelComponent, + charge_panel: ChargePanelComponent, +} + +impl Default for ChargePanelsComponent { + fn default() -> Self { + Self::new() + } +} + +impl ChargePanelsComponent { + pub fn new() -> Self { + Self { + graph_panel: ChargeGraphPanelComponent::new(), + charge_panel: ChargePanelComponent::new(), + } + } +} + +impl Component for ChargePanelsComponent { + fn handle_input(&mut self, event: Event) -> Option { + // Forward input to the charge panel + self.charge_panel.handle_input(event) + } + + fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme, info: &FrameworkInfo) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.border)) + .style(Style::default().bg(theme.background)); + + let inner_area = block.inner(area); + frame.render_widget(block, area); + + // Split horizontally: graph on left (fill), charge panel on right (fixed width) + let [graph_area, charge_panel_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Max(55)]).areas(inner_area); + + // Render graph panel on the left + self.graph_panel.render(frame, graph_area, theme, info); + + // Render charge panel on the right + self.charge_panel + .render(frame, charge_panel_area, theme, info); + } +} + +impl AdjustableComponent for ChargePanelsComponent { + fn panel(&mut self) -> &mut AdjustablePanel { + // Forward to the charge panel's adjustable panel + self.charge_panel.panel() + } +} diff --git a/src/tui/component/main.rs b/src/tui/component/main.rs index a270c70..6f91b46 100644 --- a/src/tui/component/main.rs +++ b/src/tui/component/main.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use ratatui::{ crossterm::event::{Event, KeyCode, KeyEventKind}, - layout::{Constraint, Layout, Rect}, + layout::{Constraint, Flex, Layout, Rect}, Frame, }; @@ -10,9 +10,10 @@ use crate::{ framework::{fingerprint::Fingerprint, info::FrameworkInfo}, tui::{ component::{ - brightness_panel::BrightnessPanelComponent, charge_panel::ChargePanelComponent, + brightness_panel::BrightnessPanelComponent, charge_panels::ChargePanelsComponent, pd_ports_panel::PdPortsPanelComponent, privacy_panel::PrivacyPanelComponent, - smbios_panel::SmbiosPanelComponent, AdjustableComponent, Component, + smbios_panel::SmbiosPanelComponent, thermal_panels::ThermalPanelsComponent, + AdjustableComponent, Component, }, theme::Theme, }, @@ -21,6 +22,7 @@ use crate::{ pub struct MainComponent { privacy_panel: PrivacyPanelComponent, smbios_panel: SmbiosPanelComponent, + thermal_panels: ThermalPanelsComponent, pd_ports_panel: PdPortsPanelComponent, adjustable_panels: Vec>, selected_panel: Option, @@ -29,9 +31,9 @@ pub struct MainComponent { impl MainComponent { pub fn new(finterprint: Arc, info: &FrameworkInfo) -> Self { let mut adjustable_panels: Vec> = Vec::new(); - let charge_panel = Box::new(ChargePanelComponent::new()); + let charge_panels = Box::new(ChargePanelsComponent::new()); - adjustable_panels.push(charge_panel); + adjustable_panels.push(charge_panels); if Self::is_brightness_supported(info) { let brightness_panel = Box::new(BrightnessPanelComponent::new(finterprint)); @@ -42,6 +44,7 @@ impl MainComponent { Self { privacy_panel: PrivacyPanelComponent, smbios_panel: SmbiosPanelComponent, + thermal_panels: ThermalPanelsComponent::new(), pd_ports_panel: PdPortsPanelComponent::new(), adjustable_panels, selected_panel: None, @@ -108,18 +111,37 @@ impl Component for MainComponent { } fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme, info: &FrameworkInfo) { - let [top_area, pd_ports_panel_area] = - Layout::vertical([Constraint::Max(15), Constraint::Min(0)]).areas(area); - let [charge_panel_area, top_right_area] = - Layout::horizontal([Constraint::Min(0), Constraint::Min(0)]).areas(top_area); + let [top_area, bottom_area] = + Layout::vertical([Constraint::Min(17), Constraint::Min(0)]).areas(area); - // Charge panel - self.adjustable_panels[0].render(frame, charge_panel_area, theme, info); + let [charge_panels_area, top_right_area] = + Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)]) + .areas(top_area); + let [top_right_area] = Layout::vertical([Constraint::Max(15)]) + .flex(Flex::Center) + .areas(top_right_area); + // Split bottom area: thermal panels (40%) | PD ports (60%) + let [thermal_panels_area, pd_ports_panel_area] = + Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]) + .areas(bottom_area); + + // Render charge panels (graph + charge panel) in the top left area + self.adjustable_panels[0].render(frame, charge_panels_area, theme, info); + + // Thermal panels (bottom left area) + self.thermal_panels + .render(frame, thermal_panels_area, theme, info); + + // PD Ports panel (bottom right area) + self.pd_ports_panel + .render(frame, pd_ports_panel_area, theme, info); + + // Split right panels area vertically // Show brightness panel only on supported platforms if Self::is_brightness_supported(info) { let [brightness_panel_area, privacy_and_smbios_panels_area] = - Layout::vertical([Constraint::Min(7), Constraint::Min(7)]).areas(top_right_area); + Layout::vertical([Constraint::Max(7), Constraint::Max(8)]).areas(top_right_area); // Brightness panel (top of right_area) self.adjustable_panels[1].render(frame, brightness_panel_area, theme, info); @@ -135,9 +157,5 @@ impl Component for MainComponent { // Privacy and SMBIOS panels self.render_privacy_and_smbios_panels(frame, top_right_area, theme, info); } - - // PD Ports panel (bottom of right_area) - self.pd_ports_panel - .render(frame, pd_ports_panel_area, theme, info); } } diff --git a/src/tui/component/pd_ports_panel.rs b/src/tui/component/pd_ports_panel.rs index bfa6389..df65066 100644 --- a/src/tui/component/pd_ports_panel.rs +++ b/src/tui/component/pd_ports_panel.rs @@ -259,11 +259,11 @@ impl Component for PdPortsPanelComponent { Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(block.inner(area)); let [left_back_area, left_front_area] = - Layout::vertical([Constraint::Min(5), Constraint::Min(0)]) + Layout::vertical([Constraint::Min(12), Constraint::Min(0)]) .margin(1) .areas(left_area); let [right_back_area, right_front_area] = - Layout::vertical([Constraint::Min(5), Constraint::Min(0)]) + Layout::vertical([Constraint::Min(12), Constraint::Min(0)]) .margin(1) .areas(right_area); diff --git a/src/tui/component/privacy_panel.rs b/src/tui/component/privacy_panel.rs index 04df6d0..4f391bb 100644 --- a/src/tui/component/privacy_panel.rs +++ b/src/tui/component/privacy_panel.rs @@ -60,7 +60,7 @@ impl Component for PrivacyPanelComponent { let [keys_area, values_area] = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]) - .horizontal_margin(2) + .horizontal_margin(1) .spacing(1) .vertical_margin(1) .areas(block.inner(area)); diff --git a/src/tui/component/smbios_panel.rs b/src/tui/component/smbios_panel.rs index e0788d4..16f0adc 100644 --- a/src/tui/component/smbios_panel.rs +++ b/src/tui/component/smbios_panel.rs @@ -85,7 +85,7 @@ impl Component for SmbiosPanelComponent { let [keys_area, values_area] = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]) - .horizontal_margin(2) + .horizontal_margin(1) .spacing(1) .vertical_margin(1) .areas(block.inner(area)); diff --git a/src/tui/component/thermal_graph_panel.rs b/src/tui/component/thermal_graph_panel.rs new file mode 100644 index 0000000..87c8a5a --- /dev/null +++ b/src/tui/component/thermal_graph_panel.rs @@ -0,0 +1,74 @@ +use bgraph::{ColorGradient, GradientMode, Graph, TimeSeriesState}; +use ratatui::{ + layout::{Constraint, Flex, Layout, Rect}, + widgets::{Block, Borders}, + Frame, +}; + +use crate::{ + framework::info::FrameworkInfo, + tui::{component::Component, theme::Theme}, +}; + +const HISTORY_SIZE: usize = 200; + +const FAN_RPM_MAX: f32 = 6000.0; + +pub struct ThermalGraphPanelComponent { + fan_rpm_series: TimeSeriesState, +} + +impl Default for ThermalGraphPanelComponent { + fn default() -> Self { + Self::new() + } +} + +impl ThermalGraphPanelComponent { + pub fn new() -> Self { + Self { + fan_rpm_series: TimeSeriesState::with_range(HISTORY_SIZE, 0.0, FAN_RPM_MAX), + } + } + + fn update_history(&mut self, info: &FrameworkInfo) { + let rpm = info + .fan_rpm + .as_ref() + .and_then(|rpms| rpms.first()) + .map(|&r| r as f32) + .unwrap_or(0.0); + + self.fan_rpm_series.push(rpm); + } +} + +impl Component for ThermalGraphPanelComponent { + fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme, info: &FrameworkInfo) { + // Update series with current data + self.update_history(info); + + let [area] = Layout::vertical([Constraint::Min(0)]) + .flex(Flex::Center) + .areas(area); + + let block = Block::default().borders(Borders::NONE); + let inner_area = block.inner(area); + + frame.render_widget(block, area); + + let gradient = ColorGradient::two_point( + theme.thermal_graph_light, + theme.thermal_graph_dark, + ); + + // Render FAN RPM graph with fixed range + let fan_graph = Graph::new(&self.fan_rpm_series) + .x_range(0.0, 1.0) + .y_range(0.0, FAN_RPM_MAX) + .gradient(gradient) + .gradient_mode(GradientMode::Position); + + frame.render_widget(fan_graph, inner_area); + } +} diff --git a/src/tui/component/thermal_panel.rs b/src/tui/component/thermal_panel.rs new file mode 100644 index 0000000..7c4e66d --- /dev/null +++ b/src/tui/component/thermal_panel.rs @@ -0,0 +1,107 @@ +use ratatui::{ + layout::{Constraint, Flex, Layout, Rect}, + prelude::*, + widgets::{Block, BorderType, Borders, Paragraph}, + Frame, +}; + +use crate::{ + framework::info::{FrameworkInfo, TempSensorInfo}, + tui::{component::Component, theme::Theme}, +}; + +pub struct ThermalPanelComponent; + +impl ThermalPanelComponent { + fn render_fan_speed( + &self, + frame: &mut Frame, + key_area: Rect, + value_area: Rect, + theme: &Theme, + info: &FrameworkInfo, + ) { + let fan_rpm_text = match info.fan_rpm.as_ref().and_then(|rpms| rpms.first()) { + Some(&rpm) => format!("{} RPM", rpm), + None => "N/A".to_string(), + }; + + frame.render_widget(Paragraph::new("▼ Fan Speed"), key_area); + frame.render_widget( + Paragraph::new(fan_rpm_text).style(Style::default().fg(theme.informative_text)), + value_area, + ); + } + + fn render_temp_sensor( + &self, + frame: &mut Frame, + key_area: Rect, + value_area: Rect, + theme: &Theme, + sensor: &TempSensorInfo, + ) { + let temp_text = match sensor.temp_celsius { + Some(temp) => format!("{} °C", temp), + None => "N/A".to_string(), + }; + + frame.render_widget(Paragraph::new(format!("▲ {}", sensor.name)), key_area); + frame.render_widget( + Paragraph::new(temp_text).style(Style::default().fg(theme.informative_text)), + value_area, + ); + } +} + +impl Component for ThermalPanelComponent { + fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme, info: &FrameworkInfo) { + let sensor_count = info.temp_sensors.len(); + // 1 row for fan speed + 1 row per temp sensor + 2 for vertical margin + let panel_height = 1 + sensor_count + 2; + + let [area] = Layout::vertical([Constraint::Max(panel_height as u16)]) + .flex(Flex::Center) + .areas(area); + + let block = Block::default() + .title(" Thermal ") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.border)); + + let [keys_area, values_area] = + Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]) + .vertical_margin(1) + .areas(block.inner(area)); + + let keys_block = Block::default().borders(Borders::NONE); + let values_block = Block::default().borders(Borders::NONE); + + // Create constraints: 1 for fan speed + 1 for each temp sensor + let vertical_constraints = vec![Constraint::Length(1); info.temp_sensors.len() + 1]; + + let key_areas = Layout::vertical(&vertical_constraints) + .horizontal_margin(1) + .split(keys_block.inner(keys_area)); + let value_areas = Layout::vertical(&vertical_constraints) + .horizontal_margin(1) + .split(values_block.inner(values_area)); + + // Render fan speed in the first row + if let (Some(&key_area), Some(&value_area)) = (key_areas.first(), value_areas.first()) { + self.render_fan_speed(frame, key_area, value_area, theme, info); + } + + // Render each temp sensor in subsequent rows + for (i, sensor) in info.temp_sensors.iter().enumerate() { + if let (Some(&key_area), Some(&value_area)) = + (key_areas.get(i + 1), value_areas.get(i + 1)) + { + self.render_temp_sensor(frame, key_area, value_area, theme, sensor); + } + } + + frame.render_widget(block, area); + } +} diff --git a/src/tui/component/thermal_panels.rs b/src/tui/component/thermal_panels.rs new file mode 100644 index 0000000..3dc0bd5 --- /dev/null +++ b/src/tui/component/thermal_panels.rs @@ -0,0 +1,62 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::Style, + widgets::{Block, BorderType, Borders}, + Frame, +}; + +use crate::{ + framework::info::FrameworkInfo, + tui::{ + component::{ + thermal_graph_panel::ThermalGraphPanelComponent, + thermal_panel::ThermalPanelComponent, + Component, + }, + theme::Theme, + }, +}; + +pub struct ThermalPanelsComponent { + graph_panel: ThermalGraphPanelComponent, + thermal_panel: ThermalPanelComponent, +} + +impl Default for ThermalPanelsComponent { + fn default() -> Self { + Self::new() + } +} + +impl ThermalPanelsComponent { + pub fn new() -> Self { + Self { + graph_panel: ThermalGraphPanelComponent::new(), + thermal_panel: ThermalPanelComponent, + } + } +} + +impl Component for ThermalPanelsComponent { + fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme, info: &FrameworkInfo) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.border)) + .style(Style::default().bg(theme.background)); + + let inner_area = block.inner(area); + frame.render_widget(block, area); + + // Split vertically: graph on top (filling space), info panel on bottom (fixed height) + let [graph_area, thermal_panel_area] = + Layout::horizontal([Constraint::Percentage(60), Constraint::Max(30)]).areas(inner_area); + + // Render graph panel on top + self.graph_panel.render(frame, graph_area, theme, info); + + // Render thermal info panel on bottom + self.thermal_panel + .render(frame, thermal_panel_area, theme, info); + } +} diff --git a/src/tui/theme.rs b/src/tui/theme.rs index fe43c9a..bebc5ed 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -116,6 +116,12 @@ pub struct Theme { pub bar_background: Color, pub highlighted_text: Color, pub informative_text: Color, + pub charge_voltage_graph_light: Color, + pub charge_voltage_graph_dark: Color, + pub charge_current_graph_light: Color, + pub charge_current_graph_dark: Color, + pub thermal_graph_light: Color, + pub thermal_graph_dark: Color, } impl Default for Theme { @@ -154,11 +160,17 @@ impl Theme { border_active: Color::from_str("#FFD600").unwrap(), indication_ok: Color::from_str("#00B16A").unwrap(), indication_warning: Color::from_str("#E53935").unwrap(), - brightness_bar: Color::from_str("#fdbe54").unwrap(), + brightness_bar: Color::from_str("#FDBE54").unwrap(), charge_bar: Color::from_str("#9481D8").unwrap(), bar_background: Color::from_str("#363636").unwrap(), highlighted_text: Color::from_str("#AEC2C9").unwrap(), informative_text: Color::from_str("#9481D8").unwrap(), + charge_voltage_graph_light: Color::from_str("#9481D8").unwrap(), + charge_voltage_graph_dark: Color::from_str("#6B5BA0").unwrap(), + charge_current_graph_light: Color::from_str("#FDBE54").unwrap(), + charge_current_graph_dark: Color::from_str("#C9983F").unwrap(), + thermal_graph_light: Color::from_str("#FF7043").unwrap(), + thermal_graph_dark: Color::from_str("#BF360C").unwrap(), } } @@ -176,6 +188,12 @@ impl Theme { bar_background: Color::from_str("#CFCFDE").unwrap(), highlighted_text: Color::from_str("#036A96").unwrap(), informative_text: Color::from_str("#644AC9").unwrap(), + charge_voltage_graph_light: Color::from_str("#644AC9").unwrap(), + charge_voltage_graph_dark: Color::from_str("#4E3A9E").unwrap(), + charge_current_graph_light: Color::from_str("#846E15").unwrap(), + charge_current_graph_dark: Color::from_str("#635210").unwrap(), + thermal_graph_light: Color::from_str("#CB3A2A").unwrap(), + thermal_graph_dark: Color::from_str("#A02E22").unwrap(), } } @@ -191,8 +209,14 @@ impl Theme { brightness_bar: Color::from_str("#E5C890").unwrap(), charge_bar: Color::from_str("#8CAAEE").unwrap(), bar_background: Color::from_str("#303446").unwrap(), - highlighted_text: Color::from_str("#8caaee").unwrap(), + highlighted_text: Color::from_str("#8CAAEE").unwrap(), informative_text: Color::from_str("#CA9EE6").unwrap(), + charge_voltage_graph_light: Color::from_str("#8CAAEE").unwrap(), + charge_voltage_graph_dark: Color::from_str("#6A88BE").unwrap(), + charge_current_graph_light: Color::from_str("#E5C890").unwrap(), + charge_current_graph_dark: Color::from_str("#B79E70").unwrap(), + thermal_graph_light: Color::from_str("#EF9F76").unwrap(), + thermal_graph_dark: Color::from_str("#BF7F5E").unwrap(), } } @@ -210,6 +234,12 @@ impl Theme { bar_background: Color::from_str("#EFF1F5").unwrap(), highlighted_text: Color::from_str("#1e66f5").unwrap(), informative_text: Color::from_str("#8839EF").unwrap(), + charge_voltage_graph_light: Color::from_str("#1E66F5").unwrap(), + charge_voltage_graph_dark: Color::from_str("#1850C4").unwrap(), + charge_current_graph_light: Color::from_str("#DF8E1D").unwrap(), + charge_current_graph_dark: Color::from_str("#B27217").unwrap(), + thermal_graph_light: Color::from_str("#D20F39").unwrap(), + thermal_graph_dark: Color::from_str("#A80C2E").unwrap(), } } @@ -225,8 +255,14 @@ impl Theme { brightness_bar: Color::from_str("#EED49F").unwrap(), charge_bar: Color::from_str("#8AADF4").unwrap(), bar_background: Color::from_str("#24273A").unwrap(), - highlighted_text: Color::from_str("#8aadf4").unwrap(), + highlighted_text: Color::from_str("#8AADF4").unwrap(), informative_text: Color::from_str("#C6A0F6").unwrap(), + charge_voltage_graph_light: Color::from_str("#8AADF4").unwrap(), + charge_voltage_graph_dark: Color::from_str("#6A8AC3").unwrap(), + charge_current_graph_light: Color::from_str("#EED49F").unwrap(), + charge_current_graph_dark: Color::from_str("#BEAA7F").unwrap(), + thermal_graph_light: Color::from_str("#F5A97F").unwrap(), + thermal_graph_dark: Color::from_str("#C48766").unwrap(), } } @@ -242,8 +278,14 @@ impl Theme { brightness_bar: Color::from_str("#F9E2AF").unwrap(), charge_bar: Color::from_str("#89B4FA").unwrap(), bar_background: Color::from_str("#1E1E2E").unwrap(), - highlighted_text: Color::from_str("#89b4fa").unwrap(), - informative_text: Color::from_str("#CBA6f7").unwrap(), + highlighted_text: Color::from_str("#89B4FA").unwrap(), + informative_text: Color::from_str("#CBA6F7").unwrap(), + charge_voltage_graph_light: Color::from_str("#89B4FA").unwrap(), + charge_voltage_graph_dark: Color::from_str("#6A90C8").unwrap(), + charge_current_graph_light: Color::from_str("#F9E2AF").unwrap(), + charge_current_graph_dark: Color::from_str("#C7B58C").unwrap(), + thermal_graph_light: Color::from_str("#FAB387").unwrap(), + thermal_graph_dark: Color::from_str("#C88F6C").unwrap(), } } @@ -261,6 +303,12 @@ impl Theme { bar_background: Color::from_str("#44475A").unwrap(), highlighted_text: Color::from_str("#8BE9FD").unwrap(), informative_text: Color::from_str("#BD93F9").unwrap(), + charge_voltage_graph_light: Color::from_str("#8BE9FD").unwrap(), + charge_voltage_graph_dark: Color::from_str("#6FBACA").unwrap(), + charge_current_graph_light: Color::from_str("#F1FA8C").unwrap(), + charge_current_graph_dark: Color::from_str("#C1C870").unwrap(), + thermal_graph_light: Color::from_str("#FF5555").unwrap(), + thermal_graph_dark: Color::from_str("#CC4444").unwrap(), } } @@ -278,6 +326,12 @@ impl Theme { bar_background: Color::from_str("#0E450B").unwrap(), highlighted_text: Color::from_str("#9A9E3F").unwrap(), informative_text: Color::from_str("#9A9E3F").unwrap(), + charge_voltage_graph_light: Color::from_str("#9A9E3F").unwrap(), + charge_voltage_graph_dark: Color::from_str("#7B7E32").unwrap(), + charge_current_graph_light: Color::from_str("#9A9E3F").unwrap(), + charge_current_graph_dark: Color::from_str("#7B7E32").unwrap(), + thermal_graph_light: Color::from_str("#9A9E3F").unwrap(), + thermal_graph_dark: Color::from_str("#7B7E32").unwrap(), } } @@ -295,6 +349,12 @@ impl Theme { bar_background: Color::from_str("#262C36").unwrap(), highlighted_text: Color::from_str("#9EECFF").unwrap(), informative_text: Color::from_str("#FF80D2").unwrap(), + charge_voltage_graph_light: Color::from_str("#1F6FEB").unwrap(), + charge_voltage_graph_dark: Color::from_str("#1858BC").unwrap(), + charge_current_graph_light: Color::from_str("#D3FA37").unwrap(), + charge_current_graph_dark: Color::from_str("#A9C82C").unwrap(), + thermal_graph_light: Color::from_str("#FF8E40").unwrap(), + thermal_graph_dark: Color::from_str("#CC7133").unwrap(), } } @@ -312,6 +372,12 @@ impl Theme { bar_background: Color::from_str("#F6F8FA").unwrap(), highlighted_text: Color::from_str("#212183").unwrap(), informative_text: Color::from_str("#8342FA").unwrap(), + charge_voltage_graph_light: Color::from_str("#0969DA").unwrap(), + charge_voltage_graph_dark: Color::from_str("#0754AE").unwrap(), + charge_current_graph_light: Color::from_str("#DB9D00").unwrap(), + charge_current_graph_dark: Color::from_str("#AF7E00").unwrap(), + thermal_graph_light: Color::from_str("#703100").unwrap(), + thermal_graph_dark: Color::from_str("#5A2700").unwrap(), } } @@ -329,6 +395,12 @@ impl Theme { bar_background: Color::from_str("#504945").unwrap(), highlighted_text: Color::from_str("#83A598").unwrap(), informative_text: Color::from_str("#D3869B").unwrap(), + charge_voltage_graph_light: Color::from_str("#458588").unwrap(), + charge_voltage_graph_dark: Color::from_str("#376A6D").unwrap(), + charge_current_graph_light: Color::from_str("#FABD2F").unwrap(), + charge_current_graph_dark: Color::from_str("#C89726").unwrap(), + thermal_graph_light: Color::from_str("#FE8019").unwrap(), + thermal_graph_dark: Color::from_str("#CB6614").unwrap(), } } @@ -346,6 +418,12 @@ impl Theme { bar_background: Color::from_str("#D5C4A1").unwrap(), highlighted_text: Color::from_str("#076678").unwrap(), informative_text: Color::from_str("#8F3F71").unwrap(), + charge_voltage_graph_light: Color::from_str("#458588").unwrap(), + charge_voltage_graph_dark: Color::from_str("#376A6D").unwrap(), + charge_current_graph_light: Color::from_str("#B57614").unwrap(), + charge_current_graph_dark: Color::from_str("#915E10").unwrap(), + thermal_graph_light: Color::from_str("#AF3A03").unwrap(), + thermal_graph_dark: Color::from_str("#8C2E02").unwrap(), } } @@ -363,6 +441,12 @@ impl Theme { bar_background: Color::from_str("#000000").unwrap(), highlighted_text: Color::from_str("#FFFFFF").unwrap(), informative_text: Color::from_str("#FFFFFF").unwrap(), + charge_voltage_graph_light: Color::from_str("#FFFFFF").unwrap(), + charge_voltage_graph_dark: Color::from_str("#CCCCCC").unwrap(), + charge_current_graph_light: Color::from_str("#FFFFFF").unwrap(), + charge_current_graph_dark: Color::from_str("#CCCCCC").unwrap(), + thermal_graph_light: Color::from_str("#FFFFFF").unwrap(), + thermal_graph_dark: Color::from_str("#CCCCCC").unwrap(), } } @@ -380,6 +464,12 @@ impl Theme { bar_background: Color::from_str("#FFFFFF").unwrap(), highlighted_text: Color::from_str("#000000").unwrap(), informative_text: Color::from_str("#000000").unwrap(), + charge_voltage_graph_light: Color::from_str("#000000").unwrap(), + charge_voltage_graph_dark: Color::from_str("#333333").unwrap(), + charge_current_graph_light: Color::from_str("#000000").unwrap(), + charge_current_graph_dark: Color::from_str("#333333").unwrap(), + thermal_graph_light: Color::from_str("#000000").unwrap(), + thermal_graph_dark: Color::from_str("#333333").unwrap(), } } @@ -397,6 +487,12 @@ impl Theme { bar_background: Color::from_str("#373138").unwrap(), highlighted_text: Color::from_str("#77DCE8").unwrap(), informative_text: Color::from_str("#AB9DF2").unwrap(), + charge_voltage_graph_light: Color::from_str("#AB9DF2").unwrap(), + charge_voltage_graph_dark: Color::from_str("#897EC2").unwrap(), + charge_current_graph_light: Color::from_str("#FFD866").unwrap(), + charge_current_graph_dark: Color::from_str("#CCAD52").unwrap(), + thermal_graph_light: Color::from_str("#FF6188").unwrap(), + thermal_graph_dark: Color::from_str("#CC4E6D").unwrap(), } } }