[WIP] Consent UI, via main UI render System
This commit is contained in:
parent
6db4e806f8
commit
a0fddf723f
18 changed files with 1625 additions and 1040 deletions
|
|
@ -10,7 +10,6 @@ use ratatui::{
|
|||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
};
|
||||
use std::any::Any;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UiLogEntry {
|
||||
|
|
@ -89,13 +88,13 @@ impl InteractableElement for LogCard {
|
|||
if self.scroll > 0 {
|
||||
self.scroll -= 1;
|
||||
}
|
||||
InteractionResult::Handeled
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.scroll += 1;
|
||||
InteractionResult::Handeled
|
||||
InteractionResult::Handled
|
||||
}
|
||||
_ => InteractionResult::Unhandeled,
|
||||
_ => InteractionResult::Unhandled,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
66
src/gui/input_handler.rs
Normal file
66
src/gui/input_handler.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,14 +5,15 @@ use std::pin::Pin;
|
|||
use crate::gui::screens::screens::Screen;
|
||||
|
||||
pub enum InteractionResult {
|
||||
CloseScreen,
|
||||
OpenScreen {
|
||||
screen: Box<dyn Screen>,
|
||||
},
|
||||
OpenFutureScreen {
|
||||
screen: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>>,
|
||||
},
|
||||
Handeled,
|
||||
Unhandeled,
|
||||
Handled,
|
||||
Unhandled,
|
||||
}
|
||||
|
||||
impl Debug for InteractionResult {
|
||||
|
|
@ -20,8 +21,9 @@ impl Debug for InteractionResult {
|
|||
match self {
|
||||
InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"),
|
||||
InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"),
|
||||
InteractionResult::Handeled => write!(f, "Handeled"),
|
||||
InteractionResult::Unhandeled => write!(f, "Unhandeled"),
|
||||
InteractionResult::CloseScreen => write!(f, "CloseScreen"),
|
||||
InteractionResult::Handled => write!(f, "Handled"),
|
||||
InteractionResult::Unhandled => write!(f, "Unhandled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ pub mod elements {
|
|||
}
|
||||
pub mod screens {
|
||||
pub mod screens;
|
||||
pub mod terms_checker;
|
||||
pub mod terms_updater;
|
||||
}
|
||||
pub mod app_state;
|
||||
pub mod input_handler;
|
||||
pub mod interaction_result;
|
||||
pub mod ui;
|
||||
|
|
|
|||
353
src/gui/screens/terms_checker.rs
Normal file
353
src/gui/screens/terms_checker.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
701
src/gui/screens/terms_updater.rs
Normal file
701
src/gui/screens/terms_updater.rs
Normal 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 Tensamin’s legal documents.",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"Some of them have been updated and are listed below for your review.",
|
||||
));
|
||||
text_lines.push(Line::from(""));
|
||||
2
|
||||
} else {
|
||||
header_lines += 5;
|
||||
text_lines.push(Line::from("You previously accepted earlier "));
|
||||
text_lines.push(Line::from("versions of Tensamin’s legal documents."));
|
||||
text_lines.push(Line::from("Some of them have been updated and"));
|
||||
text_lines.push(Line::from("are listed below for your review."));
|
||||
text_lines.push(Line::from(""));
|
||||
4
|
||||
};
|
||||
|
||||
if self.eula_needed {
|
||||
header_lines += 1;
|
||||
if self.eula_future {
|
||||
if size.width < 80 {
|
||||
text_lines.push(checkbox(
|
||||
"EULA ¹³ (https://legal.tensamin.net/eula/newest/)",
|
||||
self.eula,
|
||||
self.focus == Focus::Eula,
|
||||
true,
|
||||
));
|
||||
|
||||
header_lines += 1;
|
||||
let unix_timestamp = self.eula_for_future.clone().unwrap().get_time() as i64;
|
||||
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
|
||||
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
|
||||
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
|
||||
} else {
|
||||
text_lines.push(checkbox(
|
||||
"End User Licence Agreement ¹³ (https://legal.tensamin.net/eula/newest/)",
|
||||
self.eula,
|
||||
self.focus == Focus::Eula,
|
||||
true,
|
||||
));
|
||||
|
||||
header_lines += 1;
|
||||
let unix_timestamp = self.eula_for_future.clone().unwrap().get_time() as i64;
|
||||
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
|
||||
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
|
||||
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
|
||||
}
|
||||
} else {
|
||||
if size.width < 80 {
|
||||
text_lines.push(checkbox(
|
||||
"EULA ¹ (https://legal.tensamin.net/eula/newest/)",
|
||||
self.eula,
|
||||
self.focus == Focus::Eula,
|
||||
true,
|
||||
));
|
||||
} else {
|
||||
text_lines.push(checkbox(
|
||||
"End User Licence Agreement ¹ (https://legal.tensamin.net/eula/newest/)",
|
||||
self.eula,
|
||||
self.focus == Focus::Eula,
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.tos_needed {
|
||||
header_lines += 1;
|
||||
if self.tos_future {
|
||||
if size.width < 80 {
|
||||
text_lines.push(checkbox(
|
||||
"ToS ²³ (https://legal.tensamin.net/tos/newest/)",
|
||||
self.tos,
|
||||
self.focus == Focus::Tos,
|
||||
self.eula,
|
||||
));
|
||||
|
||||
header_lines += 1;
|
||||
let unix_timestamp = self.tos_for_future.clone().unwrap().get_time() as i64;
|
||||
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
|
||||
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
|
||||
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
|
||||
} else {
|
||||
text_lines.push(checkbox(
|
||||
"Terms of Service ²³ (https://legal.tensamin.net/terms-of-service/newest/)",
|
||||
self.tos,
|
||||
self.focus == Focus::Tos,
|
||||
self.eula,
|
||||
));
|
||||
|
||||
header_lines += 1;
|
||||
let unix_timestamp = self.tos_for_future.clone().unwrap().get_time() as i64;
|
||||
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
|
||||
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
|
||||
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
|
||||
}
|
||||
} else {
|
||||
if size.width < 80 {
|
||||
text_lines.push(checkbox(
|
||||
"ToS ² (https://legal.tensamin.net/tos/newest/)",
|
||||
self.tos,
|
||||
self.focus == Focus::Tos,
|
||||
self.eula,
|
||||
));
|
||||
} else {
|
||||
text_lines.push(checkbox(
|
||||
"Terms of Service ² (https://legal.tensamin.net/terms-of-service/newest/)",
|
||||
self.tos,
|
||||
self.focus == Focus::Tos,
|
||||
self.eula,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.pp_needed {
|
||||
header_lines += 1;
|
||||
if self.pp_future {
|
||||
if size.width < 80 {
|
||||
text_lines.push(checkbox(
|
||||
"PP ²³ (https://legal.tensamin.net/privacy-policy/newest/)",
|
||||
self.pp,
|
||||
self.focus == Focus::Pp,
|
||||
self.eula,
|
||||
));
|
||||
|
||||
header_lines += 1;
|
||||
let unix_timestamp = self.pp_for_future.clone().unwrap().get_time() as i64;
|
||||
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
|
||||
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
|
||||
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
|
||||
} else {
|
||||
text_lines.push(checkbox(
|
||||
"Privacy Policy ²³ (https://legal.tensamin.net/privacy-policy/newest/)",
|
||||
self.pp,
|
||||
self.focus == Focus::Pp,
|
||||
self.eula,
|
||||
));
|
||||
|
||||
header_lines += 1;
|
||||
let unix_timestamp = self.pp_for_future.clone().unwrap().get_time() as i64;
|
||||
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
|
||||
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
|
||||
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
|
||||
}
|
||||
} else {
|
||||
if size.width < 80 {
|
||||
text_lines.push(checkbox(
|
||||
"PP ² (https://legal.tensamin.net/privacy-policy/newest/)",
|
||||
self.pp,
|
||||
self.focus == Focus::Pp,
|
||||
self.eula,
|
||||
));
|
||||
} else {
|
||||
text_lines.push(checkbox(
|
||||
"Privacy Policy ² (https://legal.tensamin.net/privacy-policy/newest/)",
|
||||
self.pp,
|
||||
self.focus == Focus::Pp,
|
||||
self.eula,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from("¹ Necessary to run the program"));
|
||||
text_lines.push(Line::from("² Optional - only for Tensamin services"));
|
||||
if size.width < 100 {
|
||||
text_lines.push(Line::from(
|
||||
"³ Future version - consent stored now, takes effect later",
|
||||
));
|
||||
} else {
|
||||
text_lines.push(Line::from("³ Future version - You’ll continue using this version, automatically updated when changes apply."));
|
||||
}
|
||||
|
||||
text_lines.push(Line::from(""));
|
||||
|
||||
let mut optional_lines: Vec<i16> = if size.width > 143 {
|
||||
if self.tos_needed || self.pp_needed {
|
||||
text_lines.push(Line::from("By selecting Downgrade, you confirm that you agree to the End User License Agreement and applicable Terms of Service."));
|
||||
text_lines.push(Line::from(
|
||||
"On Downgrade: Tensamin Services will deactivate once these changes apply.",
|
||||
));
|
||||
} else {
|
||||
text_lines.push(Line::from("By selecting Continue, you confirm that you agree to the End User License Agreement and applicable Terms of Service."));
|
||||
}
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from(
|
||||
"Tensamin services require acceptance of the Terms of Service and Privacy Policy.",
|
||||
));
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from("While having a document selected press O to view in this UI or press L to open as a link."));
|
||||
|
||||
if self.tos_needed || self.pp_needed {
|
||||
vec![
|
||||
header_lines + 7,
|
||||
header_lines,
|
||||
header_lines + 7,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
header_lines + 6,
|
||||
header_lines,
|
||||
header_lines + 6,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
}
|
||||
} else if size.width > 92 {
|
||||
if self.tos_needed || self.pp_needed {
|
||||
text_lines.push(Line::from(
|
||||
"By selecting Continue, you confirm that you agree to the End User",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"License Agreement and applicable Terms of Service.",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"On Downgrade: Tensamin Services will deactivate once these changes apply.",
|
||||
));
|
||||
} else {
|
||||
text_lines.push(Line::from(
|
||||
"By selecting Continue, you confirm that you agree to the End User",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"License Agreement and applicable Terms of Service.",
|
||||
));
|
||||
}
|
||||
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from(
|
||||
"Tensamin services require acceptance of the Terms of Service and Privacy Policy.",
|
||||
));
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from("While having a document selected press O to view in this UI or press L to open as a link."));
|
||||
|
||||
if self.tos_needed || self.pp_needed {
|
||||
vec![
|
||||
header_lines + 8,
|
||||
header_lines,
|
||||
header_lines + 8,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
header_lines + 7,
|
||||
header_lines,
|
||||
header_lines + 7,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
}
|
||||
} else if size.width > 73 {
|
||||
if self.tos_needed || self.pp_needed {
|
||||
text_lines.push(Line::from(
|
||||
"By selecting Continue, you confirm that you read, understood and",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"agree to the End User License Agreement and applicable Terms of Service.",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"On Downgrade: Tensamin Services will deactivate once these changes apply.",
|
||||
));
|
||||
} else {
|
||||
text_lines.push(Line::from(
|
||||
"By selecting Continue, you confirm that you read, understood and",
|
||||
));
|
||||
text_lines.push(Line::from(
|
||||
"agree to the End User License Agreement and applicable Terms of Service.",
|
||||
));
|
||||
}
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from(
|
||||
"Tensamin services require acceptance of the ToS and Privacy Policy.",
|
||||
));
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from(
|
||||
"While having a document selected press O to view in this UI or press L",
|
||||
));
|
||||
text_lines.push(Line::from("to open as a link."));
|
||||
|
||||
if self.tos_needed || self.pp_needed {
|
||||
vec![
|
||||
header_lines + 8,
|
||||
header_lines,
|
||||
header_lines + 8,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
header_lines + 7,
|
||||
header_lines,
|
||||
header_lines + 7,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
}
|
||||
} else {
|
||||
if self.tos_needed || self.pp_needed {
|
||||
text_lines.push(Line::from("By selecting Continue, you confirm that you"));
|
||||
text_lines.push(Line::from("agree to the End User License"));
|
||||
text_lines.push(Line::from("Agreement and applicable Terms of Service."));
|
||||
text_lines.push(Line::from(
|
||||
"On Downgrade: Tensamin Services will deactivate",
|
||||
));
|
||||
text_lines.push(Line::from("once these changes apply."));
|
||||
} else {
|
||||
text_lines.push(Line::from("By selecting Continue, you confirm that you"));
|
||||
text_lines.push(Line::from("agree to the End User License"));
|
||||
text_lines.push(Line::from("Agreement and applicable Terms of Service."));
|
||||
}
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from("Tensamin services require acceptance of the"));
|
||||
text_lines.push(Line::from("Terms of Service and Privacy Policy."));
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from(
|
||||
"While having a document selected press O to view",
|
||||
));
|
||||
text_lines.push(Line::from("in this UI or press L to open as a link."));
|
||||
if self.tos_needed || self.pp_needed {
|
||||
vec![
|
||||
header_lines + 10,
|
||||
header_lines,
|
||||
header_lines + 11,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
header_lines + 8,
|
||||
header_lines,
|
||||
header_lines + 9,
|
||||
separator,
|
||||
header_lines + 2,
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
while size.height - 5 < text_lines.len() as u16 {
|
||||
if optional_lines.len() == 0 {
|
||||
break;
|
||||
}
|
||||
text_lines.remove(optional_lines[0] as usize);
|
||||
optional_lines.remove(0);
|
||||
}
|
||||
needed_height += text_lines.len();
|
||||
|
||||
let q_informer = if self.update_needed {
|
||||
"Q to Exit"
|
||||
} else {
|
||||
"Q to Cancel"
|
||||
};
|
||||
if size.width < 60 || size.height < needed_height as u16 {
|
||||
let width_style = if size.width > 76 {
|
||||
Style::default().fg(Color::Green)
|
||||
} else if size.width >= 60 {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
};
|
||||
|
||||
let height_style = if size.height > 20 {
|
||||
Style::default().fg(Color::Green)
|
||||
} else if size.height >= (header_lines as u16 + 10) {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
};
|
||||
|
||||
let warning_text = Text::from(vec![
|
||||
Line::from(vec![
|
||||
Span::raw("Width: "),
|
||||
Span::styled(format!("{}", size.width), width_style),
|
||||
Span::raw(" / 60"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw("Height: "),
|
||||
Span::styled(format!("{}", size.height), height_style),
|
||||
Span::raw(format!(" / {}", header_lines + 10)),
|
||||
]),
|
||||
]);
|
||||
|
||||
let warning = Paragraph::new(warning_text)
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!("UI Too Small [{}]", q_informer)),
|
||||
);
|
||||
|
||||
f.render_widget(warning, size);
|
||||
return;
|
||||
}
|
||||
|
||||
let consent_block = Paragraph::new(Text::from(text_lines)).block(
|
||||
Block::default()
|
||||
.title(format!(" Update Tensamin User Consent [{}] ", q_informer))
|
||||
.borders(Borders::ALL),
|
||||
);
|
||||
f.render_widget(consent_block, chunks[0]);
|
||||
|
||||
let downgrade_scenario = self.tos_needed || self.pp_needed;
|
||||
draw_buttons(
|
||||
f,
|
||||
chunks[1],
|
||||
self.focus,
|
||||
(self.eula, self.tos && self.pp),
|
||||
self.update_needed,
|
||||
downgrade_scenario,
|
||||
self.pp_needed || self.tos_needed,
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
||||
let mut possible_states = Vec::new();
|
||||
|
||||
if self.eula_needed {
|
||||
possible_states.push(Focus::Eula);
|
||||
}
|
||||
if self.tos_needed {
|
||||
possible_states.push(Focus::Tos);
|
||||
}
|
||||
if self.pp_needed {
|
||||
possible_states.push(Focus::Pp);
|
||||
}
|
||||
|
||||
possible_states.push(Focus::Cancel);
|
||||
|
||||
if self.eula {
|
||||
possible_states.push(Focus::Continue);
|
||||
if self.tos && self.pp {
|
||||
possible_states.push(Focus::ContinueAll);
|
||||
}
|
||||
}
|
||||
|
||||
match event.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
|
||||
if let Some(sender) = self.sender.take() {
|
||||
let _ = sender.send(UserChoice::Deny);
|
||||
}
|
||||
return InteractionResult::CloseScreen;
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => match self.focus {
|
||||
Focus::Eula => {
|
||||
self.eula = !self.eula;
|
||||
self.tos = !self.tos_needed;
|
||||
self.pp = !self.pp_needed;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Tos if self.eula => {
|
||||
self.tos = !self.tos;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Pp if self.eula => {
|
||||
self.pp = !self.pp;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Cancel => {
|
||||
if let Some(sender) = self.sender.take() {
|
||||
let _ = sender.send(UserChoice::Deny);
|
||||
}
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
Focus::Continue if self.eula => {
|
||||
if let Some(sender) = self.sender.take() {
|
||||
let _ = sender.send(UserChoice::AcceptEULA);
|
||||
}
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
Focus::ContinueAll if self.eula && self.tos && self.pp => {
|
||||
if let Some(sender) = self.sender.take() {
|
||||
let _ = sender.send(UserChoice::AcceptAll);
|
||||
}
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
_ => InteractionResult::Unhandled,
|
||||
},
|
||||
KeyCode::Char('o') | KeyCode::Char('O') => {
|
||||
let terms_type = match self.focus {
|
||||
Focus::Eula => Some(Type::EULA),
|
||||
Focus::Tos => Some(Type::TOS),
|
||||
Focus::Pp => Some(Type::PP),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(terms_type) = terms_type {
|
||||
let 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
215
src/gui/ui.rs
215
src/gui/ui.rs
|
|
@ -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};
|
||||
|
||||
struct ResizableBackend<'a> {
|
||||
backend: CrosstermBackend<&'a mut Vec<u8>>,
|
||||
size: Size,
|
||||
}
|
||||
|
||||
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!()
|
||||
}
|
||||
}
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::{Terminal, backend::CrosstermBackend, init};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// UI state and rendering
|
||||
|
||||
pub struct UI {
|
||||
pub cols: Arc<Mutex<u16>>,
|
||||
pub rows: Arc<Mutex<u16>>,
|
||||
screen: Arc<Mutex<Option<Box<dyn Screen>>>>,
|
||||
|
||||
cached_render: Arc<Mutex<Vec<u8>>>,
|
||||
pub terminal: Arc<Mutex<Terminal<CrosstermBackend<std::io::Stdout>>>>,
|
||||
screen: Arc<RwLock<Option<Box<dyn Screen>>>>,
|
||||
}
|
||||
|
||||
impl UI {
|
||||
pub fn new(cols: u16, rows: u16) -> Self {
|
||||
pub fn new() -> Self {
|
||||
let terminal = init();
|
||||
Self {
|
||||
cols: Arc::new(Mutex::new(cols)),
|
||||
rows: Arc::new(Mutex::new(rows)),
|
||||
screen: Arc::new(Mutex::new(None)),
|
||||
cached_render: Arc::new(Mutex::new(Vec::new())),
|
||||
terminal: Arc::new(Mutex::new(terminal)),
|
||||
screen: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_screen(&self, screen: Box<dyn Screen>) {
|
||||
*self.screen.lock().unwrap() = Some(screen);
|
||||
pub async fn set_screen(&self, screen: Box<dyn Screen>) {
|
||||
*self.screen.write().await = Some(screen);
|
||||
}
|
||||
|
||||
pub fn resize(&self, cols: u32, rows: u32) {
|
||||
*self.cols.lock().unwrap() = cols as u16;
|
||||
*self.rows.lock().unwrap() = rows as u16;
|
||||
}
|
||||
|
||||
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))
|
||||
pub async fn handle_input(self: Arc<Self>, key_event: KeyEvent) {
|
||||
let result = {
|
||||
let mut guard = self.screen.write().await;
|
||||
if let Some(screen) = guard.as_mut() {
|
||||
screen.handle_input(key_event)
|
||||
} else {
|
||||
None
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let event = match input {
|
||||
b"\x1b[A" => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)),
|
||||
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);
|
||||
}
|
||||
_ => {}
|
||||
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 => {
|
||||
/* TODO
|
||||
self.set_screen();
|
||||
*/
|
||||
}
|
||||
InteractionResult::Handled => {}
|
||||
InteractionResult::Unhandled => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
|
||||
execute!(
|
||||
&mut buf,
|
||||
EnterAlternateScreen,
|
||||
Clear(ClearType::All),
|
||||
MoveTo(0, 0)
|
||||
)
|
||||
.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()));
|
||||
}
|
||||
pub async fn render(&self) {
|
||||
if let Some(screen) = self.screen.read().await.as_ref() {
|
||||
let mut terminal = self.terminal.lock().unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
screen.render(f, f.area());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
*self.cached_render.lock().unwrap() = buf.clone();
|
||||
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue