From ae045167bf49e8506a015add27c6874642a50fa7 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Sun, 16 Nov 2025 13:12:45 -0500 Subject: [PATCH] Fix projects, remove output in interface, log output to file --- src/app.rs | 119 +++++++++++++++++----------------- src/config.rs | 6 +- src/main.rs | 6 +- src/state.rs | 130 +++++++++++++++++++++++++++++++------ src/ui.rs | 175 +++++++++++++++++++++++++++----------------------- 5 files changed, 274 insertions(+), 162 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6482df2..9e60534 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,8 +1,8 @@ -use std::process::Command; +use crate::config::Config; +use crate::state::{AppState, TimeItem}; use chrono::Utc; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; -use crate::state::{AppState, TimeItem}; -use crate::config::Config; +use std::process::Command; pub enum Screen { Main, @@ -19,7 +19,7 @@ pub struct App { pub new_entry_buffer: String, pub new_entry_project: String, pub new_entry_cursor: usize, - pub new_entry_mode: NewEntryMode, // Task or Project + pub new_entry_mode: NewEntryMode, // Task or Project pub status_message: Option<(String, std::time::Instant)>, } @@ -46,7 +46,7 @@ impl App { pub fn handle_event(&mut self, event: Event) -> anyhow::Result { // Update status message self.update_status_message(); - + match self.current_screen { Screen::Main => self.handle_main_event(event), Screen::Help => self.handle_help_event(event), @@ -58,9 +58,7 @@ impl App { fn handle_main_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { - code, - modifiers, - .. + code, modifiers, .. }) => match (code, modifiers) { (KeyCode::Char('q'), _) => return Ok(true), (KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.move_selection(1), @@ -85,9 +83,7 @@ impl App { fn handle_new_entry_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { - code, - modifiers, - .. + code, modifiers, .. }) => { match (code, modifiers) { (KeyCode::Esc, _) => { @@ -95,16 +91,20 @@ impl App { self.new_entry_buffer.clear(); self.new_entry_project.clear(); self.new_entry_mode = NewEntryMode::Task; + self.new_entry_cursor = 0; } (KeyCode::Enter, _) => { match self.new_entry_mode { NewEntryMode::Task => { if !self.new_entry_buffer.is_empty() { self.new_entry_mode = NewEntryMode::Project; + self.new_entry_cursor = self.new_entry_project.len(); } } NewEntryMode::Project => { - if self.new_entry_project.is_empty() || self.config.is_valid_project(&self.new_entry_project) { + if self.new_entry_project.is_empty() + || self.config.is_valid_project(&self.new_entry_project) + { // Create new time item let item = TimeItem { name: self.new_entry_buffer.clone(), @@ -117,13 +117,13 @@ impl App { }; // Add to current pane (or recurring if in recent) match self.state.current_pane { - 0 => self.state.permanent_items.push(item), // Permanent Items - 1 => self.state.recurring_items.push(item), // Recurring Items - 2 => self.state.recent_items.push(item), // Ad-Hoc Items + 0 => self.state.permanent_items.push(item), // Permanent Items + 1 => self.state.recurring_items.push(item), // Recurring Items + 2 => self.state.recent_items.push(item), // Ad-Hoc Items _ => unreachable!(), } self.state.save()?; - + // Clear and return to main screen self.current_screen = Screen::Main; self.new_entry_buffer.clear(); @@ -133,34 +133,35 @@ impl App { } } } - (KeyCode::Backspace, _) => { - match self.new_entry_mode { - NewEntryMode::Task => { - if self.new_entry_cursor > 0 { - self.new_entry_buffer.remove(self.new_entry_cursor - 1); - self.new_entry_cursor -= 1; - } + (KeyCode::Backspace, _) => match self.new_entry_mode { + NewEntryMode::Task => { + if self.new_entry_cursor > 0 { + self.new_entry_buffer.remove(self.new_entry_cursor - 1); + self.new_entry_cursor -= 1; } - NewEntryMode::Project => { - if self.new_entry_cursor > 0 { - self.new_entry_project.remove(self.new_entry_cursor - 1); + } + NewEntryMode::Project => { + if self.new_entry_cursor > 0 { + let idx = self.new_entry_cursor - 1; + if idx < self.new_entry_project.len() { + self.new_entry_project.remove(idx); self.new_entry_cursor -= 1; } } } - } - (KeyCode::Char(c), m) if m.is_empty() => { - match self.new_entry_mode { - NewEntryMode::Task => { - self.new_entry_buffer.insert(self.new_entry_cursor, c); - self.new_entry_cursor += 1; - } - NewEntryMode::Project => { + }, + (KeyCode::Char(c), m) if m.is_empty() => match self.new_entry_mode { + NewEntryMode::Task => { + self.new_entry_buffer.insert(self.new_entry_cursor, c); + self.new_entry_cursor += 1; + } + NewEntryMode::Project => { + if self.new_entry_cursor <= self.new_entry_project.len() { self.new_entry_project.insert(self.new_entry_cursor, c); self.new_entry_cursor += 1; } } - } + }, _ => {} } } @@ -227,7 +228,13 @@ impl App { fn toggle_current_item(&mut self) -> anyhow::Result<()> { if let Some(item) = self.get_current_item() { - if self.state.active_timer.as_ref().map(|(active, _)| active.name == item.name).unwrap_or(false) { + if self + .state + .active_timer + .as_ref() + .map(|(active, _)| active.name == item.name) + .unwrap_or(false) + { self.state.stop_timer()?; } else { self.state.start_timer(item)?; @@ -248,7 +255,7 @@ impl App { }; let index = self.state.selected_indices[self.state.current_pane]; - + if !items.is_empty() && index < items.len() { if let Some((ref active, _)) = self.state.active_timer { items[index].name == active.name @@ -274,20 +281,20 @@ impl App { }; let index = self.state.selected_indices[self.state.current_pane]; - + if !items.is_empty() && index < items.len() { // Remove the item items.remove(index); - + // Adjust index if we're at the end if !items.is_empty() && index == items.len() { self.state.selected_indices[self.state.current_pane] = items.len() - 1; } - + // Save changes self.state.save()?; } - + Ok(()) } @@ -314,23 +321,21 @@ impl App { fn edit_app_config(&mut self) -> anyhow::Result<()> { use crossterm::{ - terminal::{disable_raw_mode, enable_raw_mode}, execute, - terminal::{LeaveAlternateScreen, EnterAlternateScreen}, + terminal::{disable_raw_mode, enable_raw_mode}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, }; use std::io::stdout; - + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); let config_path = Config::config_path()?; - + // Leave TUI mode disable_raw_mode()?; execute!(stdout(), LeaveAlternateScreen)?; // Run editor - let status = Command::new(editor) - .arg(&config_path) - .status()?; + let status = Command::new(editor).arg(&config_path).status()?; if status.success() { // Reload entire application state @@ -343,21 +348,21 @@ impl App { // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; - + Ok(()) } fn edit_config(&mut self) -> anyhow::Result<()> { use crossterm::{ - terminal::{disable_raw_mode, enable_raw_mode}, execute, - terminal::{LeaveAlternateScreen, EnterAlternateScreen}, + terminal::{disable_raw_mode, enable_raw_mode}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, }; use std::io::stdout; - + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); let config_path = AppState::config_file()?; - + if !config_path.exists() { std::fs::write(&config_path, "# WAT Configuration\n# Add your permanent items here\n\npermanent_items:\n - name: Daily Standup\n tags: [daily, meeting]\n")?; } @@ -367,9 +372,7 @@ impl App { execute!(stdout(), LeaveAlternateScreen)?; // Run editor - let status = Command::new(editor) - .arg(&config_path) - .status()?; + let status = Command::new(editor).arg(&config_path).status()?; if status.success() { // Reload entire application state @@ -382,7 +385,7 @@ impl App { // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; - + Ok(()) } -} \ No newline at end of file +} diff --git a/src/config.rs b/src/config.rs index 7599a8a..7c4abc2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -57,8 +57,8 @@ impl Config { } pub fn config_path() -> anyhow::Result { - let mut path = dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?; + let mut path = + dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?; path.push("wat"); std::fs::create_dir_all(&path)?; path.push("config.yaml"); @@ -71,4 +71,4 @@ impl Config { } self.projects.contains(&project.to_string()) } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 46cc7c9..71adce6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ mod app; +mod config; mod state; mod ui; -mod config; use anyhow::Result; use crossterm::{ @@ -48,7 +48,7 @@ fn run_app(terminal: &mut Terminal, app: &mut app::App) -> Result terminal.draw(|f| ui::render(f, app))?; app.needs_redraw = false; } - + if event::poll(std::time::Duration::from_millis(50))? { if app.handle_event(event::read()?)? { return Ok(()); @@ -57,4 +57,4 @@ fn run_app(terminal: &mut Terminal, app: &mut app::App) -> Result terminal.draw(|f| ui::render(f, app))?; } } -} \ No newline at end of file +} diff --git a/src/state.rs b/src/state.rs index 1a8a2fe..4fa7e47 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,10 @@ -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimeItem { @@ -34,8 +38,8 @@ impl Default for AppState { impl AppState { pub fn config_dir() -> anyhow::Result { - let mut path = dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?; + let mut path = + dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?; path.push("wat"); std::fs::create_dir_all(&path)?; Ok(path) @@ -53,6 +57,12 @@ impl AppState { Ok(path) } + fn log_file() -> anyhow::Result { + let mut path = Self::config_dir()?; + path.push("watson.log"); + Ok(path) + } + pub fn load() -> anyhow::Result { let path = Self::state_file()?; if !path.exists() { @@ -76,18 +86,50 @@ impl AppState { } } + pub fn read_log_lines(limit: usize) -> anyhow::Result> { + let path = Self::log_file()?; + if !path.exists() { + return Ok(Vec::new()); + } + let contents = fs::read_to_string(path)?; + let mut lines: Vec = contents.lines().map(|l| l.to_string()).collect(); + if lines.len() > limit { + lines = lines.split_off(lines.len() - limit); + } + Ok(lines) + } + pub fn start_timer(&mut self, item: TimeItem) -> anyhow::Result<()> { - // Stop current timer if any if let Some((_, _)) = self.active_timer { self.stop_timer()?; } - // Start new timer - std::process::Command::new("watson") - .args(["start", &item.name]) - .args(item.tags.iter().map(|t| format!("-T {}", t))) - .spawn()? - .wait()?; + let mut command = std::process::Command::new("watson"); + command.arg("start").arg(&item.name); + for tag in &item.tags { + let tag = tag.trim(); + if tag.is_empty() { + continue; + } + if tag.starts_with('+') { + command.arg(tag); + } else { + command.arg(format!("+{}", tag)); + } + } + + let output = command.output()?; + Self::log_command_output( + &format!("watson start {} {}", item.name, item.tags.join(" ")), + &output, + )?; + + if !output.status.success() { + anyhow::bail!( + "Watson start failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } self.active_timer = Some((item.clone(), Utc::now())); Ok(()) @@ -95,12 +137,17 @@ impl AppState { pub fn stop_timer(&mut self) -> anyhow::Result<()> { if let Some((ref item, _)) = self.active_timer { - std::process::Command::new("watson") - .arg("stop") - .spawn()? - .wait()?; - - // Update last_used timestamp + let output = std::process::Command::new("watson").arg("stop").output()?; + + Self::log_command_output("watson stop", &output)?; + + if !output.status.success() { + anyhow::bail!( + "Watson stop failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let update_last_used = |items: &mut Vec| { if let Some(target) = items.iter_mut().find(|i| i.name == item.name) { target.last_used = Some(Utc::now()); @@ -115,4 +162,51 @@ impl AppState { } Ok(()) } -} \ No newline at end of file + + fn log_command_output(description: &str, output: &std::process::Output) -> anyhow::Result<()> { + let mut lines = Vec::new(); + lines.push(format!("[{}] {description}", Utc::now().to_rfc3339())); + lines.push(format!("status: {}", output.status)); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !stdout.is_empty() { + lines.push("stdout:".into()); + lines.push(stdout); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if !stderr.is_empty() { + lines.push("stderr:".into()); + lines.push(stderr); + } + + lines.push(String::new()); + Self::append_log_entry(&lines)?; + Ok(()) + } + + fn append_log_entry(lines: &[String]) -> anyhow::Result<()> { + let path = Self::log_file()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let mut file = OpenOptions::new().create(true).append(true).open(&path)?; + for line in lines { + writeln!(file, "{line}")?; + } + Self::trim_log(&path)?; + Ok(()) + } + + fn trim_log(path: &Path) -> anyhow::Result<()> { + const MAX_LINES: usize = 800; + let contents = fs::read_to_string(path)?; + let mut lines: Vec<&str> = contents.lines().collect(); + if lines.len() > MAX_LINES { + lines = lines.split_off(lines.len() - MAX_LINES); + fs::write(path, lines.join("\n") + "\n")?; + } + Ok(()) + } +} diff --git a/src/ui.rs b/src/ui.rs index 71b733a..b05b852 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,12 +1,15 @@ use ratatui::{ - layout::{Constraint, Direction, Layout, Rect, Alignment}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph, Clear}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, Frame, }; -use crate::{state::{AppState, TimeItem}, app::{App, Screen, NewEntryMode}}; +use crate::{ + app::{App, NewEntryMode, Screen}, + state::{AppState, TimeItem}, +}; const ACTIVE_COLOR: Color = Color::Green; const INACTIVE_COLOR: Color = Color::Yellow; @@ -25,24 +28,28 @@ fn render_main(frame: &mut Frame, app: &App) { let show_bottom_bar = app.config.show_help_hint || app.config.show_command_hints; let has_status = app.status_message.is_some(); let bottom_height = if show_bottom_bar { - if has_status { 2 } else { 1 } + if has_status { + 2 + } else { + 1 + } } else { - if has_status { 1 } else { 0 } + if has_status { + 1 + } else { + 0 + } }; let constraints = if bottom_height > 0 { vec![ - Constraint::Min(3), // At least 3 lines for each section + Constraint::Min(3), // At least 3 lines for each section Constraint::Min(3), Constraint::Min(3), - Constraint::Length(bottom_height), // Command bar + optional status + Constraint::Length(bottom_height), // Command bar + optional status ] } else { - vec![ - Constraint::Min(3), - Constraint::Min(3), - Constraint::Min(3), - ] + vec![Constraint::Min(3), Constraint::Min(3), Constraint::Min(3)] }; let chunks = Layout::default() @@ -109,24 +116,22 @@ fn render_new_entry(frame: &mut Frame, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(3), - ]) + .constraints([Constraint::Length(3), Constraint::Length(3)]) .split(area); // Task name input let task_block = Block::default() .title("Task Name") .borders(Borders::ALL) - .style(Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Task) { - ACTIVE_COLOR - } else { - Color::White - })); + .style( + Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Task) { + ACTIVE_COLOR + } else { + Color::White + }), + ); - let task_text = Paragraph::new(app.new_entry_buffer.as_str()) - .block(task_block); + let task_text = Paragraph::new(app.new_entry_buffer.as_str()).block(task_block); frame.render_widget(task_text, chunks[0]); @@ -134,14 +139,17 @@ fn render_new_entry(frame: &mut Frame, app: &App) { let project_block = Block::default() .title("Project (optional)") .borders(Borders::ALL) - .style(Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Project) { - ACTIVE_COLOR - } else { - Color::White - })); + .style( + Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Project) { + ACTIVE_COLOR + } else { + Color::White + }), + ); let project_text = if !app.config.projects.is_empty() { - format!("{} (available: {})", + format!( + "{} (available: {})", app.new_entry_project, app.config.projects.join(", ") ) @@ -149,18 +157,12 @@ fn render_new_entry(frame: &mut Frame, app: &App) { app.new_entry_project.clone() }; - let project_text = Paragraph::new(project_text) - .block(project_block); + let project_text = Paragraph::new(project_text).block(project_block); frame.render_widget(project_text, chunks[1]); // Render command bar - let bar_area = Rect::new( - 0, - frame.size().height - 1, - frame.size().width, - 1, - ); + let bar_area = Rect::new(0, frame.size().height - 1, frame.size().width, 1); let command_text = match app.new_entry_mode { NewEntryMode::Task => "Enter task name, press Enter to continue", @@ -180,8 +182,8 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) { Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(1), // Status message - Constraint::Length(1), // Command bar + Constraint::Length(1), // Status message + Constraint::Length(1), // Command bar ]) .split(area) } else { @@ -210,19 +212,23 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) { ("d", "delete"), ("q", "quit"), ]; - - let command_text = format!(" {}", commands.iter() - .map(|(key, desc)| format!("{} ({})", key, desc)) - .collect::>() - .join(" · ")); - + + let command_text = format!( + " {}", + commands + .iter() + .map(|(key, desc)| format!("{} ({})", key, desc)) + .collect::>() + .join(" · ") + ); + let command_area = if app.config.show_help_hint { // Leave space for help hint Rect::new( - chunks[command_line_idx].x, - chunks[command_line_idx].y, - chunks[command_line_idx].width.saturating_sub(12), - 1 + chunks[command_line_idx].x, + chunks[command_line_idx].y, + chunks[command_line_idx].width.saturating_sub(12), + 1, ) } else { chunks[command_line_idx] @@ -231,7 +237,7 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) { let command_bar = Paragraph::new(command_text) .style(Style::default().fg(Color::White)) .alignment(Alignment::Left); - + frame.render_widget(command_bar, command_area); } @@ -239,49 +245,50 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) { let help_hint = Paragraph::new("(?) for help") .alignment(Alignment::Right) .style(Style::default().fg(Color::DarkGray)); - + let help_area = Rect::new( chunks[command_line_idx].x + chunks[command_line_idx].width.saturating_sub(12), chunks[command_line_idx].y, 12, 1, ); - + frame.render_widget(help_hint, help_area); } } fn render_help_command_bar(frame: &mut Frame) { - let commands = vec![ - ("c", "configuration help"), - ("q/ESC", "back"), - ]; - - let command_text = format!(" {}", commands.iter() - .map(|(key, desc)| format!("{} ({})", key, desc)) - .collect::>() - .join(" · ")); - + let commands = vec![("c", "configuration help"), ("q/ESC", "back")]; + + let command_text = format!( + " {}", + commands + .iter() + .map(|(key, desc)| format!("{} ({})", key, desc)) + .collect::>() + .join(" · ") + ); + let bar_area = Rect::new( 0, frame.size().height.saturating_sub(1), frame.size().width, 1, ); - + let command_bar = Paragraph::new(command_text) .style(Style::default().fg(Color::White)) .alignment(Alignment::Left); - + frame.render_widget(command_bar, bar_area); } fn render_help(frame: &mut Frame, _app: &App) { let width = frame.size().width.saturating_sub(4).min(60); let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size()); - + frame.render_widget(Clear, area); - + let help_text = vec![ "WAT - Watson Time Tracker Interface", "", @@ -312,17 +319,17 @@ fn render_help(frame: &mut Frame, _app: &App) { ]; let text = help_text.join("\n"); - + let block = Block::default() .title("Help") .borders(Borders::ALL) .style(Style::default().fg(Color::White)); - + let paragraph = Paragraph::new(text) .block(block) .style(Style::default().fg(Color::White)) .wrap(ratatui::widgets::Wrap { trim: true }); - + frame.render_widget(paragraph, area); render_help_command_bar(frame); } @@ -330,9 +337,9 @@ fn render_help(frame: &mut Frame, _app: &App) { fn render_config_help(frame: &mut Frame, _app: &App) { let width = frame.size().width.saturating_sub(4).min(60); let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size()); - + frame.render_widget(Clear, area); - + let help_text = vec![ "WAT Configuration", "", @@ -360,17 +367,17 @@ fn render_config_help(frame: &mut Frame, _app: &App) { ]; let text = help_text.join("\n"); - + let block = Block::default() .title("Configuration Help") .borders(Borders::ALL) .style(Style::default().fg(Color::White)); - + let paragraph = Paragraph::new(text) .block(block) .style(Style::default().fg(Color::White)) .wrap(ratatui::widgets::Wrap { trim: true }); - + frame.render_widget(paragraph, area); render_help_command_bar(frame); } @@ -384,8 +391,12 @@ fn render_section( selected: usize, state: &AppState, ) { - let border_color = if is_active { ACTIVE_COLOR } else { INACTIVE_COLOR }; - + let border_color = if is_active { + ACTIVE_COLOR + } else { + INACTIVE_COLOR + }; + let block = Block::default() .borders(Borders::ALL) .title(title) @@ -402,9 +413,13 @@ fn render_section( .unwrap_or(false); let style = if is_running { - Style::default().fg(ACTIVE_COLOR).add_modifier(Modifier::BOLD) + Style::default() + .fg(ACTIVE_COLOR) + .add_modifier(Modifier::BOLD) } else if i == selected && is_active { - Style::default().fg(border_color).add_modifier(Modifier::REVERSED) + Style::default() + .fg(border_color) + .add_modifier(Modifier::REVERSED) } else { Style::default() }; @@ -427,11 +442,11 @@ fn render_section( fn centered_rect(width: u16, height: u16, r: Rect) -> Rect { let x = (r.width.saturating_sub(width)) / 2; let y = (r.height.saturating_sub(height)) / 2; - + Rect { x: r.x + x, y: r.y + y, width: width.min(r.width), height: height.min(r.height), } -} \ No newline at end of file +}