[WIP] Consent UI, via main UI render System

This commit is contained in:
Alex Emmet 2026-02-18 18:35:56 +01:00
commit a0fddf723f
18 changed files with 1625 additions and 1040 deletions

16
logs/log_1771421284.txt Normal file
View file

@ -0,0 +1,16 @@
1771421284869 IOTA ID: 0
1771421284869 User IDS:
1771421284885 Community IDS:
1771421284886 TLS certificate 'certs/cert.pem' not found.
1771421284886 HTTP Server running on 0.0.0.0:1984
1771421284887 Communities active on ws://10.209.226.34:1984/community/...
1771421284887 Downloading ZIP file...
1771421285166 Failed to download file: Status 404 Not Found
1771421285167 Error downloading file: Failed to download file: Status 404 Not Found
1771421285167 IOTA_REGISTER_NEW
1771421285173 OMIKRON_CONNECTING
1771421285459 OMIKRON_CONNECTION_SUCCESS
1771421297984 Shutdown signal received.
1771421297985 Web Server shutdown complete.
1771421305461 Request timed out after 20 seconds.
1771421305461 Launched

42
logs/log_1771421408.txt Normal file
View file

@ -0,0 +1,42 @@
1771421408357 IOTA ID: 0
1771421408357 User IDS:
1771421408369 Community IDS:
1771421408369 TLS certificate 'certs/cert.pem' not found.
1771421408369 HTTP Server running on 0.0.0.0:1984
1771421408370 Communities active on ws://10.209.226.34:1984/community/...
1771421408370 Downloading ZIP file...
1771421408574 Failed to download file: Status 404 Not Found
1771421408574 Error downloading file: Failed to download file: Status 404 Not Found
1771421408574 IOTA_REGISTER_NEW
1771421408581 OMIKRON_CONNECTING
1771421408791 OMIKRON_CONNECTION_SUCCESS
1771421428793 Request timed out after 20 seconds.
1771421428793 Launched
1771421428824 error_internal
1771421428911 Connection closed.
1771421429796 IOTA_REGISTER_NEW
1771421429800 OMIKRON_CONNECTING
1771421430102 OMIKRON_CONNECTION_SUCCESS
1771421450104 Request timed out after 20 seconds.
1771421450106 Launched
1771421450135 error_internal
1771421450205 Connection closed.
1771421451108 IOTA_REGISTER_NEW
1771421451113 OMIKRON_CONNECTING
1771421451310 OMIKRON_CONNECTION_SUCCESS
1771421471312 Request timed out after 20 seconds.
1771421471314 Launched
1771421471354 error_internal
1771421471428 Connection closed.
1771421472315 IOTA_REGISTER_NEW
1771421472319 OMIKRON_CONNECTING
1771421472543 OMIKRON_CONNECTION_SUCCESS
1771421492544 Request timed out after 20 seconds.
1771421492546 Launched
1771421492568 error_internal
1771421492597 Connection closed.
1771421493548 IOTA_REGISTER_NEW
1771421493554 OMIKRON_CONNECTING
1771421493769 OMIKRON_CONNECTION_SUCCESS
1771421493828 Shutdown signal received.
1771421493830 Web Server shutdown complete.

15
logs/log_1771421600.txt Normal file
View file

@ -0,0 +1,15 @@
1771421600740 IOTA ID: 0
1771421600740 User IDS:
1771421600755 Community IDS:
1771421600756 TLS certificate 'certs/cert.pem' not found.
1771421600756 HTTP Server running on 0.0.0.0:1984
1771421600756 Communities active on ws://10.209.226.34:1984/community/...
1771421600756 Downloading ZIP file...
1771421600982 Failed to download file: Status 404 Not Found
1771421600982 Error downloading file: Failed to download file: Status 404 Not Found
1771421600983 IOTA_REGISTER_NEW
1771421600988 OMIKRON_CONNECTING
1771421601270 OMIKRON_CONNECTION_SUCCESS
1771421606464 Shutdown signal received.
1771421621272 Request timed out after 20 seconds.
1771421621272 Launched

184
logs/log_1771423051.txt Normal file
View file

@ -0,0 +1,184 @@
1771423051538 IOTA ID: 0
1771423051538 User IDS:
1771423051551 Community IDS:
1771423051551 TLS certificate 'certs/cert.pem' not found.
1771423051551 HTTP Server running on 0.0.0.0:1984
1771423051552 Communities active on ws://10.209.226.34:1984/community/...
1771423051552 Downloading ZIP file...
1771423051790 Failed to download file: Status 404 Not Found
1771423051790 Error downloading file: Failed to download file: Status 404 Not Found
1771423051790 IOTA_REGISTER_NEW
1771423051793 OMIKRON_CONNECTING
1771423052032 OMIKRON_CONNECTION_SUCCESS
1771423072035 Request timed out after 20 seconds.
1771423072035 Launched
1771423072067 error_internal
1771423072105 Connection closed.
1771423073036 IOTA_REGISTER_NEW
1771423073040 OMIKRON_CONNECTING
1771423073256 OMIKRON_CONNECTION_SUCCESS
1771423093258 Request timed out after 20 seconds.
1771423093262 Launched
1771423093286 error_internal
1771423093322 Connection closed.
1771423094264 IOTA_REGISTER_NEW
1771423094267 OMIKRON_CONNECTING
1771423094484 OMIKRON_CONNECTION_SUCCESS
1771423114491 Request timed out after 20 seconds.
1771423114492 Launched
1771423114520 error_internal
1771423114558 Connection closed.
1771423115495 IOTA_REGISTER_NEW
1771423115501 OMIKRON_CONNECTING
1771423115724 OMIKRON_CONNECTION_SUCCESS
1771423135725 Request timed out after 20 seconds.
1771423135728 Launched
1771423135759 error_internal
1771423135794 Connection closed.
1771423136730 IOTA_REGISTER_NEW
1771423136738 OMIKRON_CONNECTING
1771423136959 OMIKRON_CONNECTION_SUCCESS
1771423156960 Request timed out after 20 seconds.
1771423156963 Launched
1771423156999 error_internal
1771423157078 Connection closed.
1771423157965 IOTA_REGISTER_NEW
1771423157970 OMIKRON_CONNECTING
1771423158169 OMIKRON_CONNECTION_SUCCESS
1771423178171 Request timed out after 20 seconds.
1771423178173 Launched
1771423178200 error_internal
1771423178270 Connection closed.
1771423179174 IOTA_REGISTER_NEW
1771423179178 OMIKRON_CONNECTING
1771423179404 OMIKRON_CONNECTION_SUCCESS
1771423199406 Request timed out after 20 seconds.
1771423199409 Launched
1771423199437 error_internal
1771423199485 Connection closed.
1771423200413 IOTA_REGISTER_NEW
1771423200418 OMIKRON_CONNECTING
1771423200855 OMIKRON_CONNECTION_SUCCESS
1771423220858 Request timed out after 20 seconds.
1771423220861 Launched
1771423220884 error_internal
1771423220913 Connection closed.
1771423221864 IOTA_REGISTER_NEW
1771423221870 OMIKRON_CONNECTING
1771423222087 OMIKRON_CONNECTION_SUCCESS
1771423242092 Request timed out after 20 seconds.
1771423242095 Launched
1771423242115 error_internal
1771423242145 Connection closed.
1771423243096 IOTA_REGISTER_NEW
1771423243101 OMIKRON_CONNECTING
1771423243328 OMIKRON_CONNECTION_SUCCESS
1771423263334 Request timed out after 20 seconds.
1771423263337 Launched
1771423263363 error_internal
1771423263412 Connection closed.
1771423264343 IOTA_REGISTER_NEW
1771423264348 OMIKRON_CONNECTING
1771423264553 OMIKRON_CONNECTION_SUCCESS
1771423284555 Request timed out after 20 seconds.
1771423284558 Launched
1771423284582 error_internal
1771423284609 Connection closed.
1771423285560 IOTA_REGISTER_NEW
1771423285563 OMIKRON_CONNECTING
1771423285770 OMIKRON_CONNECTION_SUCCESS
1771423305772 Request timed out after 20 seconds.
1771423305775 Launched
1771423305801 error_internal
1771423305837 Connection closed.
1771423306776 IOTA_REGISTER_NEW
1771423306786 OMIKRON_CONNECTING
1771423306995 OMIKRON_CONNECTION_SUCCESS
1771423326998 Request timed out after 20 seconds.
1771423327000 Launched
1771423327022 error_internal
1771423327068 Connection closed.
1771423328002 IOTA_REGISTER_NEW
1771423328006 OMIKRON_CONNECTING
1771423328253 OMIKRON_CONNECTION_SUCCESS
1771423348256 Request timed out after 20 seconds.
1771423348259 Launched
1771423348298 error_internal
1771423348335 Connection closed.
1771423349260 IOTA_REGISTER_NEW
1771423349265 OMIKRON_CONNECTING
1771423349504 OMIKRON_CONNECTION_SUCCESS
1771423369505 Request timed out after 20 seconds.
1771423369507 Launched
1771423369535 error_internal
1771423369584 Connection closed.
1771423370508 IOTA_REGISTER_NEW
1771423370512 OMIKRON_CONNECTING
1771423370754 OMIKRON_CONNECTION_SUCCESS
1771423390756 Request timed out after 20 seconds.
1771423390759 Launched
1771423390800 error_internal
1771423390827 Connection closed.
1771423391760 IOTA_REGISTER_NEW
1771423391764 OMIKRON_CONNECTING
1771423392002 OMIKRON_CONNECTION_SUCCESS
1771423412005 Request timed out after 20 seconds.
1771423412008 Launched
1771423412038 error_internal
1771423412065 Connection closed.
1771423413009 IOTA_REGISTER_NEW
1771423413015 OMIKRON_CONNECTING
1771423413208 OMIKRON_CONNECTION_SUCCESS
1771423433210 Request timed out after 20 seconds.
1771423433212 Launched
1771423433234 error_internal
1771423433271 Connection closed.
1771423434215 IOTA_REGISTER_NEW
1771423434221 OMIKRON_CONNECTING
1771423434422 OMIKRON_CONNECTION_SUCCESS
1771423454425 Request timed out after 20 seconds.
1771423454426 Launched
1771423454448 error_internal
1771423454478 Connection closed.
1771423455428 IOTA_REGISTER_NEW
1771423455433 OMIKRON_CONNECTING
1771423455707 OMIKRON_CONNECTION_SUCCESS
1771423475712 Request timed out after 20 seconds.
1771423475716 Launched
1771423475748 error_internal
1771423475779 Connection closed.
1771423476717 IOTA_REGISTER_NEW
1771423476721 OMIKRON_CONNECTING
1771423476935 OMIKRON_CONNECTION_SUCCESS
1771423496936 Request timed out after 20 seconds.
1771423496938 Launched
1771423496970 error_internal
1771423497007 Connection closed.
1771423497939 IOTA_REGISTER_NEW
1771423497945 OMIKRON_CONNECTING
1771423498170 OMIKRON_CONNECTION_SUCCESS
1771423518171 Request timed out after 20 seconds.
1771423518174 Launched
1771423518201 error_internal
1771423518241 Connection closed.
1771423519176 IOTA_REGISTER_NEW
1771423519181 OMIKRON_CONNECTING
1771423519392 OMIKRON_CONNECTION_SUCCESS
1771423539396 Request timed out after 20 seconds.
1771423539398 Launched
1771423539424 error_internal
1771423539455 Connection closed.
1771423540400 IOTA_REGISTER_NEW
1771423540404 OMIKRON_CONNECTING
1771423540643 OMIKRON_CONNECTION_SUCCESS
1771423560644 Request timed out after 20 seconds.
1771423560647 Launched
1771423560687 error_internal
1771423560747 Connection closed.
1771423561649 IOTA_REGISTER_NEW
1771423561653 OMIKRON_CONNECTING
1771423561858 OMIKRON_CONNECTION_SUCCESS
1771423574596 Shutdown signal received.
1771423574597 Web Server shutdown complete.
1771423581860 Request timed out after 20 seconds.
1771423581864 Launched

11
logs/log_1771423595.txt Normal file
View file

@ -0,0 +1,11 @@
1771423595203 IOTA ID: 0
1771423595203 User IDS:
1771423595215 Community IDS:
1771423595216 TLS certificate 'certs/cert.pem' not found.
1771423595216 HTTP Server running on 0.0.0.0:1984
1771423595217 Communities active on ws://10.209.226.34:1984/community/...
1771423595217 Downloading ZIP file...
1771423608345 Shutdown signal received.
1771423608346 Web Server shutdown complete.
1771423685485 Failed to download file: Status 504 Gateway Timeout
1771423685485 Error downloading file: Failed to download file: Status 504 Gateway Timeout

View file

@ -10,7 +10,6 @@ use ratatui::{
widgets::{Block, Borders, Paragraph, Wrap}, widgets::{Block, Borders, Paragraph, Wrap},
}; };
use std::any::Any; use std::any::Any;
use std::sync::Arc;
#[derive(Clone)] #[derive(Clone)]
pub struct UiLogEntry { pub struct UiLogEntry {
@ -89,13 +88,13 @@ impl InteractableElement for LogCard {
if self.scroll > 0 { if self.scroll > 0 {
self.scroll -= 1; self.scroll -= 1;
} }
InteractionResult::Handeled InteractionResult::Handled
} }
KeyCode::Down => { KeyCode::Down => {
self.scroll += 1; self.scroll += 1;
InteractionResult::Handeled InteractionResult::Handled
} }
_ => InteractionResult::Unhandeled, _ => InteractionResult::Unhandled,
} }
} }

66
src/gui/input_handler.rs Normal file
View file

@ -0,0 +1,66 @@
use crate::ACTIVE_TASKS;
use crate::gui::ui::UI;
use crate::{RELOAD, SHUTDOWN};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
use std::sync::Arc;
use std::time::Duration;
pub fn setup_input_handler(ui: Arc<UI>) {
tokio::spawn(async move {
ACTIVE_TASKS
.lock()
.unwrap()
.push("Input Handler".to_string());
loop {
{
let should_shutdown = *SHUTDOWN.read().await;
if should_shutdown {
break;
}
}
let has_event = match poll(Duration::from_millis(100)) {
Ok(true) => true,
Ok(false) => false,
Err(_) => false,
};
if has_event {
match read() {
Ok(event) => {
if let Event::Key(key_event) = event {
if key_event.kind == KeyEventKind::Press {
let ui_clone = ui.clone();
handle_input(key_event, ui_clone).await;
}
}
}
Err(_) => (),
}
}
}
{
let mut tasks = ACTIVE_TASKS.lock().unwrap();
tasks.retain(|t| t != "Input Handler");
}
});
}
pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
match (key.code, key.modifiers) {
(KeyCode::Char('q'), KeyModifiers::CONTROL) => {
*SHUTDOWN.write().await = true;
}
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
*SHUTDOWN.write().await = true;
}
(KeyCode::Char('r'), KeyModifiers::CONTROL) => {
*RELOAD.write().await = true;
*SHUTDOWN.write().await = true;
}
_ => {
ui.handle_input(key).await;
}
}
}

View file

@ -5,14 +5,15 @@ use std::pin::Pin;
use crate::gui::screens::screens::Screen; use crate::gui::screens::screens::Screen;
pub enum InteractionResult { pub enum InteractionResult {
CloseScreen,
OpenScreen { OpenScreen {
screen: Box<dyn Screen>, screen: Box<dyn Screen>,
}, },
OpenFutureScreen { OpenFutureScreen {
screen: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>>, screen: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>>,
}, },
Handeled, Handled,
Unhandeled, Unhandled,
} }
impl Debug for InteractionResult { impl Debug for InteractionResult {
@ -20,8 +21,9 @@ impl Debug for InteractionResult {
match self { match self {
InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"), InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"),
InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"), InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"),
InteractionResult::Handeled => write!(f, "Handeled"), InteractionResult::CloseScreen => write!(f, "CloseScreen"),
InteractionResult::Unhandeled => write!(f, "Unhandeled"), InteractionResult::Handled => write!(f, "Handled"),
InteractionResult::Unhandled => write!(f, "Unhandled"),
} }
} }
} }

View file

@ -4,7 +4,10 @@ pub mod elements {
} }
pub mod screens { pub mod screens {
pub mod screens; pub mod screens;
pub mod terms_checker;
pub mod terms_updater;
} }
pub mod app_state; pub mod app_state;
pub mod input_handler;
pub mod interaction_result; pub mod interaction_result;
pub mod ui; pub mod ui;

View file

@ -0,0 +1,353 @@
use crate::{
gui::{interaction_result::InteractionResult, screens::screens::Screen, ui::UI},
terms::{
buttons::{checkbox, draw_buttons},
consent_state::UserChoice,
focus::Focus,
md_viewer::FileViewer,
terms_getter::{Type, get_link, get_terms},
},
};
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 get_ui(&self) -> &Arc<UI> {
&self.ui
}
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 ui = self.ui.clone();
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(ui.clone(), 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,701 @@
use crate::{
gui::{interaction_result::InteractionResult, screens::screens::Screen, ui::UI},
terms::{
buttons::{checkbox, draw_buttons},
consent_state::{UpdateDecision, UserChoice},
doc::Doc,
focus::Focus,
md_viewer::FileViewer,
terms_getter::{Type, get_link, get_terms},
},
};
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 std::sync::Arc;
use tokio::sync::oneshot;
pub struct TermsUpdaterScreen {
ui: Arc<UI>,
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(
ui: Arc<UI>,
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 {
ui,
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 get_ui(&self) -> &Arc<UI> {
&self.ui
}
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 ui = self.ui.clone();
let fut = Box::pin(async move {
let content = get_terms(terms_type.clone()).await.unwrap();
Box::new(FileViewer::new(
ui.clone(),
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_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::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,
}
}
}

View file

@ -1,197 +1,64 @@
use crossterm::{
cursor::MoveTo,
execute,
terminal::{Clear, ClearType, EnterAlternateScreen},
};
use ratatui::{
Terminal,
backend::{Backend, CrosstermBackend},
layout::Size,
};
use std::{
io::{self, Error},
sync::{Arc, Mutex},
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::gui::{interaction_result::InteractionResult, screens::screens::Screen}; use crate::gui::{interaction_result::InteractionResult, screens::screens::Screen};
use crossterm::event::KeyEvent;
struct ResizableBackend<'a> { use ratatui::{Terminal, backend::CrosstermBackend, init};
backend: CrosstermBackend<&'a mut Vec<u8>>, use std::sync::{Arc, Mutex};
size: Size, use tokio::sync::RwLock;
}
impl<'a> ResizableBackend<'a> {
fn new(buf: &'a mut Vec<u8>, width: u16, height: u16) -> Self {
Self {
backend: CrosstermBackend::new(buf),
size: Size::new(width, height),
}
}
}
impl<'a> Backend for ResizableBackend<'a> {
type Error = Error;
fn draw<'b, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'b ratatui::buffer::Cell)>,
{
self.backend.draw(content)
}
fn hide_cursor(&mut self) -> io::Result<()> {
self.backend.hide_cursor()
}
fn show_cursor(&mut self) -> io::Result<()> {
self.backend.show_cursor()
}
#[allow(deprecated)]
fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
self.backend.get_cursor()
}
#[allow(deprecated)]
fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
self.backend.set_cursor(x, y)
}
fn clear(&mut self) -> io::Result<()> {
self.backend.clear()
}
fn clear_region(&mut self, region: ratatui::backend::ClearType) -> io::Result<()> {
self.backend.clear_region(region)
}
fn size(&self) -> Result<Size, Error> {
Ok(self.size)
}
fn flush(&mut self) -> io::Result<()> {
self.backend.flush()
}
fn get_cursor_position(&mut self) -> Result<ratatui::prelude::Position, Self::Error> {
todo!()
}
fn set_cursor_position<P: Into<ratatui::prelude::Position>>(
&mut self,
_position: P,
) -> Result<(), Self::Error> {
todo!()
}
fn window_size(&mut self) -> Result<ratatui::prelude::backend::WindowSize, Self::Error> {
todo!()
}
}
/// UI state and rendering /// UI state and rendering
pub struct UI { pub struct UI {
pub cols: Arc<Mutex<u16>>, pub terminal: Arc<Mutex<Terminal<CrosstermBackend<std::io::Stdout>>>>,
pub rows: Arc<Mutex<u16>>, screen: Arc<RwLock<Option<Box<dyn Screen>>>>,
screen: Arc<Mutex<Option<Box<dyn Screen>>>>,
cached_render: Arc<Mutex<Vec<u8>>>,
} }
impl UI { impl UI {
pub fn new(cols: u16, rows: u16) -> Self { pub fn new() -> Self {
let terminal = init();
Self { Self {
cols: Arc::new(Mutex::new(cols)), terminal: Arc::new(Mutex::new(terminal)),
rows: Arc::new(Mutex::new(rows)), screen: Arc::new(RwLock::new(None)),
screen: Arc::new(Mutex::new(None)),
cached_render: Arc::new(Mutex::new(Vec::new())),
} }
} }
pub fn set_screen(&self, screen: Box<dyn Screen>) { pub async fn set_screen(&self, screen: Box<dyn Screen>) {
*self.screen.lock().unwrap() = Some(screen); *self.screen.write().await = Some(screen);
} }
pub fn resize(&self, cols: u32, rows: u32) { pub async fn handle_input(self: Arc<Self>, key_event: KeyEvent) {
*self.cols.lock().unwrap() = cols as u16; let result = {
*self.rows.lock().unwrap() = rows as u16; let mut guard = self.screen.write().await;
} if let Some(screen) = guard.as_mut() {
screen.handle_input(key_event)
pub async fn handle_input(&self, input: &[u8]) {
let key_event = if input.len() == 1 {
let c = input[0] as char;
if c.is_ascii() {
Some(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
} else { } else {
None return;
} }
} else {
None
}; };
match result {
let event = match input { InteractionResult::OpenScreen { screen } => {
b"\x1b[A" => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)), self.set_screen(screen).await;
b"\x1b[B" => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
b"\x1b[C" => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
b"\x1b[D" => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
b"\r" | b"\n" => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
b"\x7f" => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
_ => key_event,
};
if let Some(event) = event {
let result = {
let mut guard = self.screen.lock().unwrap();
if let Some(screen) = guard.as_mut() {
screen.handle_input(event)
} else {
return;
}
};
match result {
InteractionResult::OpenScreen { screen } => {
self.set_screen(screen);
}
InteractionResult::OpenFutureScreen { screen: fut } => {
let ui = self.clone();
let screen = fut.await;
ui.set_screen(screen);
}
_ => {}
} }
InteractionResult::OpenFutureScreen { screen: fut } => {
let ui = self.clone();
let screen = fut.await;
ui.set_screen(screen).await;
}
InteractionResult::CloseScreen => {
/* TODO
self.set_screen();
*/
}
InteractionResult::Handled => {}
InteractionResult::Unhandled => {}
} }
} }
pub fn render(&self) -> Vec<u8> { pub async fn render(&self) {
let mut buf = Vec::new(); if let Some(screen) = self.screen.read().await.as_ref() {
let mut terminal = self.terminal.lock().unwrap();
execute!( terminal
&mut buf, .draw(|f| {
EnterAlternateScreen, screen.render(f, f.area());
Clear(ClearType::All), })
MoveTo(0, 0) .unwrap();
)
.unwrap();
{
let backend = ResizableBackend::new(
&mut buf,
*self.cols.lock().unwrap(),
*self.rows.lock().unwrap(),
);
let mut terminal = Terminal::new(backend).unwrap();
if let Some(screen) = self.screen.lock().unwrap().as_ref() {
let _ = terminal.draw(|f| screen.render(f, f.area()));
}
} }
*self.cached_render.lock().unwrap() = buf.clone();
buf
} }
} }

View file

@ -20,11 +20,12 @@ mod util;
use crate::communities::community_manager; use crate::communities::community_manager;
use crate::communities::interactables::registry; use crate::communities::interactables::registry;
use crate::gui::app_state::AppState; use crate::gui::app_state::AppState;
use crate::gui::ui; use crate::gui::input_handler::setup_input_handler;
use crate::gui::ui::UI;
use crate::langu::language_creator; use crate::langu::language_creator;
use crate::omikron::omikron_connection::{OMIKRON_CONNECTION, OmikronConnection}; use crate::omikron::omikron_connection::{OMIKRON_CONNECTION, OmikronConnection};
use crate::server::server::start; use crate::server::server::start;
use crate::terms::consent_state; use crate::terms::consent_state::ConsentManager;
use crate::users::user_manager; use crate::users::user_manager;
use crate::util::config_util::CONFIG; use crate::util::config_util::CONFIG;
use crate::util::file_util::download_and_extract_zip; use crate::util::file_util::download_and_extract_zip;
@ -45,17 +46,29 @@ async fn main() {
*RELOAD.write().await = false; *RELOAD.write().await = false;
*SHUTDOWN.write().await = false; *SHUTDOWN.write().await = false;
let tui = ui::UI::new(100, 100); let ui = Arc::new(UI::new());
tokio::spawn(async move { tui.render() }); setup_input_handler(ui.clone());
// EULA let ui_clone = ui.clone();
let (tos, pp) = consent_state::ConsentManager::check().await; tokio::spawn(async move {
if !tos { loop {
ui_clone.render().await;
sleep(Duration::from_millis(16)).await;
}
});
let (eula, tos_pp) = ConsentManager::check(ui.clone()).await;
if !eula {
ui.terminal.lock().unwrap().clear();
ui.terminal.lock().unwrap().flush();
println!("You need to accept our End User Licence Agreement before launching!"); println!("You need to accept our End User Licence Agreement before launching!");
println!("You can find this at 'agreements'!"); println!("You can find this at 'agreements'!");
return; return;
} }
if !pp { if !tos_pp {
ui.terminal.lock().unwrap().clear();
ui.terminal.lock().unwrap().flush();
println!( println!(
"Please accept our Privacy Policy & Terms of Serivce before using Tensamin Services!" "Please accept our Privacy Policy & Terms of Serivce before using Tensamin Services!"
); );
@ -171,5 +184,7 @@ async fn main() {
community_manager::clear(); community_manager::clear();
*APP_STATE.lock().unwrap() = AppState::new(); *APP_STATE.lock().unwrap() = AppState::new();
} }
ui.terminal.lock().unwrap().clear();
ui.terminal.lock().unwrap().flush();
} }
} }

View file

@ -1,66 +1,137 @@
use tokio::sync::oneshot;
use crate::{ use crate::{
gui::{
screens::{terms_checker::TermsCheckerScreen, terms_updater::TermsUpdaterScreen},
ui::UI,
},
terms::{ terms::{
doc::Doc, doc::Doc,
terms_checker,
terms_getter::{Type, get_current_docs, get_newest_docs}, terms_getter::{Type, get_current_docs, get_newest_docs},
terms_updater,
}, },
util::file_util::{load_file, save_file}, util::file_util::{load_file, save_file},
}; };
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
pub struct ConsentManager; pub struct ConsentManager;
impl ConsentManager { impl ConsentManager {
pub async fn check() -> (bool, bool) { pub async fn check(ui: Arc<UI>) -> (bool, bool) {
let (tx, rx) = oneshot::channel();
let file = load_file("", "agreements"); let file = load_file("", "agreements");
let (mut file_state, changed) = ConsentState::from_str(&file).sanitize(); let mut file_state = ConsentState::from_str(&file).sanitize();
ui.set_screen(Box::new(TermsCheckerScreen::new(ui.clone(), Some(tx))))
.await;
if changed { let result = rx.await.unwrap_or(UserChoice::Deny);
save_file("", "agreements", &file_state.to_string());
match result {
UserChoice::AcceptEULA | UserChoice::AcceptAll => {
if let Some((current_eula, current_tos, current_privacy)) = get_current_docs().await
{
file_state.accepted_eula = true;
file_state.eula = Some(current_eula);
if matches!(result, UserChoice::AcceptAll) {
file_state.accepted_tos = true;
file_state.accepted_pp = true;
file_state.tos = Some(current_tos);
file_state.privacy = Some(current_privacy);
}
}
}
UserChoice::Deny => {
return (false, false);
}
} }
if !file_state.accepted_eula { save_file("", "agreements", &file_state.to_string());
file_state = terms_checker::run_consent_ui(file_state).await;
save_file("", "agreements", &file_state.to_string());
}
if !file_state.accepted_eula {
return (false, false);
}
if let Some((eula_update, tos_update, privacy_update)) = Self::get_updates().await { if let Some((eula_update, tos_update, privacy_update)) = Self::get_updates().await {
let _is_forced = matches!(eula_update, UpdateDecision::Forced(_)) let _is_forced = matches!(eula_update, UpdateDecision::Forced(_))
|| matches!(tos_update, UpdateDecision::Forced(_)) || matches!(tos_update, UpdateDecision::Forced(_))
|| matches!(privacy_update, UpdateDecision::Forced(_)); || matches!(privacy_update, UpdateDecision::Forced(_));
let updater_logic = async move { if let Some((eula_update, tos_update, privacy_update)) = Self::get_updates().await {
let (state_to_update, _) = terms_updater::run_consent_ui( let is_forced = matches!(eula_update, UpdateDecision::Forced(_))
file_state, || matches!(tos_update, UpdateDecision::Forced(_))
eula_update, || matches!(privacy_update, UpdateDecision::Forced(_));
tos_update,
privacy_update,
)
.await
.sanitize();
save_file("", "agreements", &state_to_update.to_string()); let (tx, rx) = oneshot::channel();
};
// ======================================================== // ui.set_screen(Box::new(TermsUpdaterScreen::new(
// TODO: Implement UI Drawing order, draw update As PoPup // ui.clone(),
// ======================================================== // eula_update.clone(),
//if is_forced { tos_update.clone(),
updater_logic.await; privacy_update.clone(),
//} else { Some(tx),
//task::spawn(updater_logic); )))
//} .await;
if is_forced {
let result = rx.await.unwrap_or(UserChoice::Deny);
let file = load_file("", "agreements");
let mut state = ConsentState::from_str(&file).sanitize();
match result {
UserChoice::AcceptAll => {
state.accepted_eula = true;
state.accepted_tos = true;
state.accepted_pp = true;
}
UserChoice::AcceptEULA => {
state.accepted_eula = true;
}
UserChoice::Deny => {
return (false, false);
}
}
let state = state.sanitize();
save_file("", "agreements", &state.to_string());
} else {
tokio::spawn(async move {
let result = rx.await.unwrap_or(UserChoice::Deny);
let file = load_file("", "agreements");
let mut state = ConsentState::from_str(&file).sanitize();
match result {
UserChoice::AcceptAll => {
state.future_eula = match eula_update {
UpdateDecision::Future { newest } => Some(newest),
_ => state.future_eula,
};
state.future_tos = match tos_update {
UpdateDecision::Future { newest } => Some(newest),
_ => state.future_tos,
};
state.future_privacy = match privacy_update {
UpdateDecision::Future { newest } => Some(newest),
_ => state.future_privacy,
};
}
UserChoice::AcceptEULA => {
if let UpdateDecision::Future { newest } = eula_update {
state.future_eula = Some(newest);
}
}
UserChoice::Deny => {}
}
let state = state.sanitize();
save_file("", "agreements", &state.to_string());
});
}
}
} }
let file = load_file("", "agreements"); let file = load_file("", "agreements");
let (final_state, changed) = ConsentState::from_str(&file).sanitize(); let final_state = ConsentState::from_str(&file).sanitize();
if changed {
save_file("", "agreements", &final_state.to_string()); save_file("", "agreements", &final_state.to_string());
}
( (
final_state.accepted_eula, final_state.accepted_eula,
final_state.accepted_tos && final_state.accepted_pp, final_state.accepted_tos && final_state.accepted_pp,
@ -76,10 +147,9 @@ impl ConsentManager {
UpdateDecision, UpdateDecision,
)> { )> {
let file = load_file("", "agreements"); let file = load_file("", "agreements");
let (accepted_state, changed) = ConsentState::from_str(&file).sanitize(); let accepted_state = ConsentState::from_str(&file).sanitize();
if changed { save_file("", "agreements", &accepted_state.to_string());
save_file("", "agreements", &accepted_state.to_string());
}
if let ( if let (
Some((current_eula, current_tos, current_privacy)), Some((current_eula, current_tos, current_privacy)),
Some((newest_eula, newest_tos, newest_privacy)), Some((newest_eula, newest_tos, newest_privacy)),
@ -186,8 +256,7 @@ pub struct ConsentState {
} }
impl ConsentState { impl ConsentState {
fn sanitize(mut self) -> (Self, bool) { fn sanitize(mut self) -> Self {
let mut changed = false;
let current_secs = SystemTime::now() let current_secs = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap() .unwrap()
@ -197,30 +266,26 @@ impl ConsentState {
if future_eula.get_time() < current_secs { if future_eula.get_time() < current_secs {
self.eula = Some(future_eula); self.eula = Some(future_eula);
self.future_eula = None; self.future_eula = None;
changed = true;
} }
} }
if let Some(future_tos) = self.future_tos.clone() { if let Some(future_tos) = self.future_tos.clone() {
if future_tos.get_time() < current_secs { if future_tos.get_time() < current_secs {
self.tos = Some(future_tos); self.tos = Some(future_tos);
self.future_tos = None; self.future_tos = None;
changed = true;
} }
} }
if let Some(future_privacy) = self.future_privacy.clone() { if let Some(future_privacy) = self.future_privacy.clone() {
if future_privacy.get_time() < current_secs { if future_privacy.get_time() < current_secs {
self.privacy = Some(future_privacy); self.privacy = Some(future_privacy);
self.future_privacy = None; self.future_privacy = None;
changed = true;
} }
} }
if !self.accepted_eula { if !self.accepted_eula {
self.accepted_tos = false; self.accepted_tos = false;
self.accepted_pp = false; self.accepted_pp = false;
changed = true;
} }
(self, changed) self
} }
pub fn to_string(&self) -> String { pub fn to_string(&self) -> String {
@ -356,7 +421,7 @@ impl ConsentState {
pp_version = v.to_string(); pp_version = v.to_string();
} else if let Some(v) = line.strip_prefix("Privacy-Policy-HASH=") { } else if let Some(v) = line.strip_prefix("Privacy-Policy-HASH=") {
pp_hash = v.to_string(); pp_hash = v.to_string();
} else if let Some(v) = line.strip_prefix("UNIX=") { } else if let Some(v) = line.strip_prefix("UNIX-SECOND=") {
unix = v.to_string(); unix = v.to_string();
} else if let Some(v) = line.strip_prefix("FUTURE-EULA-VERSION=") { } else if let Some(v) = line.strip_prefix("FUTURE-EULA-VERSION=") {
future_eula_version = v.to_string(); future_eula_version = v.to_string();
@ -382,7 +447,7 @@ impl ConsentState {
let future_eula_time = future_eula_time.parse::<u64>().unwrap_or(0); let future_eula_time = future_eula_time.parse::<u64>().unwrap_or(0);
let future_tos_time = future_tos_time.parse::<u64>().unwrap_or(0); let future_tos_time = future_tos_time.parse::<u64>().unwrap_or(0);
let future_pp_time = future_pp_time.parse::<u64>().unwrap_or(0); let future_pp_time = future_pp_time.parse::<u64>().unwrap_or(0);
let (state, _) = Self { let state = Self {
accepted_eula: eula, accepted_eula: eula,
eula: Some(Doc::new(eula_version, eula_hash, Type::EULA, unix)), eula: Some(Doc::new(eula_version, eula_hash, Type::EULA, unix)),
accepted_pp: pp, accepted_pp: pp,

View file

@ -1,23 +1,62 @@
use crossterm::event::{self, Event, KeyCode}; use crossterm::event::{self, Event, KeyCode, KeyEvent};
use ratatui::{ use ratatui::{
DefaultTerminal, DefaultTerminal,
prelude::*, prelude::*,
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap}, widgets::{Block, Borders, Paragraph, Wrap},
}; };
use std::time::Duration; use std::{any::Any, sync::Arc, time::Duration};
use crate::gui::{interaction_result::InteractionResult, screens::screens::Screen, ui::UI};
#[derive(Default)]
pub struct FileViewer { pub struct FileViewer {
ui: Arc<UI>,
title: String, title: String,
text: Vec<DisplayLine>, text: Vec<DisplayLine>,
scroll: u16, scroll: u16,
scroll_x: 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 get_ui(&self) -> &Arc<UI> {
&self.ui
}
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 { impl FileViewer {
pub fn new(title: String, content: &str) -> Self { pub fn new(ui: Arc<UI>, title: String, content: &str) -> Self {
Self { Self {
ui,
title, title,
text: parse_document(content.to_owned()), text: parse_document(content.to_owned()),
scroll: 0, scroll: 0,

View file

@ -3,6 +3,4 @@ pub mod consent_state;
pub mod doc; pub mod doc;
pub mod focus; pub mod focus;
pub mod md_viewer; pub mod md_viewer;
pub mod terms_checker;
pub mod terms_getter; pub mod terms_getter;
pub mod terms_updater;

View file

@ -1,311 +0,0 @@
use crate::terms::{
buttons::{checkbox, draw_buttons},
consent_state::{ConsentState, UserChoice},
focus::Focus,
md_viewer::FileViewer,
terms_getter::{Type, get_current_docs, get_link, get_terms},
};
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
use std::time::Duration;
pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState {
let mut terminal = ratatui::init();
let (mut eula, mut tos, mut pp) = (false, false, false);
let mut focus = Focus::Eula;
let result = loop {
let mut too_small = false;
terminal
.draw(|f| {
let mut needed_height = 5;
let size = f.area();
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, eula, focus == Focus::Eula, true),
checkbox(tos_text, tos, focus == Focus::Tos, eula),
checkbox(pp_text, pp, focus == Focus::Pp, 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]")),
);
too_small = true;
f.render_widget(warning, size);
return;
}
let consent_block = Paragraph::new(Text::from(text_lines))
.block(Block::default().title(" Tensamin User Consent [Q to Quit] ").borders(Borders::ALL));
f.render_widget(consent_block, chunks[0]);
draw_buttons(f, chunks[1], focus, (eula, tos && pp), true, false, true);
})
.unwrap();
if event::poll(Duration::from_millis(200)).unwrap() {
if let Event::Key(key) = event::read().unwrap() {
if too_small {
if matches!(key.code, KeyCode::Char('q') | KeyCode::Char('Q')) {
break UserChoice::Deny;
} else {
continue;
}
}
let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel];
if eula {
possible_states.push(Focus::Continue);
if tos && pp {
possible_states.push(Focus::ContinueAll);
}
}
match key.code {
KeyCode::Esc => break UserChoice::Deny,
KeyCode::Up | KeyCode::Left => focus.prev(&possible_states),
KeyCode::Down | KeyCode::Right | KeyCode::Tab => focus.next(&possible_states),
KeyCode::Char('q') | KeyCode::Char('Q') => break UserChoice::Deny,
KeyCode::Char('o') | KeyCode::Char('O') => {
let terms_type = match focus {
Focus::Eula => Type::EULA,
Focus::Tos => Type::TOS,
Focus::Pp => Type::PP,
_ => continue,
};
if let Some(eula) = get_terms(terms_type.clone()).await {
terminal = FileViewer::new(terms_type.to_string(), &eula)
.force_popup(terminal);
} else {
terminal = FileViewer::new(
terms_type.to_string(),
"### A loading error occured\
\nTry reloading this site or retry in a moment.",
)
.force_popup(terminal);
}
}
KeyCode::Char('l') | KeyCode::Char('L') => {
let terms_type = match focus {
Focus::Eula => Type::EULA,
Focus::Tos => Type::TOS,
Focus::Pp => Type::PP,
_ => continue,
};
let _ = open::that(get_link(terms_type));
}
KeyCode::Char(' ') => match focus {
Focus::Eula => {
eula = !eula;
tos = false;
pp = false;
}
Focus::Tos => {
if eula {
tos = !tos
}
}
Focus::Pp => {
if eula {
pp = !pp
}
}
_ => {}
},
KeyCode::Enter => match focus {
Focus::Cancel => break UserChoice::Deny,
Focus::Continue if eula => break UserChoice::AcceptEULA,
Focus::ContinueAll if eula && tos && pp => {
break UserChoice::AcceptAll;
}
_ => {}
},
_ => {}
}
}
}
};
ratatui::restore();
if let Some((eula, tos, privacy)) = get_current_docs().await {
consent.eula = Some(eula);
consent.tos = Some(tos);
consent.privacy = Some(privacy);
}
match result {
UserChoice::AcceptAll => {
consent.accepted_eula = true;
consent.accepted_tos = true;
consent.accepted_pp = true;
}
UserChoice::AcceptEULA => {
consent.accepted_eula = true;
consent.accepted_tos = false;
consent.accepted_pp = false;
}
UserChoice::Deny => {
consent.accepted_eula = false;
consent.accepted_tos = false;
consent.accepted_pp = false;
}
};
consent
}

View file

@ -1,480 +0,0 @@
use crate::terms::{
buttons::{checkbox, draw_buttons},
consent_state::{ConsentState, UpdateDecision, UserChoice},
doc::Doc,
focus::Focus,
md_viewer::FileViewer,
terms_getter::{Type, get_newest_link, get_terms},
};
use chrono::{Local, TimeZone, Utc};
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
use std::time::Duration;
pub async fn run_consent_ui(
mut consent: ConsentState,
consent_eula: UpdateDecision,
consent_tos: UpdateDecision,
consent_pp: UpdateDecision,
) -> ConsentState {
let mut terminal = ratatui::init();
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 update_needed =
(eula_needed && !eula_future) || (tos_needed && !tos_future) || (pp_needed && !pp_future);
let (mut eula, mut tos, mut pp) = (!eula_needed, !tos_needed, !pp_needed);
let mut focus = if eula_needed {
Focus::Eula
} else if tos_needed {
Focus::Tos
} else if pp_needed {
Focus::Pp
} else {
Focus::Cancel
};
let result = loop {
let mut too_small = false;
terminal
.draw(|f| {
let mut needed_height = 5;
let size = f.area();
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 seperator = 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 eula_needed {
header_lines += 1;
if eula_future {
if size.width < 80 {
text_lines.push(checkbox("EULA ¹³ (https://legal.tensamin.net/eula/newest/)", eula, focus == Focus::Eula, true));
header_lines += 1;
let unix_timestamp = 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/)", eula, focus == Focus::Eula, true));
header_lines += 1;
let unix_timestamp = 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/)", eula, focus == Focus::Eula, true));
} else {
text_lines.push(checkbox("End User Licence Agreement ¹ (https://legal.tensamin.net/eula/newest/)", eula, focus == Focus::Eula, true));
}
}
}
if tos_needed {
header_lines += 1;
if tos_future {
if size.width < 80 {
text_lines.push(checkbox("ToS ²³ (https://legal.tensamin.net/tos/newest/)", tos, focus == Focus::Tos, eula));
header_lines += 1;
let unix_timestamp = 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/)", tos, focus == Focus::Tos, eula));
header_lines += 1;
let unix_timestamp = 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/)", tos, focus == Focus::Tos, eula));
} else {
text_lines.push(checkbox("Terms of Service ² (https://legal.tensamin.net/terms-of-service/newest/)", tos, focus == Focus::Tos, eula));
}
}
}
if pp_needed {
header_lines += 1;
if pp_future {
if size.width < 80 {
text_lines.push(checkbox("PP ²³ (https://legal.tensamin.net/privacy-policy/newest/)", pp, focus == Focus::Pp, eula));
header_lines += 1;
let unix_timestamp = 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/)", pp, focus == Focus::Pp, eula));
header_lines += 1;
let unix_timestamp = 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/)", pp, focus == Focus::Pp, eula));
} else {
text_lines.push(checkbox("Privacy Policy ² (https://legal.tensamin.net/privacy-policy/newest/)", pp, focus == Focus::Pp, 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 tos_needed || 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 tos_needed || pp_needed {
vec![header_lines + 7, header_lines, header_lines + 7, seperator, header_lines + 2]
} else {
vec![header_lines + 6, header_lines, header_lines + 6, seperator, header_lines + 2]
}
} else if size.width > 92 {
if tos_needed || 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 tos_needed || pp_needed {
vec![header_lines + 8, header_lines, header_lines + 8, seperator, header_lines + 2]
} else {
vec![header_lines + 7, header_lines, header_lines + 7, seperator, header_lines + 2]
}
} else if size.width > 73 {
if tos_needed || 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 tos_needed || pp_needed {
vec![header_lines + 8, header_lines, header_lines + 8, seperator, header_lines + 2]
} else {
vec![header_lines + 7, header_lines, header_lines + 7, seperator, header_lines + 2]
}
} else {
if tos_needed || 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 tos_needed || pp_needed {
vec![header_lines + 10, header_lines, header_lines + 11, seperator, header_lines + 2]
} else {
vec![header_lines + 8, header_lines, header_lines + 9, seperator, 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 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)),
);
too_small = true;
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 = tos_needed || pp_needed;
draw_buttons(
f,
chunks[1],
focus,
(eula, tos && pp),
update_needed,
downgrade_scenario,
pp_needed || tos_needed
);
})
.unwrap();
if event::poll(Duration::from_millis(200)).unwrap() {
if let Event::Key(key) = event::read().unwrap() {
if too_small {
if matches!(key.code, KeyCode::Char('q') | KeyCode::Char('Q')) {
break UserChoice::Deny;
} else {
continue;
}
}
let mut possible_states = vec![Focus::Eula];
if tos_needed {
possible_states.push(Focus::Tos);
}
if pp_needed {
possible_states.push(Focus::Pp);
}
possible_states.push(Focus::Cancel);
if eula {
possible_states.push(Focus::Continue);
if tos && pp && (pp_needed || tos_needed) {
possible_states.push(Focus::ContinueAll);
}
}
match key.code {
KeyCode::Esc => break UserChoice::Deny,
KeyCode::Up | KeyCode::Left => focus.prev(&possible_states),
KeyCode::Down | KeyCode::Right | KeyCode::Tab => focus.next(&possible_states),
KeyCode::Char('q') | KeyCode::Char('Q') => break UserChoice::Deny,
KeyCode::Char('o') | KeyCode::Char('O') => {
let terms_type = match focus {
Focus::Eula => Type::EULA,
Focus::Tos => Type::TOS,
Focus::Pp => Type::PP,
_ => continue,
};
if let Some(eula) = get_terms(terms_type.clone()).await {
terminal = FileViewer::new(terms_type.to_string(), &eula)
.force_popup(terminal);
} else {
terminal = FileViewer::new(
terms_type.to_string(),
"### A loading error occured\
\nTry reloading this site or retry in a moment.",
)
.force_popup(terminal);
}
}
KeyCode::Char('l') | KeyCode::Char('L') => {
let terms_type = match focus {
Focus::Eula => Type::EULA,
Focus::Tos => Type::TOS,
Focus::Pp => Type::PP,
_ => continue,
};
let _ = open::that(get_newest_link(terms_type));
}
KeyCode::Char(' ') | KeyCode::Enter => match focus {
Focus::Eula => {
eula = !eula;
tos = !tos_needed;
pp = !pp_needed;
}
Focus::Tos => {
if eula {
tos = !tos
}
}
Focus::Pp => {
if eula {
pp = !pp
}
}
Focus::Cancel => break UserChoice::Deny,
Focus::Continue if eula => break UserChoice::AcceptEULA,
Focus::ContinueAll if eula && tos && pp => {
break UserChoice::AcceptAll;
}
_ => {}
},
_ => {}
}
}
}
};
ratatui::restore();
match result {
UserChoice::AcceptAll => {
consent.accepted_eula = true;
consent.accepted_tos = true;
consent.accepted_pp = true;
if let Some(_) = eula_for_future {
consent.future_eula = eula_for_future;
}
if let Some(_) = tos_for_future {
consent.future_tos = tos_for_future;
}
if let Some(_) = pp_for_future {
consent.future_privacy = pp_for_future;
}
}
UserChoice::AcceptEULA => {
consent.accepted_eula = true;
if let Some(_) = eula_for_future {
consent.future_eula = eula_for_future;
}
}
UserChoice::Deny => {}
}
consent
}