[Add] basic split

This commit is contained in:
Alex Emmet 2026-04-04 19:46:21 +02:00
commit 3cdf7c62d5
77 changed files with 506 additions and 648 deletions

57
iota-cli/Cargo.toml Normal file
View file

@ -0,0 +1,57 @@
[package]
name = "iota-cli"
version = "0.1.0"
edition = "2024"
[dependencies]
iota-state = { path = "../iota-state" }
ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" }
ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" }
actix-web = { version = "4", features = ["rustls-0_23"] }
actix-web-actors = "4"
aes-gcm = "0.10.3"
async-trait = "0.1.89"
base64 = "0.22.1"
chrono = "0.4.43"
crossterm = "*"
dashmap = "6.1.0"
futures = "*"
futures-util = "*"
hex = "*"
hkdf = "0.12.4"
hyper = { version = "1.8.1", features = [
"capi",
"client",
"full",
"http1",
"http2",
"nightly",
"server",
] }
hyper-util = { version = "*" }
json = "*"
lazy_static = "1.5.0"
once_cell = "1.21.3"
open = "5.3.3"
pnet = "0.35.0"
rand = "0.8"
rand_core = { version = "0.6", features = ["getrandom", "std"] }
ratatui = "0.30.0"
reqwest = "0.13.2"
rusqlite = "0.39.0"
rustls = { version = "0.23.37", features = ["aws-lc-rs"] }
rustls-pemfile = "2.2.0"
serde_json = "1.0.149"
sha2 = "0.10.9"
strum = "0.27.2"
strum_macros = "0.27.2"
sysinfo = "0.38.3"
tokio = { version = "1.50.0", features = ["full"] }
tokio-tungstenite = { version = "*", features = ["native-tls"] }
tungstenite = "*"
uuid = { version = "*", features = ["v4"] }
walkdir = "2.5.0"
warp = "*"
x448 = { version = "*" }
zip = "6.0.0"

1
iota-cli/src/app_state.rs Executable file
View file

@ -0,0 +1 @@
pub use iota_state::*;

View 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),
}
}

View 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);
}

View 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
View 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(&timestamp);
}
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;
}
}

View 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;
}
}
}

View 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,
}
}
}

View 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(())
}

View 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(),
}
}
}

View file

@ -0,0 +1,2 @@
pub mod language_creator;
pub mod language_manager;

20
iota-cli/src/lib.rs Normal file
View 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;

View 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
}
}

View 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
}

View 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;
}

View 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,
}
}
}

View 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 Tensamins 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 Tensamins 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 - Youll 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
View file

164
iota-cli/src/ui.rs Normal file
View 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();
}
}
}

View 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);
}
}