[Add] basic split
This commit is contained in:
parent
a0ff6b1082
commit
3cdf7c62d5
77 changed files with 506 additions and 648 deletions
1
iota-cli/src/app_state.rs
Executable file
1
iota-cli/src/app_state.rs
Executable file
|
|
@ -0,0 +1 @@
|
|||
pub use iota_state::*;
|
||||
491
iota-cli/src/elements/console_card.rs
Normal file
491
iota-cli/src/elements/console_card.rs
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use ttp_core::{CommunicationType, CommunicationValue};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
ACTIVE_TASKS, RELOAD, SHUTDOWN,
|
||||
gui::{
|
||||
elements::elements::{Element, InteractableElement, JoinableElement},
|
||||
interaction_result::InteractionResult,
|
||||
ui::FPS,
|
||||
util::borders::draw_block_joins,
|
||||
},
|
||||
log, log_command, log_cv,
|
||||
omikron::omikron_connection::OMIKRON_CONNECTION,
|
||||
users::{user_manager, user_profile::UserProfile},
|
||||
util::file_util,
|
||||
};
|
||||
use std::{
|
||||
any::Any,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::time::Instant;
|
||||
|
||||
pub struct ConsoleCard {
|
||||
focused: bool,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub cursor_position: usize,
|
||||
|
||||
borders: Borders,
|
||||
joins: Borders,
|
||||
|
||||
cursor: Arc<Mutex<bool>>,
|
||||
last_swap: Arc<Mutex<Instant>>,
|
||||
tab_index: usize,
|
||||
}
|
||||
|
||||
impl ConsoleCard {
|
||||
pub fn new(title: &str, content: &str) -> Self {
|
||||
ConsoleCard {
|
||||
focused: false,
|
||||
title: title.to_string(),
|
||||
content: content.to_string(),
|
||||
cursor_position: content.chars().count(),
|
||||
borders: Borders::ALL,
|
||||
joins: Borders::NONE,
|
||||
cursor: Arc::new(Mutex::new(true)),
|
||||
last_swap: Arc::new(Mutex::new(Instant::now())),
|
||||
tab_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn byte_index(&self) -> usize {
|
||||
self.content
|
||||
.char_indices()
|
||||
.nth(self.cursor_position)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(self.content.len())
|
||||
}
|
||||
|
||||
fn cursor_visible(&self) -> bool {
|
||||
if !self.focused {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut visible = self.cursor.lock().unwrap();
|
||||
let mut last = self.last_swap.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
|
||||
if now.duration_since(*last) >= Duration::from_millis(500) {
|
||||
*visible = !*visible;
|
||||
*last = now;
|
||||
}
|
||||
|
||||
*visible
|
||||
}
|
||||
|
||||
fn current_prefix(&self) -> Option<&str> {
|
||||
if self.content.starts_with('/') {
|
||||
Some("/")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_spans(&self) -> Vec<Span<'static>> {
|
||||
let cursor_visible = self.cursor_visible();
|
||||
let cursor_style = Style::default().fg(Color::White).bg(Color::DarkGray);
|
||||
let mut spans = Vec::new();
|
||||
|
||||
if self.content.is_empty() {
|
||||
if self.focused {
|
||||
if cursor_visible {
|
||||
spans.push(Span::styled(" ", cursor_style));
|
||||
} else {
|
||||
spans.push(Span::styled(" ", Style::default().fg(Color::White)));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
"send command (<help> for info)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
} else {
|
||||
spans.push(Span::styled(
|
||||
" send command (<help> for info)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
let byte_index = self.byte_index();
|
||||
let before = self.content[..byte_index].to_string();
|
||||
let after = self.content[byte_index..].to_string();
|
||||
|
||||
let prefix_len = self.current_prefix().map(|s| s.len()).unwrap_or(0);
|
||||
|
||||
if prefix_len > 0 && before.len() >= prefix_len {
|
||||
let prefix = &before[..prefix_len];
|
||||
let rest = &before[prefix_len..];
|
||||
spans.push(Span::styled(
|
||||
prefix.to_string(),
|
||||
Self::style_for_part(true, false, false),
|
||||
));
|
||||
if !rest.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
rest.to_string(),
|
||||
Style::default().fg(Color::White),
|
||||
));
|
||||
}
|
||||
} else if !before.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
before.clone(),
|
||||
Style::default().fg(Color::White),
|
||||
));
|
||||
}
|
||||
|
||||
if cursor_visible {
|
||||
spans.push(Span::styled(" ", cursor_style));
|
||||
}
|
||||
|
||||
if !after.is_empty() {
|
||||
spans.push(Span::styled(after, Style::default().fg(Color::White)));
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
fn style_for_part(is_prefix: bool, is_hint: bool, is_error: bool) -> Style {
|
||||
if is_error {
|
||||
return Style::default().fg(Color::Red);
|
||||
}
|
||||
|
||||
if is_hint {
|
||||
return Style::default().fg(Color::DarkGray);
|
||||
}
|
||||
|
||||
if is_prefix {
|
||||
return Style::default().fg(Color::DarkGray);
|
||||
}
|
||||
|
||||
Style::default().fg(Color::White)
|
||||
}
|
||||
|
||||
fn render_cursor_spans(&self) -> Vec<Span<'static>> {
|
||||
self.cursor_spans()
|
||||
}
|
||||
|
||||
fn move_cursor_left(&mut self) {
|
||||
if self.cursor_position > 0 {
|
||||
self.cursor_position -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn move_cursor_right(&mut self) {
|
||||
let len = self.content.chars().count();
|
||||
if self.cursor_position < len {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_at_cursor(&mut self) {
|
||||
if self.content.is_empty() || self.cursor_position == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let start = self
|
||||
.content
|
||||
.char_indices()
|
||||
.nth(self.cursor_position.saturating_sub(1))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
let end = self.byte_index();
|
||||
self.content.replace_range(start..end, "");
|
||||
self.cursor_position -= 1;
|
||||
}
|
||||
|
||||
fn insert_at_cursor(&mut self, c: char) {
|
||||
let idx = self.byte_index();
|
||||
self.content.insert(idx, c);
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for ConsoleCard {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, r: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(self.borders)
|
||||
.title(self.title.clone())
|
||||
.title_style(Style::default().fg(Color::White))
|
||||
.border_style(if self.focused {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
})
|
||||
.style(if self.focused {
|
||||
Style::default().fg(Color::White)
|
||||
} else {
|
||||
Style::default()
|
||||
});
|
||||
|
||||
let spans = self.render_cursor_spans();
|
||||
let par = Paragraph::new(Line::from(spans))
|
||||
.block(block)
|
||||
.scroll((0, 0));
|
||||
f.render_widget(par, r);
|
||||
draw_block_joins(f, r, self.borders, self.joins);
|
||||
}
|
||||
}
|
||||
|
||||
impl JoinableElement for ConsoleCard {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_element(&self) -> &(dyn Element + 'static) {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) {
|
||||
self
|
||||
}
|
||||
|
||||
fn set_borders(&mut self, borders: Borders) {
|
||||
self.borders = borders;
|
||||
}
|
||||
|
||||
fn set_joins(&mut self, joins: Borders) {
|
||||
self.joins = joins;
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractableElement for ConsoleCard {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_element(&self) -> &(dyn Element + 'static) {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) {
|
||||
self
|
||||
}
|
||||
|
||||
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
if self.content.is_empty() {
|
||||
log!("");
|
||||
return InteractionResult::Handled;
|
||||
}
|
||||
|
||||
let command = self.content.clone();
|
||||
let id = Uuid::new_v4();
|
||||
let id = id.to_string();
|
||||
let id = id.split_at(8).0;
|
||||
let task_id = format!("command_{}_{}", command, id);
|
||||
ACTIVE_TASKS.insert(task_id.clone());
|
||||
|
||||
log_command!("{}", command);
|
||||
|
||||
tokio::spawn(async move {
|
||||
run_command(&command).await;
|
||||
ACTIVE_TASKS.remove(&task_id);
|
||||
});
|
||||
|
||||
self.content.clear();
|
||||
self.cursor_position = 0;
|
||||
self.tab_index = 0;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.delete_at_cursor();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
let len = self.content.chars().count();
|
||||
if self.cursor_position < len {
|
||||
let start = self.byte_index();
|
||||
let end = self
|
||||
.content
|
||||
.char_indices()
|
||||
.nth(self.cursor_position + 1)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(self.content.len());
|
||||
self.content.replace_range(start..end, "");
|
||||
}
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.move_cursor_left();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Right => {
|
||||
self.move_cursor_right();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Home => {
|
||||
self.cursor_position = 0;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.cursor_position = self.content.chars().count();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
if let Some(prefix) = self.current_prefix() {
|
||||
if prefix == "/" {
|
||||
self.tab_index = self.tab_index.saturating_add(1);
|
||||
}
|
||||
}
|
||||
InteractionResult::Handled
|
||||
}
|
||||
_ => {
|
||||
if let Some(c) = key.code.as_char() {
|
||||
self.insert_at_cursor(c);
|
||||
InteractionResult::Handled
|
||||
} else {
|
||||
InteractionResult::Unhandled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn can_focus(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
|
||||
fn focus(&mut self, f: bool) {
|
||||
self.focused = f;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_command(command: &str) {
|
||||
let parts = command.split(" ").collect::<Vec<&str>>();
|
||||
|
||||
match parts.as_slice() {
|
||||
["tasks"] => {
|
||||
let active_tasks: Vec<String> =
|
||||
ACTIVE_TASKS.clone().iter().map(|v| v.to_string()).collect();
|
||||
let info = if *SHUTDOWN.read().await && *RELOAD.read().await {
|
||||
"Rebooting, "
|
||||
} else if *SHUTDOWN.read().await {
|
||||
"Shutting , "
|
||||
} else {
|
||||
""
|
||||
};
|
||||
log!("{}Active tasks: {:?}", info, active_tasks);
|
||||
}
|
||||
["fps"] => {
|
||||
let (fps, skips) = *FPS.read().await;
|
||||
log!("{:.1} FPS with {:.1}% of attempts skipped", fps, skips);
|
||||
}
|
||||
|
||||
["help"] => {
|
||||
log!("Available commands: tasks, fps, ping, user");
|
||||
}
|
||||
|
||||
["help", "tasks"] => {
|
||||
log!("Tasks command usage: tasks");
|
||||
}
|
||||
["help", "fps"] => {
|
||||
log!("FPS command usage: fps");
|
||||
}
|
||||
["help", "ping"] => {
|
||||
log!("Ping command usage: ping [time]");
|
||||
}
|
||||
["help", "user"] => {
|
||||
log!("User command usage: user add <username> | user remove <username> | user list");
|
||||
}
|
||||
|
||||
["ping"] => {
|
||||
ping(20).await;
|
||||
}
|
||||
["ping", time] => {
|
||||
let time = time.parse::<u64>().unwrap_or(20);
|
||||
ping(time).await;
|
||||
}
|
||||
["user", "add", username] => {
|
||||
if let (Some(user), Some(_)) = user_manager::create_user(username).await {
|
||||
log!("Created user {}", user.user_id);
|
||||
} else {
|
||||
log!("Failed to create user");
|
||||
}
|
||||
}
|
||||
["user", "remove", username] => {
|
||||
if let Some(user) = user_manager::get_user_by_username(username) {
|
||||
user_manager::remove_user(user.user_id);
|
||||
log!("Removed user {}", user.user_id);
|
||||
} else {
|
||||
log!("Failed to find user");
|
||||
}
|
||||
}
|
||||
["user", "list"] => {
|
||||
let users: Vec<UserProfile> = user_manager::get_users();
|
||||
for user in users {
|
||||
let storage = file_util::get_designed_storage(user.user_id);
|
||||
log!(
|
||||
"> Username: {}, ID: {}, created at: {}, storage: {}",
|
||||
user.username,
|
||||
user.user_id,
|
||||
user.created_at,
|
||||
storage
|
||||
);
|
||||
}
|
||||
}
|
||||
["user", "info", username] => {
|
||||
if let Some(user) = user_manager::get_user_by_username(username) {
|
||||
user_manager::remove_user(user.user_id);
|
||||
log!("Removed user {}", user.user_id);
|
||||
} else {
|
||||
log!("Failed to find user");
|
||||
}
|
||||
}
|
||||
["reload"] | ["restart"] => {
|
||||
log!("Restarting");
|
||||
*RELOAD.write().await = true;
|
||||
*SHUTDOWN.write().await = true;
|
||||
}
|
||||
["shutdown"] | ["stop"] => {
|
||||
log!("Shutting down");
|
||||
*SHUTDOWN.write().await = true;
|
||||
}
|
||||
_ => {
|
||||
log!("Unknown command");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ping(time: u64) {
|
||||
let conn = OMIKRON_CONNECTION.clone();
|
||||
|
||||
let response_cv = conn
|
||||
.await_response(
|
||||
&CommunicationValue::new(CommunicationType::ping),
|
||||
Some(Duration::from_secs(time)),
|
||||
)
|
||||
.await;
|
||||
match response_cv {
|
||||
Ok(response) => log_cv!(response),
|
||||
Err(err) => log!("Ping error: {:?}", err),
|
||||
}
|
||||
}
|
||||
49
iota-cli/src/elements/elements.rs
Normal file
49
iota-cli/src/elements/elements.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use std::any::Any;
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::{Frame, layout::Rect, widgets::Borders};
|
||||
|
||||
use crate::gui::{interaction_result::InteractionResult, screens::screens::Screen};
|
||||
|
||||
#[allow(unused)]
|
||||
pub trait Element: Send + Sync + Any {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
|
||||
fn render(&self, f: &mut Frame, r: Rect);
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub trait JoinableElement: Send + Sync + Any {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
fn as_element(&self) -> &dyn Element;
|
||||
fn as_element_mut(&mut self) -> &mut dyn Element;
|
||||
|
||||
fn set_borders(&mut self, borders: Borders);
|
||||
fn set_joins(&mut self, joins: Borders);
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub trait InfoElement: Send + Sync + Any {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
fn as_element(&self) -> &dyn Element;
|
||||
fn as_element_mut(&mut self) -> &mut dyn Element;
|
||||
|
||||
fn get_info_screen(&self) -> Box<dyn Screen>;
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub trait InteractableElement: Send + Sync + Any {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
fn as_element(&self) -> &dyn Element;
|
||||
fn as_element_mut(&mut self) -> &mut dyn Element;
|
||||
|
||||
fn interact(&mut self, key: KeyEvent) -> InteractionResult;
|
||||
|
||||
fn can_focus(&self) -> bool;
|
||||
fn is_focused(&self) -> bool;
|
||||
fn focus(&mut self, f: bool);
|
||||
}
|
||||
215
iota-cli/src/elements/graph_card.rs
Normal file
215
iota-cli/src/elements/graph_card.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
use std::{any::Any, sync::Arc};
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
widgets::{
|
||||
Block, Borders,
|
||||
canvas::{Canvas, Line},
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
APP_STATE,
|
||||
gui::{
|
||||
elements::elements::{Element, InteractableElement, JoinableElement},
|
||||
interaction_result::InteractionResult,
|
||||
ui::UI,
|
||||
util::borders::draw_block_joins,
|
||||
},
|
||||
};
|
||||
|
||||
pub enum GRAPHS {
|
||||
Ram,
|
||||
Cpu,
|
||||
Ping,
|
||||
}
|
||||
|
||||
impl GRAPHS {
|
||||
pub fn get_color(&self) -> Color {
|
||||
match self {
|
||||
GRAPHS::Ram => Color::Blue,
|
||||
GRAPHS::Cpu => Color::Red,
|
||||
GRAPHS::Ping => Color::Green,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_graph(&self) -> Vec<(f64, f64)> {
|
||||
match self {
|
||||
GRAPHS::Ram => APP_STATE.lock().unwrap().with_width(28).ram.clone(),
|
||||
GRAPHS::Cpu => APP_STATE.lock().unwrap().with_width(28).cpu.clone(),
|
||||
GRAPHS::Ping => APP_STATE.lock().unwrap().with_width(28).ping.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_unit(&self) -> String {
|
||||
match self {
|
||||
GRAPHS::Ram => "MB".to_string(),
|
||||
GRAPHS::Cpu => "%".to_string(),
|
||||
GRAPHS::Ping => "ms".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub struct GraphCard {
|
||||
ui: Arc<UI>,
|
||||
graph_type: GRAPHS,
|
||||
|
||||
focused: bool,
|
||||
pub title: String,
|
||||
|
||||
borders: Borders,
|
||||
joins: Borders,
|
||||
|
||||
open: bool,
|
||||
}
|
||||
|
||||
impl GraphCard {
|
||||
pub fn new(ui: Arc<UI>, graph_type: GRAPHS, title: String) -> Self {
|
||||
Self {
|
||||
ui,
|
||||
graph_type,
|
||||
focused: false,
|
||||
title,
|
||||
borders: Borders::ALL,
|
||||
joins: Borders::NONE,
|
||||
open: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_open(&mut self, open: bool) {
|
||||
self.open = open;
|
||||
}
|
||||
}
|
||||
impl Element for GraphCard {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, r: Rect) {
|
||||
if self.open {
|
||||
let graph = self.graph_type.get_graph();
|
||||
let unit = self.graph_type.get_unit();
|
||||
let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0);
|
||||
let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0);
|
||||
let min_y = graph
|
||||
.iter()
|
||||
.map(|(_, y)| *y)
|
||||
.filter(|y| *y > 0.0)
|
||||
.min_by(|a, b| a.total_cmp(b))
|
||||
.unwrap_or(0.0);
|
||||
let max_y = graph.iter().map(|(_, y)| *y).fold(-1.0, f64::max);
|
||||
|
||||
let block = Block::default()
|
||||
.title(format!(
|
||||
"{}:─{}{}─{}min/{}max",
|
||||
self.title,
|
||||
graph.last().unwrap_or(&(0.0, 0.0)).1 as i64,
|
||||
unit,
|
||||
min_y as i64,
|
||||
max_y as i64,
|
||||
))
|
||||
.borders(self.borders)
|
||||
.border_style(if self.focused {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
});
|
||||
|
||||
let canvas = Canvas::default()
|
||||
.block(block)
|
||||
.x_bounds([min_x, max_x])
|
||||
.y_bounds([0.0, 100.0])
|
||||
.paint(|ctx| {
|
||||
for (x, y) in &graph {
|
||||
ctx.draw(&Line {
|
||||
x1: *x,
|
||||
y1: 0.0,
|
||||
x2: *x,
|
||||
y2: *y,
|
||||
color: self.graph_type.get_color(),
|
||||
});
|
||||
}
|
||||
});
|
||||
f.render_widget(canvas, r);
|
||||
} else {
|
||||
let block = Block::default()
|
||||
.title("")
|
||||
.borders(self.borders)
|
||||
.border_style(if self.focused {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
});
|
||||
f.render_widget(block, r);
|
||||
}
|
||||
draw_block_joins(f, r, self.borders, self.joins);
|
||||
}
|
||||
}
|
||||
|
||||
impl JoinableElement for GraphCard {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_element(&self) -> &dyn Element {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_element_mut(&mut self) -> &mut dyn Element {
|
||||
self
|
||||
}
|
||||
|
||||
fn set_borders(&mut self, borders: Borders) {
|
||||
self.borders = borders;
|
||||
}
|
||||
|
||||
fn set_joins(&mut self, joins: Borders) {
|
||||
self.joins = joins;
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractableElement for GraphCard {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_element(&self) -> &dyn Element {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_element_mut(&mut self) -> &mut dyn Element {
|
||||
self
|
||||
}
|
||||
|
||||
fn interact(&mut self, _key: KeyEvent) -> InteractionResult {
|
||||
InteractionResult::Handled
|
||||
}
|
||||
|
||||
fn can_focus(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
|
||||
fn focus(&mut self, f: bool) {
|
||||
self.focused = f;
|
||||
}
|
||||
}
|
||||
483
iota-cli/src/elements/log_card.rs
Executable file
483
iota-cli/src/elements/log_card.rs
Executable file
|
|
@ -0,0 +1,483 @@
|
|||
use crate::APP_STATE;
|
||||
use crate::gui::elements::elements::{Element, InteractableElement, JoinableElement};
|
||||
use crate::gui::interaction_result::InteractionResult;
|
||||
use crate::gui::util::borders::draw_block_joins;
|
||||
use crate::util::logger::PrintType;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use std::any::Any;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UiLogEntry {
|
||||
pub timestamp_ms: u128,
|
||||
pub sender: PrintType,
|
||||
pub message: String,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
impl UiLogEntry {
|
||||
pub fn format_timestamp(&self) -> String {
|
||||
let secs = (self.timestamp_ms / 1000) as i64;
|
||||
let hours = (secs / 3600) % 24;
|
||||
let minutes = (secs / 60) % 60;
|
||||
let seconds = secs % 60;
|
||||
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LogEntry> for UiLogEntry {
|
||||
fn from(entry: LogEntry) -> Self {
|
||||
Self {
|
||||
timestamp_ms: entry.timestamp_ms,
|
||||
sender: entry.sender,
|
||||
message: entry.message,
|
||||
is_error: entry.is_error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp_ms: u128,
|
||||
pub sender: PrintType,
|
||||
pub message: String,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
impl LogEntry {
|
||||
pub fn new(sender: PrintType, message: String, is_error: bool) -> Self {
|
||||
Self {
|
||||
timestamp_ms: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis(),
|
||||
sender,
|
||||
message,
|
||||
is_error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LogCard {
|
||||
focused: bool,
|
||||
selected: bool,
|
||||
scroll_offset: usize,
|
||||
last_total_lines: usize,
|
||||
last_visible_height: usize,
|
||||
pub borders: Borders,
|
||||
pub joins: Borders,
|
||||
}
|
||||
|
||||
impl LogCard {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
focused: false,
|
||||
selected: false,
|
||||
scroll_offset: 0,
|
||||
last_total_lines: 0,
|
||||
last_visible_height: 10,
|
||||
borders: Borders::ALL,
|
||||
joins: Borders::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_logs(&self) -> Vec<UiLogEntry> {
|
||||
let state = APP_STATE.lock().unwrap();
|
||||
state.get_logs().iter().cloned().collect()
|
||||
}
|
||||
|
||||
fn find_split_point(s: &str, max_width: usize) -> usize {
|
||||
if max_width == 0 {
|
||||
return s.len();
|
||||
}
|
||||
|
||||
let mut current_width = 0usize;
|
||||
let mut last_boundary = 0usize;
|
||||
|
||||
for (idx, ch) in s.char_indices() {
|
||||
let char_width = if ch.is_ascii() { 1 } else { 2 };
|
||||
if current_width + char_width > max_width {
|
||||
if last_boundary == 0 {
|
||||
return idx + ch.len_utf8();
|
||||
}
|
||||
return last_boundary;
|
||||
}
|
||||
current_width += char_width;
|
||||
last_boundary = idx + ch.len_utf8();
|
||||
}
|
||||
|
||||
s.len()
|
||||
}
|
||||
|
||||
fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, Color, bool)> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let timestamp = entry.format_timestamp();
|
||||
let first_prefix = "┌ ";
|
||||
let default_prefix = "│ ";
|
||||
let last_prefix = "└ ";
|
||||
|
||||
let prefix_width = 2;
|
||||
let first_line_width = available_width.saturating_sub(prefix_width + 1);
|
||||
let continuation_width = available_width.saturating_sub(prefix_width);
|
||||
|
||||
let segments: Vec<&str> = entry.message.split('\n').collect();
|
||||
|
||||
let mut raw_lines = Vec::new();
|
||||
|
||||
for segment in segments {
|
||||
let mut remaining = segment;
|
||||
if remaining.is_empty() {
|
||||
raw_lines.push(String::new());
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut is_first_part = true;
|
||||
while !remaining.is_empty() {
|
||||
let current_width = if is_first_part {
|
||||
first_line_width
|
||||
} else {
|
||||
continuation_width
|
||||
};
|
||||
|
||||
let split_point = Self::find_split_point(remaining, current_width);
|
||||
let line_content = remaining[..split_point].to_string();
|
||||
raw_lines.push(line_content);
|
||||
remaining = &remaining[split_point..];
|
||||
is_first_part = false;
|
||||
}
|
||||
}
|
||||
|
||||
if raw_lines.is_empty() {
|
||||
raw_lines.push(String::new());
|
||||
}
|
||||
|
||||
for (idx, content) in raw_lines.iter().enumerate() {
|
||||
let is_last = idx + 1 == raw_lines.len();
|
||||
let is_single = raw_lines.len() == 1;
|
||||
let is_first = idx == 0;
|
||||
let prefix = if is_first {
|
||||
first_prefix
|
||||
} else if is_last && !is_single {
|
||||
last_prefix
|
||||
} else {
|
||||
default_prefix
|
||||
};
|
||||
|
||||
let mut line = String::from(prefix);
|
||||
line.push_str(content);
|
||||
|
||||
if is_last && !timestamp.is_empty() {
|
||||
line.push(' ');
|
||||
line.push_str(×tamp);
|
||||
}
|
||||
|
||||
result.push((line, entry.sender.prefix_color(), entry.is_error));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn build_all_lines(
|
||||
&self,
|
||||
entries: Vec<UiLogEntry>,
|
||||
width: usize,
|
||||
) -> Vec<(String, Color, bool)> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let wrapped = Self::wrap_entry(&entry, width);
|
||||
lines.extend(wrapped);
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn calculate_view_window(&self, total_lines: usize, visible_height: usize) -> (usize, usize) {
|
||||
if total_lines <= visible_height {
|
||||
return (0, total_lines);
|
||||
}
|
||||
|
||||
let max_offset = total_lines - visible_height;
|
||||
let clamped_offset = self.scroll_offset.min(max_offset);
|
||||
|
||||
let end = total_lines - clamped_offset;
|
||||
let start = end.saturating_sub(visible_height);
|
||||
|
||||
(start, end)
|
||||
}
|
||||
|
||||
fn get_title_hints(&self) -> (bool, bool) {
|
||||
if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height {
|
||||
return (false, false);
|
||||
}
|
||||
|
||||
let max_offset = self.last_total_lines - self.last_visible_height;
|
||||
let can_scroll_up = self.scroll_offset < max_offset;
|
||||
let can_scroll_down = self.scroll_offset > 0;
|
||||
|
||||
(can_scroll_up, can_scroll_down)
|
||||
}
|
||||
|
||||
fn build_title(&self) -> String {
|
||||
if !self.focused {
|
||||
return "Logs".to_string();
|
||||
}
|
||||
|
||||
let (can_up, can_down) = self.get_title_hints();
|
||||
|
||||
if !can_up && !can_down {
|
||||
return "Logs".to_string();
|
||||
}
|
||||
|
||||
let nav_symbol = if self.selected { "↑" } else { "j" };
|
||||
let down_symbol = if self.selected { "↓" } else { "k" };
|
||||
|
||||
match (can_up, can_down) {
|
||||
(true, true) => format!("Logs ({} older {} newer)", nav_symbol, down_symbol),
|
||||
(true, false) => format!("Logs ({} older)", nav_symbol),
|
||||
(false, true) => format!("Logs ({} newer)", down_symbol),
|
||||
(false, false) => "Logs".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_up(&mut self) {
|
||||
let max_offset = self
|
||||
.last_total_lines
|
||||
.saturating_sub(self.last_visible_height);
|
||||
self.scroll_offset = (self.scroll_offset + 1).min(max_offset);
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
|
||||
fn split_line_prefix(line: &str) -> (&str, &str) {
|
||||
if let Some(rest) = line.strip_prefix("└ ") {
|
||||
("└ ", rest)
|
||||
} else if let Some(rest) = line.strip_prefix("│ ") {
|
||||
("│ ", rest)
|
||||
} else if let Some(rest) = line.strip_prefix("┌ ") {
|
||||
("┌ ", rest)
|
||||
} else {
|
||||
("", line)
|
||||
}
|
||||
}
|
||||
|
||||
fn split_timestamp_suffix(line: &str) -> (&str, &str) {
|
||||
if let Some(idx) = line.rfind(' ') {
|
||||
let possible_timestamp = &line[idx + 1..];
|
||||
if possible_timestamp.len() == 8
|
||||
&& possible_timestamp.as_bytes()[2] == b':'
|
||||
&& possible_timestamp.as_bytes()[5] == b':'
|
||||
{
|
||||
let (content, timestamp_with_space) = line.split_at(idx);
|
||||
return (content.trim_end(), timestamp_with_space);
|
||||
}
|
||||
}
|
||||
(line, "")
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for LogCard {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, area: Rect) {
|
||||
let entries = self.get_logs();
|
||||
|
||||
let block = Block::default()
|
||||
.title(self.build_title())
|
||||
.borders(self.borders)
|
||||
.border_style(if self.focused {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
});
|
||||
|
||||
let inner_area = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
if inner_area.width == 0 || inner_area.height == 0 {
|
||||
draw_block_joins(f, area, self.borders, self.joins);
|
||||
return;
|
||||
}
|
||||
|
||||
let all_lines = self.build_all_lines(entries, inner_area.width as usize);
|
||||
let total_lines = all_lines.len();
|
||||
let visible_height = inner_area.height as usize;
|
||||
|
||||
let (start, end) = self.calculate_view_window(total_lines, visible_height);
|
||||
let visible_lines = &all_lines[start..end];
|
||||
|
||||
let rendered_lines: Vec<Line> = visible_lines
|
||||
.iter()
|
||||
.map(|(line, prefix_color, is_error)| {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
let (prefix, rest) = Self::split_line_prefix(line);
|
||||
|
||||
if !prefix.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
prefix.to_string(),
|
||||
Style::default().fg(*prefix_color),
|
||||
));
|
||||
}
|
||||
|
||||
let (content, timestamp) = Self::split_timestamp_suffix(rest);
|
||||
let text_color = if *is_error { Color::Red } else { Color::White };
|
||||
|
||||
if !content.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
content.to_string(),
|
||||
Style::default().fg(text_color),
|
||||
));
|
||||
}
|
||||
|
||||
if !timestamp.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
timestamp.to_string(),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (idx, line) in rendered_lines.iter().enumerate() {
|
||||
let line_area = Rect {
|
||||
x: inner_area.x,
|
||||
y: inner_area.y + idx as u16,
|
||||
width: inner_area.width,
|
||||
height: 1,
|
||||
};
|
||||
f.render_widget(Paragraph::new(line.clone()), line_area);
|
||||
}
|
||||
|
||||
draw_block_joins(f, area, self.borders, self.joins);
|
||||
}
|
||||
}
|
||||
|
||||
impl JoinableElement for LogCard {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_element(&self) -> &(dyn Element + 'static) {
|
||||
&*self
|
||||
}
|
||||
|
||||
fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) {
|
||||
&mut *self
|
||||
}
|
||||
|
||||
fn set_borders(&mut self, borders: Borders) {
|
||||
self.borders = borders;
|
||||
}
|
||||
|
||||
fn set_joins(&mut self, joins: Borders) {
|
||||
self.joins = joins;
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractableElement for LogCard {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_element(&self) -> &(dyn Element + 'static) {
|
||||
&*self
|
||||
}
|
||||
|
||||
fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) {
|
||||
&mut *self
|
||||
}
|
||||
|
||||
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
|
||||
let entries = self.get_logs();
|
||||
let estimated_width = 80usize;
|
||||
let all_lines = self.build_all_lines(entries, estimated_width);
|
||||
|
||||
self.last_total_lines = all_lines.len();
|
||||
let visible_height = self.last_visible_height.max(1);
|
||||
|
||||
match key.code {
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
self.selected = !self.selected;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Char('J') => {
|
||||
let (can_up, _) = self.get_title_hints();
|
||||
if can_up {
|
||||
self.scroll_up();
|
||||
}
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Char('K') => {
|
||||
let (_, can_down) = self.get_title_hints();
|
||||
if can_down {
|
||||
self.scroll_down();
|
||||
}
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Up if self.selected => {
|
||||
let (can_up, _) = self.get_title_hints();
|
||||
if can_up {
|
||||
self.scroll_up();
|
||||
}
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Down if self.selected => {
|
||||
let (_, can_down) = self.get_title_hints();
|
||||
if can_down {
|
||||
self.scroll_down();
|
||||
}
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Home => {
|
||||
if self.last_total_lines > visible_height {
|
||||
self.scroll_offset = self.last_total_lines - visible_height;
|
||||
}
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.scroll_offset = 0;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
_ => InteractionResult::Unhandled,
|
||||
}
|
||||
}
|
||||
|
||||
fn can_focus(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
|
||||
fn focus(&mut self, f: bool) {
|
||||
self.focused = f;
|
||||
}
|
||||
}
|
||||
56
iota-cli/src/input_handler.rs
Normal file
56
iota-cli/src/input_handler.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
use crate::ui::{UI, UNIQUE};
|
||||
use crate::{RELOAD, SHUTDOWN};
|
||||
use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn setup_input_handler(ui: Arc<UI>) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if *SHUTDOWN.read().await {
|
||||
break;
|
||||
}
|
||||
|
||||
let event_result = tokio::task::spawn_blocking(|| {
|
||||
if let Ok(true) = poll(Duration::from_millis(100)) {
|
||||
read().ok().and_then(|ev| match ev {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => Some(key),
|
||||
_ => None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match event_result {
|
||||
Ok(Some(key_event)) => {
|
||||
handle_input(key_event, ui.clone()).await;
|
||||
UNIQUE.store(true, Ordering::Relaxed);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("Input task error: {}", e);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
|
||||
match (key.code, key.modifiers) {
|
||||
(crossterm::event::KeyCode::Char('q'), KeyModifiers::CONTROL)
|
||||
| (crossterm::event::KeyCode::Char('c'), KeyModifiers::CONTROL) => {
|
||||
*SHUTDOWN.write().await = true;
|
||||
}
|
||||
(crossterm::event::KeyCode::Char('r'), KeyModifiers::CONTROL) => {
|
||||
*RELOAD.write().await = true;
|
||||
*SHUTDOWN.write().await = true;
|
||||
}
|
||||
_ => {
|
||||
ui.handle_input(key).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
49
iota-cli/src/interaction_result.rs
Normal file
49
iota-cli/src/interaction_result.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use std::fmt::{Debug, Formatter};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::screens::screens::Screen;
|
||||
|
||||
#[allow(unused)]
|
||||
pub enum InteractionResult {
|
||||
CloseScreen,
|
||||
OpenScreen {
|
||||
screen: Box<dyn Screen>,
|
||||
},
|
||||
OpenFutureScreen {
|
||||
screen: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>>,
|
||||
},
|
||||
Handled,
|
||||
Unhandled,
|
||||
}
|
||||
|
||||
impl Debug for InteractionResult {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"),
|
||||
InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"),
|
||||
InteractionResult::CloseScreen => write!(f, "CloseScreen"),
|
||||
InteractionResult::Handled => write!(f, "Handled"),
|
||||
InteractionResult::Unhandled => write!(f, "Unhandled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for InteractionResult {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(
|
||||
InteractionResult::OpenScreen { screen: _ },
|
||||
InteractionResult::OpenScreen { screen: _ },
|
||||
) => true,
|
||||
(
|
||||
InteractionResult::OpenFutureScreen { screen: _ },
|
||||
InteractionResult::OpenFutureScreen { screen: _ },
|
||||
) => true,
|
||||
(InteractionResult::CloseScreen, InteractionResult::CloseScreen) => true,
|
||||
(InteractionResult::Handled, InteractionResult::Handled) => true,
|
||||
(InteractionResult::Unhandled, InteractionResult::Unhandled) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
80
iota-cli/src/langu/language_creator.rs
Normal file
80
iota-cli/src/langu/language_creator.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use crate::util::file_util::save_file;
|
||||
use json::{self, JsonError, JsonValue};
|
||||
|
||||
pub fn create_languages() -> Result<(), JsonError> {
|
||||
let mut frontend_messages = JsonValue::new_object();
|
||||
let mut omikron_messages = JsonValue::new_object();
|
||||
let mut button_texts = JsonValue::new_object();
|
||||
let mut general_texts = JsonValue::new_object();
|
||||
let mut debug_messages = JsonValue::new_object();
|
||||
|
||||
frontend_messages.insert("error", "An error occurred")?;
|
||||
// FRONTEND
|
||||
frontend_messages.insert("get_chats", "User {} is loading conversations")?;
|
||||
frontend_messages.insert("message_get", "User {} is loading messages")?;
|
||||
frontend_messages.insert("get_communities", "User {} is loading communities")?;
|
||||
frontend_messages.insert("client_connected", "Client {} connected")?;
|
||||
frontend_messages.insert("add_conversation", "User {} added {}")?;
|
||||
frontend_messages.insert("message_send", "User {} sent a message")?;
|
||||
|
||||
// OMIKRON
|
||||
omikron_messages.insert(
|
||||
"identification_response",
|
||||
"IOTA identified on Omikron, {} users!",
|
||||
)?;
|
||||
omikron_messages.insert(
|
||||
"send_message_failed",
|
||||
"Failed to send message to Omikron: {}",
|
||||
)?;
|
||||
omikron_messages.insert("connection_failed", "Failed to connect to Omikron: {}")?;
|
||||
|
||||
// BUTTONS
|
||||
button_texts.insert("exit", "Exit")?;
|
||||
|
||||
// GENERAL
|
||||
general_texts.insert("iota_id", "IOTA ID: {}-####-####-####-############")?;
|
||||
general_texts.insert("user_id", "USER ID: {}")?;
|
||||
general_texts.insert("user_ids", "USER IDS: {}")?;
|
||||
general_texts.insert("user_load_failed", "Failed to load user data")?;
|
||||
general_texts.insert("setup_completed", "Launched")?;
|
||||
general_texts.insert(
|
||||
"community_active",
|
||||
"Communities active on ws://{}:{}/community/...",
|
||||
)?;
|
||||
general_texts.insert(
|
||||
"community_start_error",
|
||||
"Failed to start community socket on port {}!",
|
||||
)?;
|
||||
general_texts.insert(
|
||||
"community_start_error_admin",
|
||||
"Failed to start community socket on port {}! Run with admin privileges",
|
||||
)?;
|
||||
// DEBUG
|
||||
debug_messages.insert("", "")?;
|
||||
save_file(
|
||||
"languages/en_INT",
|
||||
"frontend.json",
|
||||
&frontend_messages.to_string(),
|
||||
);
|
||||
save_file(
|
||||
"languages/en_INT",
|
||||
"omikron.json",
|
||||
&omikron_messages.to_string(),
|
||||
);
|
||||
save_file(
|
||||
"languages/en_INT",
|
||||
"buttons.json",
|
||||
&button_texts.to_string(),
|
||||
);
|
||||
save_file(
|
||||
"languages/en_INT",
|
||||
"debug.json",
|
||||
&debug_messages.to_string(),
|
||||
);
|
||||
save_file(
|
||||
"languages/en_INT",
|
||||
"general.json",
|
||||
&general_texts.to_string(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
105
iota-cli/src/langu/language_manager.rs
Normal file
105
iota-cli/src/langu/language_manager.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
use crate::util::file_util::{self};
|
||||
use json::parse;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LanguagePack {
|
||||
language: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub static LANGUAGE_PACK: Lazy<Mutex<LanguagePack>> =
|
||||
Lazy::new(|| Mutex::new(LanguagePack::new("en_INT")));
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_language() -> LanguagePack {
|
||||
LANGUAGE_PACK.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_languages() -> Vec<String> {
|
||||
file_util::get_children("languages")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_language(language: &str) {
|
||||
LANGUAGE_PACK.lock().unwrap().language.clear();
|
||||
LANGUAGE_PACK.lock().unwrap().load_language(language);
|
||||
}
|
||||
|
||||
pub fn from_key(key: &str) -> String {
|
||||
LANGUAGE_PACK
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_translation(key)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn format(key: &str, args: &[&str]) -> String {
|
||||
let message = from_key(key);
|
||||
let mut formatted = String::new();
|
||||
let parts = message.split("{}");
|
||||
for (i, part) in parts.enumerate() {
|
||||
formatted.push_str(part);
|
||||
if i < args.len() {
|
||||
formatted.push_str(args[i]);
|
||||
}
|
||||
}
|
||||
formatted
|
||||
}
|
||||
|
||||
impl LanguagePack {
|
||||
pub fn new(language: &str) -> Self {
|
||||
let mut pack = LanguagePack {
|
||||
language: HashMap::new(),
|
||||
};
|
||||
pack.load_language(language);
|
||||
pack
|
||||
}
|
||||
|
||||
pub fn load_language(&mut self, language: &str) {
|
||||
let path = format!("languages/{}/", language);
|
||||
|
||||
let frontend_messages = file_util::load_file(&path, "frontend.json");
|
||||
let frontend_messages = parse(&frontend_messages).unwrap();
|
||||
for (key, value) in frontend_messages.entries() {
|
||||
self.language
|
||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
||||
}
|
||||
|
||||
let omikron_messages = file_util::load_file(&path, "omikron.json");
|
||||
let omikron_messages = parse(&omikron_messages).unwrap();
|
||||
for (key, value) in omikron_messages.entries() {
|
||||
self.language
|
||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
||||
}
|
||||
|
||||
let button_texts = file_util::load_file(&path, "buttons.json");
|
||||
let button_texts = parse(&button_texts).unwrap();
|
||||
for (key, value) in button_texts.entries() {
|
||||
self.language
|
||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
||||
}
|
||||
|
||||
let debug_messages = file_util::load_file(&path, "debug.json");
|
||||
let debug_messages = parse(&debug_messages).unwrap();
|
||||
for (key, value) in debug_messages.entries() {
|
||||
self.language
|
||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
||||
}
|
||||
|
||||
let general_messages = file_util::load_file(&path, "general.json");
|
||||
let general_messages = parse(&general_messages).unwrap();
|
||||
for (key, value) in general_messages.entries() {
|
||||
self.language
|
||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
||||
}
|
||||
}
|
||||
pub fn get_translation(&self, key: &str) -> String {
|
||||
match self.language.get(key) {
|
||||
Some(v) if !v.is_empty() => v.clone(),
|
||||
_ => key.to_uppercase(),
|
||||
}
|
||||
}
|
||||
}
|
||||
2
iota-cli/src/langu/mod.rs
Normal file
2
iota-cli/src/langu/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod language_creator;
|
||||
pub mod language_manager;
|
||||
20
iota-cli/src/lib.rs
Normal file
20
iota-cli/src/lib.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
pub mod elements {
|
||||
pub mod console_card;
|
||||
pub mod elements;
|
||||
pub mod graph_card;
|
||||
pub mod log_card;
|
||||
}
|
||||
pub mod screens {
|
||||
pub mod main_screen;
|
||||
pub mod md_viewer;
|
||||
pub mod screens;
|
||||
pub mod terms_checker;
|
||||
pub mod terms_updater;
|
||||
}
|
||||
pub mod util {
|
||||
pub mod borders;
|
||||
}
|
||||
pub mod app_state;
|
||||
pub mod input_handler;
|
||||
pub mod interaction_result;
|
||||
pub mod ui;
|
||||
242
iota-cli/src/screens/main_screen.rs
Normal file
242
iota-cli/src/screens/main_screen.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
use crate::{
|
||||
elements::{
|
||||
console_card::ConsoleCard,
|
||||
elements::{InteractableElement, JoinableElement},
|
||||
graph_card::{GRAPHS, GraphCard},
|
||||
log_card::LogCard,
|
||||
},
|
||||
interaction_result::InteractionResult,
|
||||
screens::screens::{NavDirection, Screen},
|
||||
ui::UI,
|
||||
};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Layout, Margin, Rect},
|
||||
widgets::{Block, Borders},
|
||||
};
|
||||
|
||||
use std::{any::Any, sync::Arc};
|
||||
|
||||
pub struct MainScreen {
|
||||
elements: Vec<Box<dyn InteractableElement>>,
|
||||
nav_grid: Vec<Vec<Option<usize>>>,
|
||||
selected_coords: (usize, usize),
|
||||
graphs_open: bool,
|
||||
}
|
||||
|
||||
impl MainScreen {
|
||||
pub async fn new(ui: Arc<UI>) -> Self {
|
||||
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
|
||||
|
||||
let nav_grid = vec![
|
||||
vec![Some(0), Some(2)],
|
||||
vec![Some(0), Some(3)],
|
||||
vec![Some(1), Some(4)],
|
||||
];
|
||||
|
||||
let mut log_card = LogCard::new();
|
||||
log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT));
|
||||
let mut console_card = ConsoleCard::new("Console", "");
|
||||
console_card.set_joins(Borders::TOP);
|
||||
|
||||
elements.push(Box::new(log_card));
|
||||
elements.push(Box::new(console_card));
|
||||
|
||||
let mut ram_graph = GraphCard::new(ui.clone(), GRAPHS::Ram, "RAM".into());
|
||||
ram_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
|
||||
elements.push(Box::new(ram_graph));
|
||||
let mut cpu_graph = GraphCard::new(ui.clone(), GRAPHS::Cpu, "CPU".into());
|
||||
cpu_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
|
||||
cpu_graph.set_joins(Borders::TOP);
|
||||
elements.push(Box::new(cpu_graph));
|
||||
let mut ping_graph = GraphCard::new(ui.clone(), GRAPHS::Ping, "Ping".into());
|
||||
ping_graph.set_joins(Borders::TOP);
|
||||
elements.push(Box::new(ping_graph));
|
||||
|
||||
let graphs_open = true;
|
||||
|
||||
let mut screen = MainScreen {
|
||||
elements,
|
||||
nav_grid,
|
||||
selected_coords: (1, 0),
|
||||
graphs_open,
|
||||
};
|
||||
screen.focus_current();
|
||||
screen
|
||||
}
|
||||
|
||||
fn focus_current(&mut self) {
|
||||
let (y, x) = self.selected_coords;
|
||||
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) {
|
||||
if let Some(element) = self.elements.get_mut(*index) {
|
||||
if element.can_focus() {
|
||||
element.focus(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unfocus_current(&mut self, y: usize, x: usize) {
|
||||
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) {
|
||||
if let Some(element) = self.elements.get_mut(*index) {
|
||||
element.focus(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate(&mut self, direction: NavDirection) {
|
||||
let (current_row, current_col) = self.selected_coords;
|
||||
let current_element = self.nav_grid[current_row][current_col];
|
||||
|
||||
self.unfocus_current(current_row, current_col);
|
||||
|
||||
let (delta_row, delta_col) = match direction {
|
||||
NavDirection::Up => (-1isize, 0),
|
||||
NavDirection::Down => (1, 0),
|
||||
NavDirection::Left => (0, -1),
|
||||
NavDirection::Right => (0, 1),
|
||||
_ => (0, 0),
|
||||
};
|
||||
|
||||
let mut next_row = current_row as isize;
|
||||
let mut next_col = current_col as isize;
|
||||
|
||||
loop {
|
||||
next_row += delta_row;
|
||||
next_col += delta_col;
|
||||
|
||||
if next_row < 0 || next_col < 0 {
|
||||
self.selected_coords = (
|
||||
(next_row - delta_row) as usize,
|
||||
(next_col - delta_col) as usize,
|
||||
);
|
||||
break;
|
||||
}
|
||||
let next_row_u = next_row as usize;
|
||||
let next_col_u = next_col as usize;
|
||||
|
||||
if next_row_u >= self.nav_grid.len() {
|
||||
self.selected_coords = (
|
||||
(next_row - delta_row) as usize,
|
||||
(next_col - delta_col) as usize,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(row) = self.nav_grid.get(next_row_u) {
|
||||
if next_col_u >= row.len() {
|
||||
self.selected_coords = (
|
||||
(next_row - delta_row) as usize,
|
||||
(next_col - delta_col) as usize,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(next_element) = row[next_col_u] {
|
||||
if Some(next_element) != current_element {
|
||||
self.selected_coords = (next_row_u, next_col_u);
|
||||
self.focus_current();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.focus_current();
|
||||
}
|
||||
}
|
||||
|
||||
impl Screen for MainScreen {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, rect: Rect) {
|
||||
let main_block = Block::default().title("Main").borders(Borders::ALL);
|
||||
f.render_widget(main_block, rect);
|
||||
|
||||
let inner = rect.inner(Margin {
|
||||
vertical: 1,
|
||||
horizontal: 1,
|
||||
});
|
||||
|
||||
let graphs_width = if self.graphs_open { 30 } else { 2 };
|
||||
let main_width = inner.width.saturating_sub(graphs_width);
|
||||
|
||||
let horizontal_chunks = Layout::default()
|
||||
.direction(ratatui::layout::Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(main_width),
|
||||
Constraint::Length(graphs_width),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let left_area = horizontal_chunks[0];
|
||||
let right_area = horizontal_chunks[1];
|
||||
|
||||
let left_rows =
|
||||
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area);
|
||||
|
||||
if let Some(log) = self.elements.get(0) {
|
||||
log.as_element().render(f, left_rows[0]);
|
||||
}
|
||||
|
||||
if let Some(console) = self.elements.get(1) {
|
||||
console.as_element().render(f, left_rows[1]);
|
||||
}
|
||||
|
||||
let graph_elements: Vec<_> = self
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|el| el.as_any().is::<GraphCard>())
|
||||
.collect();
|
||||
|
||||
if !graph_elements.is_empty() {
|
||||
let graph_chunks = Layout::vertical(
|
||||
graph_elements
|
||||
.iter()
|
||||
.map(|_| Constraint::Ratio(1, graph_elements.len() as u32))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.split(right_area);
|
||||
|
||||
for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) {
|
||||
el.as_element().render(f, *area);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
||||
match event.code {
|
||||
KeyCode::Up => self.navigate(NavDirection::Up),
|
||||
KeyCode::Down => self.navigate(NavDirection::Down),
|
||||
KeyCode::Left => self.navigate(NavDirection::Left),
|
||||
KeyCode::Right => self.navigate(NavDirection::Right),
|
||||
KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => {
|
||||
self.graphs_open = !self.graphs_open;
|
||||
for element in self.elements.iter_mut() {
|
||||
if let Some(graph) = element.as_any_mut().downcast_mut::<GraphCard>() {
|
||||
graph.set_open(self.graphs_open);
|
||||
}
|
||||
}
|
||||
return InteractionResult::Handled;
|
||||
}
|
||||
_ => {
|
||||
let (y, x) = self.selected_coords;
|
||||
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|r| r.get(x)) {
|
||||
if let Some(el) = self.elements.get_mut(*index) {
|
||||
return el.interact(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InteractionResult::Handled
|
||||
}
|
||||
}
|
||||
493
iota-cli/src/screens/md_viewer.rs
Normal file
493
iota-cli/src/screens/md_viewer.rs
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
use crossterm::event::{self, Event, KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
DefaultTerminal,
|
||||
prelude::*,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
};
|
||||
use std::{any::Any, time::Duration};
|
||||
|
||||
use crate::{interaction_result::InteractionResult, screens::screens::Screen};
|
||||
|
||||
pub struct FileViewer {
|
||||
title: String,
|
||||
text: Vec<DisplayLine>,
|
||||
scroll: u16,
|
||||
scroll_x: u16,
|
||||
}
|
||||
|
||||
impl Screen for FileViewer {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, rect: Rect) {
|
||||
self.draw(f, rect);
|
||||
}
|
||||
|
||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
||||
match event.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => {
|
||||
return InteractionResult::CloseScreen;
|
||||
}
|
||||
|
||||
KeyCode::Down => self.scroll = self.scroll.saturating_add(1),
|
||||
KeyCode::Up => self.scroll = self.scroll.saturating_sub(1),
|
||||
KeyCode::PageDown => self.scroll = self.scroll.saturating_add(10),
|
||||
KeyCode::PageUp => self.scroll = self.scroll.saturating_sub(10),
|
||||
KeyCode::Right => self.scroll_x = self.scroll_x.saturating_add(2),
|
||||
KeyCode::Left => self.scroll_x = self.scroll_x.saturating_sub(2),
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
InteractionResult::Unhandled
|
||||
}
|
||||
}
|
||||
|
||||
impl FileViewer {
|
||||
pub fn new(title: String, content: &str) -> Self {
|
||||
Self {
|
||||
title,
|
||||
text: parse_document(content.to_owned()),
|
||||
scroll: 0,
|
||||
scroll_x: 0,
|
||||
}
|
||||
}
|
||||
pub fn force_popup(mut self, mut terminal: DefaultTerminal) -> DefaultTerminal {
|
||||
loop {
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = f.area();
|
||||
self.draw(f, area);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
if event::poll(Duration::from_millis(100)).unwrap() {
|
||||
let ev = event::read().unwrap();
|
||||
self.handle_event(&ev);
|
||||
|
||||
if matches!(ev, Event::Key(k) if k.code == KeyCode::Char('q')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
terminal
|
||||
}
|
||||
fn draw(&self, f: &mut Frame, area: Rect) {
|
||||
use ratatui::text::Text;
|
||||
|
||||
let mut rendered_lines = Vec::new();
|
||||
|
||||
for display_line in &self.text {
|
||||
if display_line.scrollable {
|
||||
let content: String = display_line
|
||||
.line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect();
|
||||
|
||||
let start = self.scroll_x as usize;
|
||||
let width = area.width as usize - 2;
|
||||
|
||||
let visible = if start < content.chars().count() {
|
||||
content.chars().skip(start).take(width).collect()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut chars: Vec<char> = visible.chars().collect();
|
||||
|
||||
if start > 0 && !chars.is_empty() {
|
||||
chars[0] = '<';
|
||||
}
|
||||
|
||||
if start + width < content.chars().count() && !chars.is_empty() {
|
||||
let last = chars.len() - 1;
|
||||
chars[last] = '>';
|
||||
}
|
||||
|
||||
let visible: String = chars.into_iter().collect();
|
||||
|
||||
rendered_lines.push(Line::from(Span::styled(
|
||||
visible,
|
||||
display_line
|
||||
.line
|
||||
.spans
|
||||
.first()
|
||||
.map(|s| s.style)
|
||||
.unwrap_or_default(),
|
||||
)));
|
||||
} else {
|
||||
rendered_lines.push(display_line.line.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(Text::from(rendered_lines))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!("{} - [Q to close]", self.title.as_str(),)),
|
||||
)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((self.scroll, 0));
|
||||
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
pub fn handle_event(&mut self, event: &Event) {
|
||||
if let Event::Key(key) = event {
|
||||
match key.code {
|
||||
KeyCode::Down => self.scroll = self.scroll.saturating_add(1),
|
||||
KeyCode::Up => self.scroll = self.scroll.saturating_sub(1),
|
||||
KeyCode::PageDown => self.scroll = self.scroll.saturating_add(10),
|
||||
KeyCode::PageUp => self.scroll = self.scroll.saturating_sub(10),
|
||||
KeyCode::Right => self.scroll_x = self.scroll_x.saturating_add(2),
|
||||
KeyCode::Left => self.scroll_x = self.scroll_x.saturating_sub(2),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn parse_document(input: String) -> Vec<DisplayLine> {
|
||||
let mut lines_vec = Vec::new();
|
||||
let mut in_code_block = false;
|
||||
let liness: Vec<String> = input.lines().map(String::from).collect();
|
||||
let mut i = 0;
|
||||
|
||||
while i < liness.len() {
|
||||
let raw = &liness[i];
|
||||
|
||||
if raw.trim().starts_with("```") {
|
||||
in_code_block = !in_code_block;
|
||||
let code: String = if raw.trim().replace("```", "").is_empty() {
|
||||
"──".to_string()
|
||||
} else {
|
||||
raw.trim().replace("```", "")
|
||||
};
|
||||
lines_vec.push(DisplayLine {
|
||||
line: Line::from(Span::styled(
|
||||
format!("────────{}────────", code),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
scrollable: false,
|
||||
});
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_code_block {
|
||||
lines_vec.push(DisplayLine {
|
||||
line: Line::from(Span::styled(
|
||||
raw.to_string(),
|
||||
Style::default().fg(Color::Yellow),
|
||||
)),
|
||||
scrollable: false,
|
||||
});
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if raw.starts_with("### ") {
|
||||
lines_vec.push(DisplayLine {
|
||||
line: Line::from(Span::styled(
|
||||
raw.trim_start_matches("### ").to_string(),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
scrollable: false,
|
||||
});
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if raw.starts_with("## ") {
|
||||
lines_vec.push(DisplayLine {
|
||||
line: Line::from(Span::styled(
|
||||
raw.trim_start_matches("## ").to_string(),
|
||||
Style::default()
|
||||
.fg(Color::LightCyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
scrollable: false,
|
||||
});
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if raw.starts_with("# ") {
|
||||
lines_vec.push(DisplayLine {
|
||||
line: Line::from(Span::styled(
|
||||
raw.trim_start_matches("# ").to_string(),
|
||||
Style::default()
|
||||
.fg(Color::Gray)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
scrollable: false,
|
||||
});
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if raw.trim_start().starts_with("- ") {
|
||||
let indent = raw.chars().take_while(|c| *c == ' ').count();
|
||||
lines_vec.push(DisplayLine {
|
||||
line: Line::from(Span::raw(format!(
|
||||
"{}• {}",
|
||||
" ".repeat(indent),
|
||||
raw.trim_start_matches("- ")
|
||||
))),
|
||||
scrollable: false,
|
||||
});
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if raw.trim().starts_with('|') && raw.contains('|') {
|
||||
let mut table_lines = vec![raw.clone()];
|
||||
let mut j = i + 1;
|
||||
while j < liness.len() && liness[j].trim().starts_with('|') {
|
||||
table_lines.push(liness[j].clone());
|
||||
j += 1;
|
||||
}
|
||||
|
||||
let table = parse_table(&table_lines.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
lines_vec.extend(table_to_lines(table));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
lines_vec.push(DisplayLine {
|
||||
line: Line::from(parse_inline(raw.as_str())),
|
||||
scrollable: false,
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
|
||||
lines_vec
|
||||
}
|
||||
|
||||
fn parse_inline(input: &str) -> Vec<Span<'static>> {
|
||||
let mut spans = Vec::new();
|
||||
let mut buf = String::new();
|
||||
|
||||
let mut bold = false;
|
||||
let mut underline = false;
|
||||
let mut code = false;
|
||||
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
let toggle = match c {
|
||||
'*' if chars.peek() == Some(&'*') => {
|
||||
chars.next();
|
||||
Some("bold")
|
||||
}
|
||||
'_' if chars.peek() == Some(&'_') => {
|
||||
chars.next();
|
||||
Some("underline")
|
||||
}
|
||||
'`' => Some("code"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(kind) = toggle {
|
||||
flush_span(&mut spans, &mut buf, current_style(bold, underline, code));
|
||||
|
||||
match kind {
|
||||
"bold" => bold = !bold,
|
||||
"underline" => underline = !underline,
|
||||
"code" => code = !code,
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
buf.push(c);
|
||||
}
|
||||
|
||||
flush_span(&mut spans, &mut buf, current_style(bold, underline, code));
|
||||
spans
|
||||
}
|
||||
|
||||
fn current_style(bold: bool, underline: bool, code: bool) -> Style {
|
||||
let mut style = Style::default();
|
||||
|
||||
if bold {
|
||||
style = style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if underline {
|
||||
style = style.add_modifier(Modifier::UNDERLINED);
|
||||
}
|
||||
if code {
|
||||
style = style.fg(Color::Yellow);
|
||||
}
|
||||
|
||||
style
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct DisplayLine {
|
||||
line: Line<'static>,
|
||||
scrollable: bool,
|
||||
}
|
||||
|
||||
fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
|
||||
if table.len() < 2 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let header = &table[0];
|
||||
let mut column_heights = vec![0; table[0].len()];
|
||||
|
||||
for row in table.iter().skip(1) {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
let lines = cell.lines().count().max(1);
|
||||
column_heights[i] += lines;
|
||||
}
|
||||
}
|
||||
|
||||
let widths: Vec<usize> = header
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, h)| {
|
||||
let h_len = h.chars().count().max(1);
|
||||
if i == 0 {
|
||||
table
|
||||
.iter()
|
||||
.map(|row| row.get(i).map(|c| c.chars().count()).unwrap_or(0))
|
||||
.max()
|
||||
.unwrap_or(h_len)
|
||||
} else {
|
||||
let max = (3 * h_len) as usize;
|
||||
max.max(h_len)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for (row_idx, row) in table.iter().enumerate() {
|
||||
if row_idx == 1 {
|
||||
let divider = widths
|
||||
.iter()
|
||||
.map(|w| "─".repeat(*w))
|
||||
.collect::<Vec<_>>()
|
||||
.join("─┼─");
|
||||
|
||||
lines.push(DisplayLine {
|
||||
line: Line::from(Span::styled(divider, Style::default().fg(Color::DarkGray))),
|
||||
scrollable: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let wrapped_cells: Vec<Vec<String>> = row
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, cell)| wrap_cell(cell, widths[i]))
|
||||
.collect();
|
||||
|
||||
let row_height = wrapped_cells.iter().map(|c| c.len()).max().unwrap_or(1);
|
||||
|
||||
for line_idx in 0..row_height {
|
||||
let mut line = String::new();
|
||||
|
||||
for (i, cell) in wrapped_cells.iter().enumerate() {
|
||||
let content = cell.get(line_idx).map(String::as_str).unwrap_or("");
|
||||
line.push_str(&format!("{:width$}", content, width = widths[i]));
|
||||
if i < wrapped_cells.len() - 1 {
|
||||
line.push_str(" │ ");
|
||||
}
|
||||
}
|
||||
|
||||
let style = if row_idx == 0 {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
};
|
||||
|
||||
lines.push(DisplayLine {
|
||||
line: Line::from(Span::styled(line, style)),
|
||||
scrollable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
fn wrap_cell(cell: &str, width: usize) -> Vec<String> {
|
||||
if width == 0 {
|
||||
return vec![String::new()];
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for word in cell.split_whitespace() {
|
||||
let word_len = word.chars().count();
|
||||
let current_len = current.chars().count();
|
||||
|
||||
if current_len == 0 {
|
||||
if word_len <= width {
|
||||
current.push_str(word);
|
||||
} else {
|
||||
for chunk in word.chars().collect::<Vec<_>>().chunks(width) {
|
||||
lines.push(chunk.iter().collect());
|
||||
}
|
||||
}
|
||||
} else if current_len + 1 + word_len <= width {
|
||||
current.push(' ');
|
||||
current.push_str(word);
|
||||
} else {
|
||||
lines.push(current);
|
||||
current = String::new();
|
||||
|
||||
if word_len <= width {
|
||||
current.push_str(word);
|
||||
} else {
|
||||
for chunk in word.chars().collect::<Vec<_>>().chunks(width) {
|
||||
lines.push(chunk.iter().collect());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
lines.push(current);
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn flush_span(spans: &mut Vec<Span>, buf: &mut String, style: Style) {
|
||||
if !buf.is_empty() {
|
||||
spans.push(Span::styled(buf.clone(), style));
|
||||
buf.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_table(lines: &[&str]) -> Vec<Vec<String>> {
|
||||
let mut table = Vec::new();
|
||||
|
||||
for &line in lines {
|
||||
if !line.starts_with('|') || !line.contains('|') {
|
||||
break;
|
||||
}
|
||||
let row: Vec<String> = line
|
||||
.trim_matches('|')
|
||||
.split('|')
|
||||
.map(|s| s.trim().to_string())
|
||||
.collect();
|
||||
table.push(row);
|
||||
}
|
||||
|
||||
table
|
||||
}
|
||||
25
iota-cli/src/screens/screens.rs
Normal file
25
iota-cli/src/screens/screens.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
use std::any::Any;
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::{Frame, layout::Rect};
|
||||
|
||||
use crate::interaction_result::InteractionResult;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NavDirection {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
|
||||
Next,
|
||||
Prev,
|
||||
}
|
||||
|
||||
pub trait Screen: Send + Sync + Any {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
|
||||
fn render(&self, f: &mut Frame, rect: Rect);
|
||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult;
|
||||
}
|
||||
343
iota-cli/src/screens/terms_checker.rs
Normal file
343
iota-cli/src/screens/terms_checker.rs
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
use crate::{
|
||||
interaction_result::InteractionResult,
|
||||
screens::{md_viewer::FileViewer, screens::Screen},
|
||||
ui::UI,
|
||||
};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use std::{any::Any, pin::Pin, sync::Arc};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
pub struct TermsCheckerScreen {
|
||||
ui: Arc<UI>,
|
||||
sender: Option<oneshot::Sender<UserChoice>>,
|
||||
|
||||
eula: bool,
|
||||
tos: bool,
|
||||
pp: bool,
|
||||
|
||||
focus: Focus,
|
||||
}
|
||||
|
||||
impl TermsCheckerScreen {
|
||||
pub fn new(ui: Arc<UI>, sender: Option<oneshot::Sender<UserChoice>>) -> Self {
|
||||
Self {
|
||||
ui,
|
||||
sender,
|
||||
eula: false,
|
||||
tos: false,
|
||||
pp: false,
|
||||
focus: Focus::Eula,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Screen for TermsCheckerScreen {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, size: Rect) {
|
||||
let mut needed_height = 5;
|
||||
|
||||
if size.height < 6 || size.width < 27 {
|
||||
f.render_widget(
|
||||
Line::from(format!("too small {}/63 by {}/13", size.width, size.height)),
|
||||
size,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let max_width = 150;
|
||||
let max_height = 26;
|
||||
|
||||
let content_width = if max_width < size.width {
|
||||
max_width
|
||||
} else {
|
||||
size.width
|
||||
};
|
||||
let content_height = if max_height < size.height {
|
||||
max_height
|
||||
} else {
|
||||
size.height
|
||||
};
|
||||
|
||||
let horizontal_margin = (size.width.saturating_sub(content_width)) / 2;
|
||||
let vertical_margin = (size.height.saturating_sub(content_height)) / 2;
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(7), Constraint::Length(3)])
|
||||
.split(Rect {
|
||||
x: horizontal_margin,
|
||||
y: vertical_margin,
|
||||
width: content_width,
|
||||
height: content_height,
|
||||
});
|
||||
let eula_text = if size.width < 70 {
|
||||
"EULA ¹ (https://legal.tensamin.net/eula/)"
|
||||
} else {
|
||||
"End User Licence Agreement ¹ (https://legal.tensamin.net/eula/)"
|
||||
};
|
||||
let tos_text = if size.width < 72 {
|
||||
"ToS ² (https://legal.tensamin.net/terms-of-service/)"
|
||||
} else {
|
||||
"Terms of Service ² (https://legal.tensamin.net/terms-of-service/)"
|
||||
};
|
||||
let pp_text = if size.width < 68 {
|
||||
"PP ² (https://legal.tensamin.net/privacy-policy/)"
|
||||
} else {
|
||||
"Privacy Policy ² (https://legal.tensamin.net/privacy-policy/)"
|
||||
};
|
||||
|
||||
let (mut optional_lines, agree_lines): (Vec<i16>, Vec<&str>) = if size.width > 143 {
|
||||
(
|
||||
vec![8, 3, 8, 5],
|
||||
vec![
|
||||
"",
|
||||
"By selecting Continue, you confirm that you agree to the End User License Agreement and applicable Terms of Service.",
|
||||
"",
|
||||
"Tensamin services require acceptance of the Terms of Service and Privacy Policy.",
|
||||
"",
|
||||
"While having a document selected press O to view in this UI or press L to open as a link.",
|
||||
],
|
||||
)
|
||||
} else if size.width > 92 {
|
||||
(
|
||||
vec![9, 3, 9, 5],
|
||||
vec![
|
||||
"",
|
||||
"By selecting Continue, you confirm that you agree to the End User License",
|
||||
"Agreement and applicable Terms of Service.",
|
||||
"",
|
||||
"Tensamin services require acceptance of the Terms of Service and Privacy Policy.",
|
||||
"",
|
||||
"While having a document selected press O to view in this UI or press L to open as a link.",
|
||||
],
|
||||
)
|
||||
} else if size.width > 73 {
|
||||
(
|
||||
vec![9, 3, 9, 5],
|
||||
vec![
|
||||
"",
|
||||
"By selecting Continue, you confirm that you agree to the",
|
||||
"End User License Agreement and applicable Terms of Service.",
|
||||
"",
|
||||
"Tensamin services require acceptance of the ToS and Privacy Policy.",
|
||||
"",
|
||||
"While having a document selected press O to view in this UI or press L",
|
||||
"to open as a link.",
|
||||
],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
vec![10, 3, 11, 5],
|
||||
vec![
|
||||
"",
|
||||
"By selecting Continue, you confirm that you agree",
|
||||
"to the End User License Agreement and",
|
||||
"applicable Terms of Service.",
|
||||
"",
|
||||
"Tensamin services require acceptance of the",
|
||||
"Terms of Service and Privacy Policy.",
|
||||
"",
|
||||
"While having a document selected press O to view",
|
||||
"in this UI or press L to open as a link.",
|
||||
],
|
||||
)
|
||||
};
|
||||
let mut text_lines = vec![
|
||||
checkbox(eula_text, self.eula, self.focus == Focus::Eula, true),
|
||||
checkbox(tos_text, self.tos, self.focus == Focus::Tos, self.eula),
|
||||
checkbox(pp_text, self.pp, self.focus == Focus::Pp, self.eula),
|
||||
Line::from(""),
|
||||
Line::from("¹ Necessary– required to run the program"),
|
||||
Line::from("² Optional – required only for Tensamin services"),
|
||||
];
|
||||
for line in agree_lines {
|
||||
text_lines.insert(text_lines.len(), Line::from(line));
|
||||
}
|
||||
|
||||
while size.height - 5 < text_lines.len() as u16 {
|
||||
if optional_lines.len() == 0 {
|
||||
break;
|
||||
}
|
||||
text_lines.remove(optional_lines[0] as usize);
|
||||
optional_lines.remove(0);
|
||||
}
|
||||
needed_height += text_lines.len();
|
||||
|
||||
if size.width < 60 || size.height < needed_height as u16 {
|
||||
let width_style = if size.width > 76 {
|
||||
Style::default().fg(Color::Green)
|
||||
} else if size.width >= 60 {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
};
|
||||
|
||||
let height_style = if size.height > 19 {
|
||||
Style::default().fg(Color::Green)
|
||||
} else if size.height >= 13 {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
};
|
||||
|
||||
let warning_text = Text::from(vec![
|
||||
Line::from(vec![
|
||||
Span::raw("Width: "),
|
||||
Span::styled(format!("{}", size.width), width_style),
|
||||
Span::raw(" / 60"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw("Height: "),
|
||||
Span::styled(format!("{}", size.height), height_style),
|
||||
Span::raw(format!(" / 13")),
|
||||
]),
|
||||
]);
|
||||
|
||||
let warning = Paragraph::new(warning_text)
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!("UI Too Small [Q to Quit]")),
|
||||
);
|
||||
f.render_widget(warning, size);
|
||||
return;
|
||||
}
|
||||
|
||||
let consent_block = Paragraph::new(Text::from(text_lines)).block(
|
||||
Block::default()
|
||||
.title(format!(" Tensamin User Consent [Q to Quit] ",))
|
||||
.borders(Borders::ALL),
|
||||
);
|
||||
f.render_widget(consent_block, chunks[0]);
|
||||
|
||||
draw_buttons(
|
||||
f,
|
||||
chunks[1],
|
||||
self.focus,
|
||||
(self.eula, self.tos && self.pp),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
||||
let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel];
|
||||
|
||||
if self.eula {
|
||||
possible_states.push(Focus::Continue);
|
||||
if self.tos && self.pp {
|
||||
possible_states.push(Focus::ContinueAll);
|
||||
}
|
||||
}
|
||||
|
||||
match event.code {
|
||||
KeyCode::Esc => {
|
||||
if let Some(sender) = self.sender.take() {
|
||||
let _ = sender.send(UserChoice::Deny);
|
||||
}
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
KeyCode::Up | KeyCode::Left => {
|
||||
self.focus.prev(&possible_states);
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
|
||||
self.focus.next(&possible_states);
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Char('q') | KeyCode::Char('Q') => {
|
||||
if let Some(sender) = self.sender.take() {
|
||||
let _ = sender.send(UserChoice::Deny);
|
||||
}
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
KeyCode::Char('o') | KeyCode::Char('O') => {
|
||||
let terms_type = match self.focus {
|
||||
Focus::Eula => Some(Type::EULA),
|
||||
Focus::Tos => Some(Type::TOS),
|
||||
Focus::Pp => Some(Type::PP),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(terms_type) = terms_type {
|
||||
let fut: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>> =
|
||||
Box::pin(async move {
|
||||
let content = get_terms(terms_type.clone()).await.unwrap();
|
||||
let screen: FileViewer =
|
||||
FileViewer::new(terms_type.to_string(), &content);
|
||||
Box::new(screen) as Box<dyn Screen>
|
||||
});
|
||||
InteractionResult::OpenFutureScreen { screen: fut }
|
||||
} else {
|
||||
InteractionResult::Unhandled
|
||||
}
|
||||
}
|
||||
KeyCode::Char('l') | KeyCode::Char('L') => match self.focus {
|
||||
Focus::Eula => {
|
||||
let _ = open::that(get_link(Type::EULA));
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Tos => {
|
||||
let _ = open::that(get_link(Type::TOS));
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Pp => {
|
||||
let _ = open::that(get_link(Type::PP));
|
||||
InteractionResult::Handled
|
||||
}
|
||||
_ => InteractionResult::Unhandled,
|
||||
},
|
||||
KeyCode::Enter | KeyCode::Char(' ') => match self.focus {
|
||||
Focus::Eula => {
|
||||
self.eula = !self.eula;
|
||||
self.tos = false;
|
||||
self.pp = false;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Tos if self.eula => {
|
||||
self.tos = !self.tos;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Pp if self.eula => {
|
||||
self.pp = !self.pp;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Cancel => {
|
||||
if let Some(sender) = self.sender.take() {
|
||||
let _ = sender.send(UserChoice::Deny);
|
||||
}
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
Focus::Continue if self.eula => {
|
||||
if let Some(sender) = self.sender.take() {
|
||||
let _ = sender.send(UserChoice::AcceptEULA);
|
||||
}
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
Focus::ContinueAll if self.eula && self.tos && self.pp => {
|
||||
if let Some(sender) = self.sender.take() {
|
||||
let _ = sender.send(UserChoice::AcceptAll);
|
||||
}
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
_ => InteractionResult::Unhandled,
|
||||
},
|
||||
|
||||
_ => InteractionResult::Unhandled,
|
||||
}
|
||||
}
|
||||
}
|
||||
682
iota-cli/src/screens/terms_updater.rs
Normal file
682
iota-cli/src/screens/terms_updater.rs
Normal file
|
|
@ -0,0 +1,682 @@
|
|||
use crate::{
|
||||
interaction_result::InteractionResult,
|
||||
screens::{md_viewer::FileViewer, screens::Screen},
|
||||
};
|
||||
use chrono::{Local, TimeZone, Utc};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use std::any::Any;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
pub struct TermsUpdaterScreen {
|
||||
sender: Option<oneshot::Sender<UserChoice>>,
|
||||
|
||||
eula_needed: bool,
|
||||
tos_needed: bool,
|
||||
pp_needed: bool,
|
||||
|
||||
eula_future: bool,
|
||||
tos_future: bool,
|
||||
pp_future: bool,
|
||||
|
||||
eula_for_future: Option<Doc>,
|
||||
tos_for_future: Option<Doc>,
|
||||
pp_for_future: Option<Doc>,
|
||||
|
||||
update_needed: bool,
|
||||
|
||||
eula: bool,
|
||||
tos: bool,
|
||||
pp: bool,
|
||||
|
||||
focus: Focus,
|
||||
}
|
||||
impl TermsUpdaterScreen {
|
||||
pub fn new(
|
||||
consent_eula: UpdateDecision,
|
||||
consent_tos: UpdateDecision,
|
||||
consent_pp: UpdateDecision,
|
||||
sender: Option<oneshot::Sender<UserChoice>>,
|
||||
) -> Self {
|
||||
let (eula_needed, eula_future, eula_for_future): (bool, bool, Option<Doc>) =
|
||||
match consent_eula {
|
||||
UpdateDecision::NoChange => (false, false, None),
|
||||
UpdateDecision::Future { newest } => (true, true, Some(newest)),
|
||||
UpdateDecision::Forced(doc) => (true, false, Some(doc)),
|
||||
};
|
||||
let (tos_needed, tos_future, tos_for_future): (bool, bool, Option<Doc>) = match consent_tos
|
||||
{
|
||||
UpdateDecision::NoChange => (false, false, None),
|
||||
UpdateDecision::Future { newest } => (true, true, Some(newest)),
|
||||
UpdateDecision::Forced(doc) => (true, false, Some(doc)),
|
||||
};
|
||||
let (pp_needed, pp_future, pp_for_future): (bool, bool, Option<Doc>) = match consent_pp {
|
||||
UpdateDecision::NoChange => (false, false, None),
|
||||
UpdateDecision::Future { newest } => (true, true, Some(newest)),
|
||||
UpdateDecision::Forced(doc) => (true, false, Some(doc)),
|
||||
};
|
||||
|
||||
let focus = if eula_needed {
|
||||
Focus::Eula
|
||||
} else if tos_needed {
|
||||
Focus::Tos
|
||||
} else if pp_needed {
|
||||
Focus::Pp
|
||||
} else {
|
||||
Focus::Cancel
|
||||
};
|
||||
|
||||
let update_needed = (eula_needed && !eula_future)
|
||||
|| (tos_needed && !tos_future)
|
||||
|| (pp_needed && !pp_future);
|
||||
|
||||
Self {
|
||||
sender,
|
||||
|
||||
eula_needed,
|
||||
tos_needed,
|
||||
pp_needed,
|
||||
|
||||
eula_for_future,
|
||||
tos_for_future,
|
||||
pp_for_future,
|
||||
|
||||
eula_future,
|
||||
tos_future,
|
||||
pp_future,
|
||||
|
||||
update_needed,
|
||||
|
||||
eula: !eula_needed,
|
||||
tos: !tos_needed,
|
||||
pp: !pp_needed,
|
||||
|
||||
focus,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Screen for TermsUpdaterScreen {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, size: Rect) {
|
||||
let mut needed_height = 5;
|
||||
|
||||
if size.height < 6 || size.width < 27 {
|
||||
f.render_widget(
|
||||
Line::from(format!("too small {}/63 by {}/15", size.width, size.height)),
|
||||
size,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let max_width = 150;
|
||||
let max_height = 30;
|
||||
|
||||
let content_width = if max_width < size.width {
|
||||
max_width
|
||||
} else {
|
||||
size.width
|
||||
};
|
||||
let content_height = if max_height < size.height {
|
||||
max_height
|
||||
} else {
|
||||
size.height
|
||||
};
|
||||
|
||||
let horizontal_margin = (size.width.saturating_sub(content_width)) / 2;
|
||||
let vertical_margin = (size.height.saturating_sub(content_height)) / 2;
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(7), Constraint::Length(3)])
|
||||
.split(Rect {
|
||||
x: horizontal_margin,
|
||||
y: vertical_margin,
|
||||
width: content_width,
|
||||
height: content_height,
|
||||
});
|
||||
|
||||
let mut text_lines: Vec<Line> = Vec::new();
|
||||
let mut header_lines = 0;
|
||||
let separator = if size.width > 72 {
|
||||
header_lines += 3;
|
||||
text_lines.push(Line::from(
|
||||
"You previously accepted earlier versions of Tensamin’s legal documents.",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"Some of them have been updated and are listed below for your review.",
|
||||
));
|
||||
text_lines.push(Line::from(""));
|
||||
2
|
||||
} else {
|
||||
header_lines += 5;
|
||||
text_lines.push(Line::from("You previously accepted earlier "));
|
||||
text_lines.push(Line::from("versions of Tensamin’s legal documents."));
|
||||
text_lines.push(Line::from("Some of them have been updated and"));
|
||||
text_lines.push(Line::from("are listed below for your review."));
|
||||
text_lines.push(Line::from(""));
|
||||
4
|
||||
};
|
||||
|
||||
if self.eula_needed {
|
||||
header_lines += 1;
|
||||
if self.eula_future {
|
||||
if size.width < 80 {
|
||||
text_lines.push(checkbox(
|
||||
"EULA ¹³ (https://legal.tensamin.net/eula/newest/)",
|
||||
self.eula,
|
||||
self.focus == Focus::Eula,
|
||||
true,
|
||||
));
|
||||
|
||||
header_lines += 1;
|
||||
let unix_timestamp = self.eula_for_future.clone().unwrap().get_time() as i64;
|
||||
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
|
||||
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
|
||||
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
|
||||
} else {
|
||||
text_lines.push(checkbox(
|
||||
"End User Licence Agreement ¹³ (https://legal.tensamin.net/eula/newest/)",
|
||||
self.eula,
|
||||
self.focus == Focus::Eula,
|
||||
true,
|
||||
));
|
||||
|
||||
header_lines += 1;
|
||||
let unix_timestamp = self.eula_for_future.clone().unwrap().get_time() as i64;
|
||||
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
|
||||
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
|
||||
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
|
||||
}
|
||||
} else {
|
||||
if size.width < 80 {
|
||||
text_lines.push(checkbox(
|
||||
"EULA ¹ (https://legal.tensamin.net/eula/newest/)",
|
||||
self.eula,
|
||||
self.focus == Focus::Eula,
|
||||
true,
|
||||
));
|
||||
} else {
|
||||
text_lines.push(checkbox(
|
||||
"End User Licence Agreement ¹ (https://legal.tensamin.net/eula/newest/)",
|
||||
self.eula,
|
||||
self.focus == Focus::Eula,
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.tos_needed {
|
||||
header_lines += 1;
|
||||
if self.tos_future {
|
||||
if size.width < 80 {
|
||||
text_lines.push(checkbox(
|
||||
"ToS ²³ (https://legal.tensamin.net/tos/newest/)",
|
||||
self.tos,
|
||||
self.focus == Focus::Tos,
|
||||
self.eula,
|
||||
));
|
||||
|
||||
header_lines += 1;
|
||||
let unix_timestamp = self.tos_for_future.clone().unwrap().get_time() as i64;
|
||||
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
|
||||
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
|
||||
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
|
||||
} else {
|
||||
text_lines.push(checkbox(
|
||||
"Terms of Service ²³ (https://legal.tensamin.net/terms-of-service/newest/)",
|
||||
self.tos,
|
||||
self.focus == Focus::Tos,
|
||||
self.eula,
|
||||
));
|
||||
|
||||
header_lines += 1;
|
||||
let unix_timestamp = self.tos_for_future.clone().unwrap().get_time() as i64;
|
||||
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
|
||||
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
|
||||
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
|
||||
}
|
||||
} else {
|
||||
if size.width < 80 {
|
||||
text_lines.push(checkbox(
|
||||
"ToS ² (https://legal.tensamin.net/tos/newest/)",
|
||||
self.tos,
|
||||
self.focus == Focus::Tos,
|
||||
self.eula,
|
||||
));
|
||||
} else {
|
||||
text_lines.push(checkbox(
|
||||
"Terms of Service ² (https://legal.tensamin.net/terms-of-service/newest/)",
|
||||
self.tos,
|
||||
self.focus == Focus::Tos,
|
||||
self.eula,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.pp_needed {
|
||||
header_lines += 1;
|
||||
if self.pp_future {
|
||||
if size.width < 80 {
|
||||
text_lines.push(checkbox(
|
||||
"PP ²³ (https://legal.tensamin.net/privacy-policy/newest/)",
|
||||
self.pp,
|
||||
self.focus == Focus::Pp,
|
||||
self.eula,
|
||||
));
|
||||
|
||||
header_lines += 1;
|
||||
let unix_timestamp = self.pp_for_future.clone().unwrap().get_time() as i64;
|
||||
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
|
||||
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
|
||||
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
|
||||
} else {
|
||||
text_lines.push(checkbox(
|
||||
"Privacy Policy ²³ (https://legal.tensamin.net/privacy-policy/newest/)",
|
||||
self.pp,
|
||||
self.focus == Focus::Pp,
|
||||
self.eula,
|
||||
));
|
||||
|
||||
header_lines += 1;
|
||||
let unix_timestamp = self.pp_for_future.clone().unwrap().get_time() as i64;
|
||||
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
|
||||
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
|
||||
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
|
||||
}
|
||||
} else {
|
||||
if size.width < 80 {
|
||||
text_lines.push(checkbox(
|
||||
"PP ² (https://legal.tensamin.net/privacy-policy/newest/)",
|
||||
self.pp,
|
||||
self.focus == Focus::Pp,
|
||||
self.eula,
|
||||
));
|
||||
} else {
|
||||
text_lines.push(checkbox(
|
||||
"Privacy Policy ² (https://legal.tensamin.net/privacy-policy/newest/)",
|
||||
self.pp,
|
||||
self.focus == Focus::Pp,
|
||||
self.eula,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from("¹ Necessary to run the program"));
|
||||
text_lines.push(Line::from("² Optional - only for Tensamin services"));
|
||||
if size.width < 100 {
|
||||
text_lines.push(Line::from(
|
||||
"³ Future version - consent stored now, takes effect later",
|
||||
));
|
||||
} else {
|
||||
text_lines.push(Line::from("³ Future version - You’ll continue using this version, automatically updated when changes apply."));
|
||||
}
|
||||
|
||||
text_lines.push(Line::from(""));
|
||||
|
||||
let mut optional_lines: Vec<i16> = if size.width > 143 {
|
||||
if self.tos_needed || self.pp_needed {
|
||||
text_lines.push(Line::from("By selecting Downgrade, you confirm that you agree to the End User License Agreement and applicable Terms of Service."));
|
||||
text_lines.push(Line::from(
|
||||
"On Downgrade: Tensamin Services will deactivate once these changes apply.",
|
||||
));
|
||||
} else {
|
||||
text_lines.push(Line::from("By selecting Continue, you confirm that you agree to the End User License Agreement and applicable Terms of Service."));
|
||||
}
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from(
|
||||
"Tensamin services require acceptance of the Terms of Service and Privacy Policy.",
|
||||
));
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from("While having a document selected press O to view in this UI or press L to open as a link."));
|
||||
|
||||
if self.tos_needed || self.pp_needed {
|
||||
vec![
|
||||
header_lines + 7,
|
||||
header_lines,
|
||||
header_lines + 7,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
header_lines + 6,
|
||||
header_lines,
|
||||
header_lines + 6,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
}
|
||||
} else if size.width > 92 {
|
||||
if self.tos_needed || self.pp_needed {
|
||||
text_lines.push(Line::from(
|
||||
"By selecting Continue, you confirm that you agree to the End User",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"License Agreement and applicable Terms of Service.",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"On Downgrade: Tensamin Services will deactivate once these changes apply.",
|
||||
));
|
||||
} else {
|
||||
text_lines.push(Line::from(
|
||||
"By selecting Continue, you confirm that you agree to the End User",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"License Agreement and applicable Terms of Service.",
|
||||
));
|
||||
}
|
||||
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from(
|
||||
"Tensamin services require acceptance of the Terms of Service and Privacy Policy.",
|
||||
));
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from("While having a document selected press O to view in this UI or press L to open as a link."));
|
||||
|
||||
if self.tos_needed || self.pp_needed {
|
||||
vec![
|
||||
header_lines + 8,
|
||||
header_lines,
|
||||
header_lines + 8,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
header_lines + 7,
|
||||
header_lines,
|
||||
header_lines + 7,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
}
|
||||
} else if size.width > 73 {
|
||||
if self.tos_needed || self.pp_needed {
|
||||
text_lines.push(Line::from(
|
||||
"By selecting Continue, you confirm that you read, understood and",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"agree to the End User License Agreement and applicable Terms of Service.",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"On Downgrade: Tensamin Services will deactivate once these changes apply.",
|
||||
));
|
||||
} else {
|
||||
text_lines.push(Line::from(
|
||||
"By selecting Continue, you confirm that you read, understood and",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"agree to the End User License Agreement and applicable Terms of Service.",
|
||||
));
|
||||
}
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from(
|
||||
"Tensamin services require acceptance of the ToS and Privacy Policy.",
|
||||
));
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from(
|
||||
"While having a document selected press O to view in this UI or press L",
|
||||
));
|
||||
text_lines.push(Line::from("to open as a link."));
|
||||
|
||||
if self.tos_needed || self.pp_needed {
|
||||
vec![
|
||||
header_lines + 8,
|
||||
header_lines,
|
||||
header_lines + 8,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
header_lines + 7,
|
||||
header_lines,
|
||||
header_lines + 7,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
}
|
||||
} else {
|
||||
if self.tos_needed || self.pp_needed {
|
||||
text_lines.push(Line::from("By selecting Continue, you confirm that you"));
|
||||
text_lines.push(Line::from("agree to the End User License"));
|
||||
text_lines.push(Line::from("Agreement and applicable Terms of Service."));
|
||||
text_lines.push(Line::from(
|
||||
"On Downgrade: Tensamin Services will deactivate",
|
||||
));
|
||||
text_lines.push(Line::from("once these changes apply."));
|
||||
} else {
|
||||
text_lines.push(Line::from("By selecting Continue, you confirm that you"));
|
||||
text_lines.push(Line::from("agree to the End User License"));
|
||||
text_lines.push(Line::from("Agreement and applicable Terms of Service."));
|
||||
}
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from("Tensamin services require acceptance of the"));
|
||||
text_lines.push(Line::from("Terms of Service and Privacy Policy."));
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from(
|
||||
"While having a document selected press O to view",
|
||||
));
|
||||
text_lines.push(Line::from("in this UI or press L to open as a link."));
|
||||
if self.tos_needed || self.pp_needed {
|
||||
vec![
|
||||
header_lines + 10,
|
||||
header_lines,
|
||||
header_lines + 11,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
header_lines + 8,
|
||||
header_lines,
|
||||
header_lines + 9,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
while size.height - 5 < text_lines.len() as u16 {
|
||||
if optional_lines.len() == 0 {
|
||||
break;
|
||||
}
|
||||
text_lines.remove(optional_lines[0] as usize);
|
||||
optional_lines.remove(0);
|
||||
}
|
||||
needed_height += text_lines.len();
|
||||
|
||||
let q_informer = if self.update_needed {
|
||||
"Q to Exit"
|
||||
} else {
|
||||
"Q to Cancel"
|
||||
};
|
||||
if size.width < 60 || size.height < needed_height as u16 {
|
||||
let width_style = if size.width > 76 {
|
||||
Style::default().fg(Color::Green)
|
||||
} else if size.width >= 60 {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
};
|
||||
|
||||
let height_style = if size.height > 20 {
|
||||
Style::default().fg(Color::Green)
|
||||
} else if size.height >= (header_lines as u16 + 10) {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
};
|
||||
|
||||
let warning_text = Text::from(vec![
|
||||
Line::from(vec![
|
||||
Span::raw("Width: "),
|
||||
Span::styled(format!("{}", size.width), width_style),
|
||||
Span::raw(" / 60"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw("Height: "),
|
||||
Span::styled(format!("{}", size.height), height_style),
|
||||
Span::raw(format!(" / {}", header_lines + 10)),
|
||||
]),
|
||||
]);
|
||||
|
||||
let warning = Paragraph::new(warning_text)
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!("UI Too Small [{}]", q_informer)),
|
||||
);
|
||||
|
||||
f.render_widget(warning, size);
|
||||
return;
|
||||
}
|
||||
|
||||
let consent_block = Paragraph::new(Text::from(text_lines)).block(
|
||||
Block::default()
|
||||
.title(format!(" Update Tensamin User Consent [{}] ", q_informer))
|
||||
.borders(Borders::ALL),
|
||||
);
|
||||
f.render_widget(consent_block, chunks[0]);
|
||||
|
||||
let downgrade_scenario = self.tos_needed || self.pp_needed;
|
||||
draw_buttons(
|
||||
f,
|
||||
chunks[1],
|
||||
self.focus,
|
||||
(self.eula, self.tos && self.pp),
|
||||
self.update_needed,
|
||||
downgrade_scenario,
|
||||
self.pp_needed || self.tos_needed,
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
||||
let mut possible_states = Vec::new();
|
||||
|
||||
if self.eula_needed {
|
||||
possible_states.push(Focus::Eula);
|
||||
}
|
||||
if self.tos_needed {
|
||||
possible_states.push(Focus::Tos);
|
||||
}
|
||||
if self.pp_needed {
|
||||
possible_states.push(Focus::Pp);
|
||||
}
|
||||
|
||||
possible_states.push(Focus::Cancel);
|
||||
|
||||
if self.eula {
|
||||
possible_states.push(Focus::Continue);
|
||||
if self.tos && self.pp {
|
||||
possible_states.push(Focus::ContinueAll);
|
||||
}
|
||||
}
|
||||
|
||||
match event.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
|
||||
if let Some(sender) = self.sender.take() {
|
||||
let _ = sender.send(UserChoice::Deny);
|
||||
}
|
||||
return InteractionResult::CloseScreen;
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => match self.focus {
|
||||
Focus::Eula => {
|
||||
self.eula = !self.eula;
|
||||
self.tos = !self.tos_needed;
|
||||
self.pp = !self.pp_needed;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Tos if self.eula => {
|
||||
self.tos = !self.tos;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Pp if self.eula => {
|
||||
self.pp = !self.pp;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Cancel => {
|
||||
if let Some(sender) = self.sender.take() {
|
||||
let _ = sender.send(UserChoice::Deny);
|
||||
}
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
Focus::Continue if self.eula => {
|
||||
if let Some(sender) = self.sender.take() {
|
||||
let _ = sender.send(UserChoice::AcceptEULA);
|
||||
}
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
Focus::ContinueAll if self.eula && self.tos && self.pp => {
|
||||
if let Some(sender) = self.sender.take() {
|
||||
let _ = sender.send(UserChoice::AcceptAll);
|
||||
}
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
_ => InteractionResult::Unhandled,
|
||||
},
|
||||
KeyCode::Char('o') | KeyCode::Char('O') => {
|
||||
let terms_type = match self.focus {
|
||||
Focus::Eula => Some(Type::EULA),
|
||||
Focus::Tos => Some(Type::TOS),
|
||||
Focus::Pp => Some(Type::PP),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(terms_type) = terms_type {
|
||||
let fut = Box::pin(async move {
|
||||
let content = get_terms(terms_type.clone()).await.unwrap();
|
||||
Box::new(FileViewer::new(terms_type.to_string(), &content))
|
||||
as Box<dyn Screen>
|
||||
});
|
||||
|
||||
return InteractionResult::OpenFutureScreen { screen: fut };
|
||||
} else {
|
||||
InteractionResult::Unhandled
|
||||
}
|
||||
}
|
||||
KeyCode::Char('l') | KeyCode::Char('L') => match self.focus {
|
||||
Focus::Eula => {
|
||||
let _ = open::that(get_newest_link(Type::EULA));
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Tos => {
|
||||
let _ = open::that(get_newest_link(Type::TOS));
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Pp => {
|
||||
let _ = open::that(get_newest_link(Type::PP));
|
||||
InteractionResult::Handled
|
||||
}
|
||||
_ => InteractionResult::Unhandled,
|
||||
},
|
||||
KeyCode::Up | KeyCode::Left => {
|
||||
self.focus.prev(&possible_states);
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
|
||||
self.focus.next(&possible_states);
|
||||
InteractionResult::Handled
|
||||
}
|
||||
_ => InteractionResult::Unhandled,
|
||||
}
|
||||
}
|
||||
}
|
||||
0
iota-cli/src/tui.rs
Normal file
0
iota-cli/src/tui.rs
Normal file
164
iota-cli/src/ui.rs
Normal file
164
iota-cli/src/ui.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
use crate::{
|
||||
input_handler::setup_input_handler, interaction_result::InteractionResult,
|
||||
screens::screens::Screen,
|
||||
};
|
||||
use crossterm::event::KeyEvent;
|
||||
use once_cell::sync::Lazy;
|
||||
use ratatui::{Terminal, backend::CrosstermBackend, init};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io::Stdout,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{sync::RwLock, time::Instant};
|
||||
|
||||
/// UI state and rendering
|
||||
pub static UNIQUE: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
pub static FPS: Lazy<RwLock<(f64, f64)>> = Lazy::new(|| RwLock::new((0.0, 0.0)));
|
||||
|
||||
pub struct UI {
|
||||
pub terminal: Arc<Mutex<Terminal<CrosstermBackend<Stdout>>>>,
|
||||
screen_stack: Arc<RwLock<Vec<Box<dyn Screen>>>>,
|
||||
}
|
||||
|
||||
pub fn start_tui() -> Arc<UI> {
|
||||
let ui = Arc::new(UI::new());
|
||||
let uic = ui.clone();
|
||||
ACTIVE_TASKS.insert("UI Renderer".to_string());
|
||||
tokio::spawn(async move {
|
||||
let mut last_render = Instant::now();
|
||||
|
||||
let mut fps_samples: VecDeque<f64> = VecDeque::with_capacity(20);
|
||||
let mut skip_samples: VecDeque<u16> = VecDeque::with_capacity(20);
|
||||
|
||||
let mut fps_sum = 0.0;
|
||||
let mut skip_sum: u32 = 0;
|
||||
|
||||
let mut skipped = 0;
|
||||
|
||||
loop {
|
||||
if *SHUTDOWN.read().await {
|
||||
break;
|
||||
}
|
||||
|
||||
if skipped > 5 || UNIQUE.load(Ordering::Relaxed) {
|
||||
uic.render().await;
|
||||
|
||||
skip_samples.push_back(skipped);
|
||||
skip_sum += skipped as u32;
|
||||
|
||||
if skip_samples.len() > 20 {
|
||||
if let Some(old) = skip_samples.pop_front() {
|
||||
skip_sum -= old as u32;
|
||||
}
|
||||
}
|
||||
|
||||
skipped = 0;
|
||||
|
||||
let elapsed = last_render.elapsed().as_secs_f64();
|
||||
if elapsed > 0.0 {
|
||||
let fps = 1.0 / elapsed;
|
||||
|
||||
fps_samples.push_back(fps);
|
||||
fps_sum += fps;
|
||||
|
||||
if fps_samples.len() > 20 {
|
||||
if let Some(old) = fps_samples.pop_front() {
|
||||
fps_sum -= old;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let avg_fps = if !fps_samples.is_empty() {
|
||||
fps_sum / fps_samples.len() as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let avg_skips_percentage = if !skip_samples.is_empty() {
|
||||
let avg_skipped = skip_sum as f64 / skip_samples.len() as f64;
|
||||
let total_iterations = avg_skipped + 1.0;
|
||||
(avg_skipped / total_iterations) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
*FPS.write().await = (avg_fps, avg_skips_percentage);
|
||||
|
||||
last_render = Instant::now();
|
||||
UNIQUE.store(false, Ordering::Relaxed);
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(16)).await;
|
||||
}
|
||||
ACTIVE_TASKS.remove("UI Renderer");
|
||||
ratatui::restore();
|
||||
});
|
||||
setup_input_handler(ui.clone());
|
||||
ui
|
||||
}
|
||||
impl UI {
|
||||
pub fn new() -> Self {
|
||||
let terminal = init();
|
||||
Self {
|
||||
terminal: Arc::new(Mutex::new(terminal)),
|
||||
screen_stack: Arc::new(RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_screen(&self, screen: Box<dyn Screen>) {
|
||||
self.screen_stack.write().await.push(screen);
|
||||
}
|
||||
pub async fn replace_screen(&self, screen: Box<dyn Screen>) {
|
||||
let mut stack = self.screen_stack.write().await;
|
||||
stack.pop();
|
||||
stack.push(screen);
|
||||
}
|
||||
pub async fn handle_input(self: Arc<Self>, key_event: KeyEvent) {
|
||||
let result = {
|
||||
let mut stack = self.screen_stack.write().await;
|
||||
if let Some(screen) = stack.last_mut() {
|
||||
screen.handle_input(key_event)
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
match result {
|
||||
InteractionResult::OpenScreen { screen } => {
|
||||
self.set_screen(screen).await;
|
||||
}
|
||||
InteractionResult::OpenFutureScreen { screen: fut } => {
|
||||
let ui = self.clone();
|
||||
let screen = fut.await;
|
||||
ui.set_screen(screen).await;
|
||||
}
|
||||
InteractionResult::CloseScreen => {
|
||||
let mut stack = self.screen_stack.write().await;
|
||||
stack.pop();
|
||||
|
||||
if stack.is_empty() {
|
||||
*SHUTDOWN.write().await = true;
|
||||
}
|
||||
}
|
||||
InteractionResult::Handled => {}
|
||||
InteractionResult::Unhandled => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn render(&self) {
|
||||
if let Some(screen) = self.screen_stack.read().await.last() {
|
||||
let mut terminal = self.terminal.lock().unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
screen.render(f, f.area());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
63
iota-cli/src/util/borders.rs
Normal file
63
iota-cli/src/util/borders.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::Borders;
|
||||
|
||||
fn set_join_char(frame: &mut Frame, x: u16, y: u16, c: char) {
|
||||
frame
|
||||
.buffer_mut()
|
||||
.set_string(x, y, c.to_string(), Style::default());
|
||||
}
|
||||
|
||||
pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: Borders) {
|
||||
let x0 = area.x;
|
||||
let y0 = area.y;
|
||||
let x1 = area.x + area.width - 1;
|
||||
let y1 = area.y + area.height - 1;
|
||||
|
||||
if borders.contains(Borders::TOP) && borders.contains(Borders::LEFT) {
|
||||
let top_left = match (joins.contains(Borders::TOP), joins.contains(Borders::LEFT)) {
|
||||
(true, true) => '┼',
|
||||
(true, false) => '├',
|
||||
(false, true) => '┬',
|
||||
(false, false) => '┌',
|
||||
};
|
||||
set_join_char(frame, x0, y0, top_left);
|
||||
}
|
||||
|
||||
if borders.contains(Borders::TOP) && borders.contains(Borders::RIGHT) {
|
||||
let top_right = match (joins.contains(Borders::TOP), joins.contains(Borders::RIGHT)) {
|
||||
(true, true) => '┼',
|
||||
(true, false) => '┤',
|
||||
(false, true) => '┬',
|
||||
(false, false) => '┐',
|
||||
};
|
||||
set_join_char(frame, x1, y0, top_right);
|
||||
}
|
||||
|
||||
if borders.contains(Borders::BOTTOM) && borders.contains(Borders::LEFT) {
|
||||
let bottom_left = match (
|
||||
joins.contains(Borders::BOTTOM),
|
||||
joins.contains(Borders::LEFT),
|
||||
) {
|
||||
(true, true) => '┼',
|
||||
(true, false) => '├',
|
||||
(false, true) => '┴',
|
||||
(false, false) => '└',
|
||||
};
|
||||
set_join_char(frame, x0, y1, bottom_left);
|
||||
}
|
||||
|
||||
if borders.contains(Borders::BOTTOM) && borders.contains(Borders::RIGHT) {
|
||||
let bottom_right = match (
|
||||
joins.contains(Borders::BOTTOM),
|
||||
joins.contains(Borders::RIGHT),
|
||||
) {
|
||||
(true, true) => '┼',
|
||||
(true, false) => '┤',
|
||||
(false, true) => '┴',
|
||||
(false, false) => '┘',
|
||||
};
|
||||
set_join_char(frame, x1, y1, bottom_right);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue