diff options
Diffstat (limited to 'src/main')
| -rw-r--r-- | src/main/resources/db/20251114-0-init-schema.sql | 53 | ||||
| -rw-r--r-- | src/main/resources/db/20251117-0-summary-view.sql | 65 | ||||
| -rw-r--r-- | src/main/rust/app.rs | 220 | ||||
| -rw-r--r-- | src/main/rust/main.rs | 189 | ||||
| -rw-r--r-- | src/main/rust/store/mod.rs | 58 | ||||
| -rw-r--r-- | src/main/rust/store/sqlite.rs | 199 | ||||
| -rw-r--r-- | src/main/rust/ui.rs | 134 |
7 files changed, 0 insertions, 918 deletions
diff --git a/src/main/resources/db/20251114-0-init-schema.sql b/src/main/resources/db/20251114-0-init-schema.sql deleted file mode 100644 index 886a961..0000000 --- a/src/main/resources/db/20251114-0-init-schema.sql +++ /dev/null @@ -1,53 +0,0 @@ --- Equity tracker schema --- SQLite does not control types so directly; but it makes it more portable to a different SQL server with specificity - --- enum as table - there may be other equity types, does not affect views directly -create table equity_type -( - equity_type_name VARCHAR(20) PRIMARY KEY NOT NULL -); - -INSERT INTO equity_type (equity_type_name) VALUES - ('STOCK'), - ('ETF'), - ('CRYPTO'); - --- deletion of equity_type does not cascade - equity_type is required for equity_symbol, which is required for equity_change_event, which is required for fiduciary_account. --- equity_symbol, equity_type would never be deleted, but they could be cleaned up if they are orphaned records. -create table equity_symbol -( - equity_symbol_id SMALLINT PRIMARY KEY NOT NULL, - equity_symbol_name VARCHAR(16) NOT NULL, - equity_symbol_type VARCHAR(20) NOT NULL - REFERENCES equity_type(equity_type_name), - equity_symbol_managing_company VARCHAR(96) NOT NULL, - equity_symbol_created_timestamp TIMESTAMP NOT NULL -); - -create table fiduciary_account -( - fiduciary_account_id TINYINT PRIMARY KEY NOT NULL, - fiduciary_account_number UNSIGNED BIG INT NOT NULL, - fiduciary_account_name NVARCHAR(96) NOT NULL, - fiduciary_account_description NVARCHAR(512), - fiduciary_account_created_timestamp TIMESTAMP NOT NULL -); - --- deletion of fiduciary_account row cascades - an account precludes change events. --- deletion of equity_symbol row does not cascade - all events should be recorded for an account, and all events must have symbols -create table equity_change_event -( - equity_change_event_id UNSIGNED BIG INT PRIMARY KEY NOT NULL, - fiduciary_account_id TINYINT NOT NULL - REFERENCES fiduciary_account(fiduciary_account_id) - ON DELETE CASCADE, - equity_symbol_id SMALLINT NOT NULL - REFERENCES equity_symbol(equity_symbol_id), - equity_change_quantity DECIMAL(20, 8) NOT NULL, - equity_change_cost_basis_usd DECIMAL(14, 2) NOT NULL, - equity_change_type VARCHAR(20) NOT NULL - CHECK (equity_change_type IN ('BUY', 'SELL')), -- This is essentially '+' or '-' - equity_change_timestamp TIMESTAMP NOT NULL, - equity_change_type_score NUMERIC AS (CASE WHEN equity_change_type = 'BUY' THEN 1.0 ELSE -1.0 END) STORED, - equity_change_est_price DECIMAL(14, 2) AS (equity_change_cost_basis_usd / equity_change_quantity) STORED -); diff --git a/src/main/resources/db/20251117-0-summary-view.sql b/src/main/resources/db/20251117-0-summary-view.sql deleted file mode 100644 index a4e8004..0000000 --- a/src/main/resources/db/20251117-0-summary-view.sql +++ /dev/null @@ -1,65 +0,0 @@ --- Yucky slow view, since it's sub-querying for each of the summation fields --- AND has a subquery on the inner join to find the right symbols. Anyways it --- should preview relative strength of treating equity transaction data as --- immutable. Any application on top of this should get the full list of --- records so it can construct helpful views of summations based on the history --- of changes, and/or show price data over time -CREATE VIEW account_symbol_summary_view -AS -SELECT - acc.fiduciary_account_id, - acc.fiduciary_account_name, - acc.fiduciary_account_description, - acc.fiduciary_account_number, - acc.fiduciary_account_created_timestamp, - sym.equity_symbol_id, - sym.equity_symbol_type, - sym.equity_symbol_name, - sym.equity_symbol_managing_company, - (SELECT - SUM(ev.equity_change_quantity * ev.equity_change_type_score) - FROM equity_change_event ev - WHERE ev.fiduciary_account_id = acc.fiduciary_account_id - AND ev.equity_symbol_id = sym.equity_symbol_id - ) AS account_symbol_quantity, - (SELECT - printf( '%.2f', SUM(ev.equity_change_cost_basis_usd * ev.equity_change_type_score) / SUM(ev.equity_change_quantity * ev.equity_change_type_score)) - FROM equity_change_event ev - WHERE ev.fiduciary_account_id = acc.fiduciary_account_id - AND ev.equity_symbol_id = sym.equity_symbol_id - ) AS account_symbol_cost_basis_price, - (SELECT - printf( '%.2f', SUM(ev.equity_change_cost_basis_usd * ev.equity_change_type_score)) - FROM equity_change_event ev - WHERE ev.fiduciary_account_id = acc.fiduciary_account_id - AND ev.equity_symbol_id = sym.equity_symbol_id - ) AS account_symbol_cost_basis_total_usd, - (SELECT - printf( '%.2f', SUM(ev.equity_change_quantity) * ( - SELECT - iev.equity_change_est_price - FROM equity_change_event iev - WHERE iev.fiduciary_account_id = acc.fiduciary_account_id - AND iev.equity_symbol_id = sym.equity_symbol_id - ORDER BY iev.equity_change_timestamp DESC - LIMIT 1 - )) - FROM equity_change_event ev - WHERE ev.fiduciary_account_id = acc.fiduciary_account_id - AND ev.equity_symbol_id = sym.equity_symbol_id - ) AS account_symbol_last_effective_price, - (SELECT - ev.equity_change_timestamp - FROM equity_change_event ev - WHERE ev.fiduciary_account_id = acc.fiduciary_account_id - AND ev.equity_symbol_id = sym.equity_symbol_id - ORDER BY ev.equity_change_timestamp DESC - LIMIT 1 - ) AS account_symbol_last_update_timestamp -FROM fiduciary_account acc -INNER JOIN equity_symbol sym ON EXISTS( - SELECT * - FROM equity_change_event e - WHERE e.fiduciary_account_id = acc.fiduciary_account_id - AND e.equity_symbol_id = sym.equity_symbol_id -);
\ No newline at end of file diff --git a/src/main/rust/app.rs b/src/main/rust/app.rs deleted file mode 100644 index 2ed7c28..0000000 --- a/src/main/rust/app.rs +++ /dev/null @@ -1,220 +0,0 @@ -use crate::store::{Context, Store}; -use miette::{Diagnostic, ErrReport, Result}; -use thiserror::Error; - -#[derive(Default)] -pub struct App { - context: Context, - error: Option<ErrReport>, - exit: bool, -} - -#[derive(Diagnostic, Debug, Error, PartialEq)] -#[error("An overflow error occurred")] -#[diagnostic()] -pub struct OverflowError { - #[help] - pub _msg: String, -} - -impl App { - /// Build context in App and pass it forward - pub fn init_context(&mut self) -> Result<&mut Self> { - let store = self.context.unwrap_store(); - - store.init()?; - Ok(self) - } - - pub fn get_exit_status(&self) -> bool { - self.exit - } - - pub fn exit(&mut self) -> Result<()> { - self.exit = true; - Ok(()) - } - - pub fn decrement_counter(&mut self) -> Result<()> { - let store = self.context.unwrap_store(); - let count = store.get_counter()?; - - if count == 0 { - Err(OverflowError { - _msg: "Cannot count below 0".into(), - } - .into()) - } else { - store.set_counter(count - 1)?; - Ok(()) - } - } - - pub fn increment_counter(&mut self) -> Result<()> { - let store = self.context.unwrap_store(); - let count = store.get_counter()?; - - if count == u8::MAX { - Err(OverflowError { - _msg: "Cannot count above u8::MAX".into(), - } - .into()) - } else { - store.set_counter(count + 1)?; - Ok(()) - } - } - - pub fn display_counter(&self) -> String { - self.context - .get_store() - .get_counter() - .map(|u| u.to_string()) - .unwrap_or("Error".into()) - } - - pub fn set_error(&mut self, error: ErrReport) -> Result<()> { - self.error = Some(error); - Ok(()) - } - - pub fn get_error(&self) -> &Option<ErrReport> { - &self.error - } - - pub fn clear_error(&mut self) -> Result<()> { - self.error = None; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::store::ContextError; - - fn get_count(app: &App) -> Result<u8, ContextError> { - app.context.get_store().get_counter() - } - - #[test] - fn display_counter_inited() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - - let count = app.display_counter(); - - assert_eq!(count, "0"); - } - - #[test] - fn display_counter_noninited() { - let app = App::default(); - - let count = app.display_counter(); - - assert_eq!(count, "Error"); - } - - #[test] - fn increment_decrement() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - - let res = app.increment_counter(); - assert!(res.is_ok()); - assert_eq!(get_count(&app).unwrap(), 1); - - let res = app.decrement_counter(); - assert!(res.is_ok()); - assert_eq!(get_count(&app).unwrap(), 0); - } - - #[test] - fn increment_n() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let n = 8; - - for i in 1..=n { - let res = app.increment_counter(); - assert!(res.is_ok()); - assert_eq!(get_count(&app).unwrap(), i); - } - assert_eq!(get_count(&app).unwrap(), n); - } - - #[test] - fn increment_n_decrement() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let n = 8; - - for i in 1..=n { - let res = app.increment_counter(); - assert!(res.is_ok()); - assert_eq!(get_count(&app).unwrap(), i); - } - assert_eq!(get_count(&app).unwrap(), n); - - let res = app.decrement_counter(); - assert!(res.is_ok()); - assert_eq!(get_count(&app).unwrap(), n - 1); - } - - #[test] - fn exit() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - - let res = app.exit(); - - assert!(res.is_ok()); - assert!(app.exit); - } - - #[test] - fn decrement_counter_error() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let res = app.decrement_counter(); - - assert!(res.is_err()); - let res_err = res.unwrap_err(); - let casted_val = res_err - .downcast_ref::<OverflowError>() - .expect("something bad occurred"); - - let expected_err_msg: String = "Cannot count below 0".into(); - assert_eq!(casted_val._msg, expected_err_msg); - assert_eq!( - *casted_val, - OverflowError { - _msg: expected_err_msg, - }, - ); - } - - #[test] - fn increment_counter_error() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - assert!(app.context.unwrap_store().set_counter(u8::MAX).is_ok()); - let res = app.increment_counter(); - - assert!(res.is_err()); - let res_err = res.unwrap_err(); - let casted_val = res_err - .downcast_ref::<OverflowError>() - .expect("something bad occurred"); - - let expected_err_msg: String = "Cannot count above u8::MAX".into(); - assert_eq!(casted_val._msg, expected_err_msg); - assert_eq!( - *casted_val, - OverflowError { - _msg: expected_err_msg, - }, - ); - } -} diff --git a/src/main/rust/main.rs b/src/main/rust/main.rs deleted file mode 100644 index 124ce81..0000000 --- a/src/main/rust/main.rs +++ /dev/null @@ -1,189 +0,0 @@ -mod app; -pub mod store; -mod ui; - -use app::App; -use miette::{IntoDiagnostic, Result, miette}; -use ratatui::{Terminal, backend::Backend}; - -use ratatui::crossterm::{ - event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, - }, - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::prelude::CrosstermBackend; - -#[cfg(not(tarpaulin_include))] -fn main() -> Result<()> { - enable_raw_mode().into_diagnostic()?; - let mut stdout = std::io::stdout(); - - execute!(stdout, EnterAlternateScreen, EnableMouseCapture).into_diagnostic()?; - - let backend = CrosstermBackend::new(stdout); - let mut term = Terminal::new(backend).into_diagnostic()?; - - let mut app = App::default(); - app.init_context()?; - - // could be error, so don't unwrap; let term restore first - let run = run_app(&mut term, &mut app); - - // restore terminal - disable_raw_mode().into_diagnostic()?; - execute!( - term.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - ) - .into_diagnostic()?; - term.show_cursor().into_diagnostic()?; - run?; - Ok(()) -} - -#[cfg(not(tarpaulin_include))] -fn run_app<B: Backend>(term: &mut Terminal<B>, app: &mut App) -> Result<()> { - while !app.get_exit_status() { - term.draw(|frame| ui::draw(frame, app)) - .map_err(|_| miette!("Unrecoverable error occurred"))?; - - match event::read().into_diagnostic()? { - // it's important to check that the event is a key press event as - // crossterm also emits key release and repeat events on Windows. - Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { - // if error occurred during input processing, set error in the app state - match handle_key_event(app, key_event) { - Err(e) => app.set_error(e)?, - _ => {} - } - } - _ => {} - }; - } - Ok(()) -} - -/// Process user input and run associated app functions -fn handle_key_event(app: &mut App, key_event: KeyEvent) -> Result<()> { - match key_event.code { - KeyCode::Char('q') => app.exit()?, - KeyCode::Left => app.decrement_counter()?, - KeyCode::Right => app.increment_counter()?, - _ => {} - }; - // any new key event, we should clear the existing error - app.clear_error()?; - Ok(()) -} - -#[cfg(test)] -mod tests { - - use super::*; - use app::OverflowError; - - #[test] - fn handle_key_event_inc_dec() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let res = handle_key_event(&mut app, KeyCode::Right.into()); - assert!(res.is_ok()); - assert_eq!(app.display_counter(), "1"); - - let res = handle_key_event(&mut app, KeyCode::Left.into()); - assert!(res.is_ok()); - assert_eq!(app.display_counter(), "0"); - } - - #[test] - fn handle_key_event_inc_n() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let n = 8; - - for i in 1..=n { - let res = handle_key_event(&mut app, KeyCode::Right.into()); - assert!(res.is_ok()); - assert_eq!(app.display_counter(), i.to_string()); - } - assert_eq!(app.display_counter(), n.to_string()); - } - - #[test] - fn handle_key_event_inc_n_dec() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let n = 8; - - for i in 1..=n { - let res = handle_key_event(&mut app, KeyCode::Right.into()); - assert!(res.is_ok()); - assert_eq!(app.display_counter(), i.to_string()); - } - assert_eq!(app.display_counter(), n.to_string()); - - let res = handle_key_event(&mut app, KeyCode::Left.into()); - assert!(res.is_ok()); - assert_eq!(app.display_counter(), (n - 1).to_string()); - } - - #[test] - fn handle_key_event_exit() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let res = handle_key_event(&mut app, KeyCode::Char('q').into()); - assert!(res.is_ok()); - assert!(app.get_exit_status()); - } - - #[test] - fn handle_key_event_dec_error() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let res = handle_key_event(&mut app, KeyCode::Left.into()); - - assert!(res.is_err()); - let res_err = res.unwrap_err(); - let casted_val = res_err - .downcast_ref::<OverflowError>() - .expect("something bad occurred"); - - let expected_err_msg: String = "Cannot count below 0".into(); - assert_eq!(casted_val._msg, expected_err_msg); - assert_eq!( - *casted_val, - OverflowError { - _msg: expected_err_msg, - }, - ); - } - - #[test] - fn handle_key_event_inc_error() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - // no pub set_counter exposed, so we manually set this - for _ in 0..u8::MAX { - assert!(app.increment_counter().is_ok()); - } - let res = handle_key_event(&mut app, KeyCode::Right.into()); - - assert!(res.is_err()); - let res_err = res.unwrap_err(); - let casted_val = res_err - .downcast_ref::<OverflowError>() - .expect("something bad occurred"); - - let expected_err_msg: String = "Cannot count above u8::MAX".into(); - assert_eq!(casted_val._msg, expected_err_msg); - assert_eq!( - *casted_val, - OverflowError { - _msg: expected_err_msg, - }, - ); - } -} diff --git a/src/main/rust/store/mod.rs b/src/main/rust/store/mod.rs deleted file mode 100644 index 2cce3e9..0000000 --- a/src/main/rust/store/mod.rs +++ /dev/null @@ -1,58 +0,0 @@ -mod sqlite; - -use crate::store::sqlite::SQLiteStore; -use miette::{Diagnostic, Result}; -use thiserror::Error; - -pub enum Context { - SQLite(SQLiteStore), -} - -#[derive(Debug, Diagnostic, Error)] -pub enum ContextError { - #[error("Data storage error")] - DataError(#[from] ::sqlite::Error), - #[error("Int conversion error")] - ConversionError(#[from] std::num::TryFromIntError), -} - -impl Default for Context { - fn default() -> Self { - Context::SQLite(SQLiteStore::default()) - } -} - -pub trait Store { - fn init(&mut self) -> Result<(), ContextError>; - fn get_counter(&self) -> Result<u8, ContextError>; - fn set_counter(&mut self, new_count: u8) -> Result<u8, ContextError>; -} - -impl Context { - pub fn unwrap_store(&mut self) -> &mut impl Store { - match self { - Context::SQLite(store) => store, - } - } - - pub fn get_store(&self) -> &impl Store { - match self { - Context::SQLite(store) => store, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn context_default() { - let context = Context::default(); - - let res = match context { - Context::SQLite(_) => true, - }; - assert!(res); - } -} diff --git a/src/main/rust/store/sqlite.rs b/src/main/rust/store/sqlite.rs deleted file mode 100644 index 6647556..0000000 --- a/src/main/rust/store/sqlite.rs +++ /dev/null @@ -1,199 +0,0 @@ -use crate::store::{ContextError, Store}; -use miette::Result; -use sqlite::Connection; - -pub struct SQLiteStore { - location: String, - connection: Option<Connection>, -} - -impl SQLiteStore { - /// Wrap connection in a Result to make it easier to unwrap later - fn get_connection(&self) -> Result<&Connection, sqlite::Error> { - match &self.connection { - Some(conn) => Ok(conn), - None => Err(sqlite::Error { - code: None, - message: Some("Uninitialized".into()), - }), - } - } - - fn create_table(&mut self) -> Result<(), sqlite::Error> { - let conn = self.get_connection()?; - let mut statement = conn.prepare("CREATE TABLE RATAT ( COUNTER SMALLINT )")?; - statement.next()?; - Ok(()) - } - - fn insert_counter(&mut self, counter: i64) -> Result<(), sqlite::Error> { - let conn = self.get_connection()?; - let mut statement = conn.prepare("INSERT INTO RATAT ( COUNTER ) VALUES ( :counter )")?; - statement.bind((":counter", counter))?; - statement.next()?; - Ok(()) - } - - fn select_counter(&self) -> Result<i64, sqlite::Error> { - let conn = self.get_connection()?; - let mut statement = conn.prepare("SELECT COUNTER FROM RATAT")?; - statement.next()?; - statement.read("COUNTER") - } - - fn update_counter(&mut self, counter: i64) -> Result<(), sqlite::Error> { - let conn = self.get_connection()?; - let mut statement = conn.prepare("UPDATE RATAT SET COUNTER = :counter")?; - statement.bind((":counter", counter))?; - statement.next()?; - Ok(()) - } -} - -impl Default for SQLiteStore { - fn default() -> Self { - SQLiteStore { - location: ":memory:".into(), - connection: Option::None, - } - } -} - -impl Store for SQLiteStore { - /// Init SQLite for counting. Creates `RATAT` table with single row, with - /// counter set to `0` - fn init(&mut self) -> Result<(), ContextError> { - let conn_path = self.location.clone(); - let conn = sqlite::open(conn_path)?; - self.connection = Option::Some(conn); - - self.create_table()?; - self.insert_counter(0)?; - - Ok(()) - } - - /// Selects counter from `RATAT` table. Since sqlite doesn't scrutinize the - /// value and we have a more restricted datatype (i64 -> u8), we use - /// `try_from` which may produce an error result. - fn get_counter(&self) -> Result<u8, ContextError> { - let result = self.select_counter()?; - - let count = u8::try_from(result)?; - Ok(count) - } - - /// Updates row with counter value on `RATAT` table - fn set_counter(&mut self, new_count: u8) -> Result<u8, ContextError> { - self.update_counter(i64::from(new_count))?; - - Ok(new_count) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Practically, the `Store` trait is what we interface with externally, and - // provides the more general `Result` interface we want to work with so - // that's what we test here - - #[test] - fn default() { - let context = SQLiteStore::default(); - - assert_eq!(context.location, ":memory:"); - assert!(context.connection.is_none()); - } - - #[test] - fn init() { - let mut context = SQLiteStore::default(); - - let res = context.init(); - - assert!(res.is_ok()); - assert!(context.connection.is_some()); - } - - #[test] - fn get_counter() { - let mut context = SQLiteStore::default(); - assert!(context.init().is_ok()); - - let res = context.get_counter(); - - assert!(res.is_ok()); - assert_eq!(res.unwrap(), 0); - } - - #[test] - fn get_counter_error() { - let context = SQLiteStore::default(); - // don't initialize - - let res = context.get_counter(); - - assert!(res.is_err()); - } - - #[test] - fn get_counter_outside_range() { - let mut context = SQLiteStore::default(); - assert!(context.init().is_ok()); - - let new_value = -1; - if let Some(conn) = &context.connection { - let mut statement = conn.prepare("UPDATE RATAT SET COUNTER = ?").unwrap(); - statement.bind((1, new_value)).unwrap(); - statement.next().unwrap(); - } - let res = context.get_counter(); - - assert!(res.is_err()); - } - - #[test] - fn set_counter() { - let mut context = SQLiteStore::default(); - assert!(context.init().is_ok()); - - let expected = 240; - let res = context.set_counter(expected); - - assert!(res.is_ok()); - assert_eq!(res.unwrap(), expected); - } - - #[test] - fn set_counter_error() { - let mut context = SQLiteStore::default(); - // don't initialize - - let res = context.set_counter(1); - - assert!(res.is_err()); - } - - #[test] - fn set_and_get() { - let mut context = SQLiteStore::default(); - assert!(context.init().is_ok()); - - let count_0 = context.get_counter().unwrap(); - - assert_eq!(count_0, 0); - - let new_num = 244; - let res = context.set_counter(new_num); - - assert!(res.is_ok()); - assert_eq!(res.unwrap(), new_num); - - let count_1 = context.get_counter().unwrap(); - - assert_eq!(count_1, new_num); - assert_ne!(count_0, count_1); - } -} diff --git a/src/main/rust/ui.rs b/src/main/rust/ui.rs deleted file mode 100644 index 1632623..0000000 --- a/src/main/rust/ui.rs +++ /dev/null @@ -1,134 +0,0 @@ -use crate::app::App; -use miette::ErrReport; -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Style, Stylize}, - symbols::border, - text::{Line, Span, Text}, - widgets::{Block, Clear, Paragraph, Widget}, -}; - -pub fn draw(frame: &mut Frame, app: &App) { - let rect = draw_layout(app); - frame.render_widget(rect, frame.area()); - - if let Some(error) = app.get_error() { - let popup = error_popup(error); - - let area = centered_rect(40, 25, frame.area()); - frame.render_widget(Clear, area); - frame.render_widget(popup, area); - } -} - -fn draw_layout(app: &App) -> impl Widget { - let title = Line::from(" Counter App Tutorial ".bold()); - let instructions = Line::from(vec![ - " Decrement ".into(), - "<Left>".blue().bold(), - " Increment ".into(), - "<Right>".blue().bold(), - " Quit ".into(), - "<Q> ".blue().bold(), - ]); - let block = Block::bordered() - .title(title.centered()) - .title_bottom(instructions.centered()) - .border_set(border::THICK); - - let counter_text = Text::from(vec![Line::from(vec![ - "Value: ".into(), - app.display_counter().yellow(), - ])]); - - Paragraph::new(counter_text).centered().block(block) -} - -/// helper function to create a centered rect using up certain percentage of the available rect `r` -fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - // Cut the given rectangle into three vertical pieces - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - // Then cut the middle vertical piece into three width-wise pieces - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] // Return the middle chunk -} - -fn error_popup(error: &ErrReport) -> impl Widget { - let title = Line::from("Error").bold(); - let popup = Block::bordered() - .title(title.centered()) - .border_set(border::DOUBLE); - - let error_text = error_text(error); - - Paragraph::new(error_text).block(popup) -} - -fn error_text(error: &ErrReport) -> Text<'_> { - let errname_spans = vec![ - Span::raw("Error "), - Span::styled("x", Style::new().red()), - Span::raw(" "), - Span::raw(error.to_string()), - ]; - - let line1 = Line::default().spans(errname_spans); - let help_spans = vec![ - Span::styled(" help: ", Style::new().cyan()), - Span::raw(error.help().map_or("".into(), |x| x.to_string())), - ]; - let line2 = Line::default().spans(help_spans); - - Text::from(vec![line1, line2]) -} - -#[cfg(test)] -mod tests { - - use super::*; - use ratatui::{buffer::Buffer, layout::Rect, style::Style}; - - #[test] - fn render_success() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - - let rect = Rect::new(0, 0, 50, 4); - let mut buf = Buffer::empty(rect); - - let res = draw_layout(&app); - res.render(rect, &mut buf); - - let mut expected = Buffer::with_lines(vec![ - "┏━━━━━━━━━━━━━ Counter App Tutorial ━━━━━━━━━━━━━┓", - "┃ Value: 0 ┃", - "┃ ┃", - "┗━ Decrement <Left> Increment <Right> Quit <Q> ━━┛", - ]); - let title_style = Style::new().bold(); - let counter_style = Style::new().yellow(); - let key_style = Style::new().blue().bold(); - - expected.set_style(Rect::new(14, 0, 22, 1), title_style); - expected.set_style(Rect::new(28, 1, 1, 1), counter_style); - expected.set_style(Rect::new(13, 3, 6, 1), key_style); - expected.set_style(Rect::new(30, 3, 7, 1), key_style); - expected.set_style(Rect::new(43, 3, 4, 1), key_style); - - assert_eq!(buf, expected); - } -} |
