[Add] basic split

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

View file

@ -0,0 +1,242 @@
use crate::{
elements::{
console_card::ConsoleCard,
elements::{InteractableElement, JoinableElement},
graph_card::{GRAPHS, GraphCard},
log_card::LogCard,
},
interaction_result::InteractionResult,
screens::screens::{NavDirection, Screen},
ui::UI,
};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::{Constraint, Layout, Margin, Rect},
widgets::{Block, Borders},
};
use std::{any::Any, sync::Arc};
pub struct MainScreen {
elements: Vec<Box<dyn InteractableElement>>,
nav_grid: Vec<Vec<Option<usize>>>,
selected_coords: (usize, usize),
graphs_open: bool,
}
impl MainScreen {
pub async fn new(ui: Arc<UI>) -> Self {
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
let nav_grid = vec![
vec![Some(0), Some(2)],
vec![Some(0), Some(3)],
vec![Some(1), Some(4)],
];
let mut log_card = LogCard::new();
log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT));
let mut console_card = ConsoleCard::new("Console", "");
console_card.set_joins(Borders::TOP);
elements.push(Box::new(log_card));
elements.push(Box::new(console_card));
let mut ram_graph = GraphCard::new(ui.clone(), GRAPHS::Ram, "RAM".into());
ram_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
elements.push(Box::new(ram_graph));
let mut cpu_graph = GraphCard::new(ui.clone(), GRAPHS::Cpu, "CPU".into());
cpu_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
cpu_graph.set_joins(Borders::TOP);
elements.push(Box::new(cpu_graph));
let mut ping_graph = GraphCard::new(ui.clone(), GRAPHS::Ping, "Ping".into());
ping_graph.set_joins(Borders::TOP);
elements.push(Box::new(ping_graph));
let graphs_open = true;
let mut screen = MainScreen {
elements,
nav_grid,
selected_coords: (1, 0),
graphs_open,
};
screen.focus_current();
screen
}
fn focus_current(&mut self) {
let (y, x) = self.selected_coords;
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) {
if let Some(element) = self.elements.get_mut(*index) {
if element.can_focus() {
element.focus(true);
}
}
}
}
fn unfocus_current(&mut self, y: usize, x: usize) {
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) {
if let Some(element) = self.elements.get_mut(*index) {
element.focus(false);
}
}
}
fn navigate(&mut self, direction: NavDirection) {
let (current_row, current_col) = self.selected_coords;
let current_element = self.nav_grid[current_row][current_col];
self.unfocus_current(current_row, current_col);
let (delta_row, delta_col) = match direction {
NavDirection::Up => (-1isize, 0),
NavDirection::Down => (1, 0),
NavDirection::Left => (0, -1),
NavDirection::Right => (0, 1),
_ => (0, 0),
};
let mut next_row = current_row as isize;
let mut next_col = current_col as isize;
loop {
next_row += delta_row;
next_col += delta_col;
if next_row < 0 || next_col < 0 {
self.selected_coords = (
(next_row - delta_row) as usize,
(next_col - delta_col) as usize,
);
break;
}
let next_row_u = next_row as usize;
let next_col_u = next_col as usize;
if next_row_u >= self.nav_grid.len() {
self.selected_coords = (
(next_row - delta_row) as usize,
(next_col - delta_col) as usize,
);
break;
}
if let Some(row) = self.nav_grid.get(next_row_u) {
if next_col_u >= row.len() {
self.selected_coords = (
(next_row - delta_row) as usize,
(next_col - delta_col) as usize,
);
break;
}
if let Some(next_element) = row[next_col_u] {
if Some(next_element) != current_element {
self.selected_coords = (next_row_u, next_col_u);
self.focus_current();
return;
}
}
}
}
self.focus_current();
}
}
impl Screen for MainScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, rect: Rect) {
let main_block = Block::default().title("Main").borders(Borders::ALL);
f.render_widget(main_block, rect);
let inner = rect.inner(Margin {
vertical: 1,
horizontal: 1,
});
let graphs_width = if self.graphs_open { 30 } else { 2 };
let main_width = inner.width.saturating_sub(graphs_width);
let horizontal_chunks = Layout::default()
.direction(ratatui::layout::Direction::Horizontal)
.constraints([
Constraint::Length(main_width),
Constraint::Length(graphs_width),
])
.split(inner);
let left_area = horizontal_chunks[0];
let right_area = horizontal_chunks[1];
let left_rows =
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area);
if let Some(log) = self.elements.get(0) {
log.as_element().render(f, left_rows[0]);
}
if let Some(console) = self.elements.get(1) {
console.as_element().render(f, left_rows[1]);
}
let graph_elements: Vec<_> = self
.elements
.iter()
.filter(|el| el.as_any().is::<GraphCard>())
.collect();
if !graph_elements.is_empty() {
let graph_chunks = Layout::vertical(
graph_elements
.iter()
.map(|_| Constraint::Ratio(1, graph_elements.len() as u32))
.collect::<Vec<_>>(),
)
.split(right_area);
for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) {
el.as_element().render(f, *area);
}
}
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
match event.code {
KeyCode::Up => self.navigate(NavDirection::Up),
KeyCode::Down => self.navigate(NavDirection::Down),
KeyCode::Left => self.navigate(NavDirection::Left),
KeyCode::Right => self.navigate(NavDirection::Right),
KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => {
self.graphs_open = !self.graphs_open;
for element in self.elements.iter_mut() {
if let Some(graph) = element.as_any_mut().downcast_mut::<GraphCard>() {
graph.set_open(self.graphs_open);
}
}
return InteractionResult::Handled;
}
_ => {
let (y, x) = self.selected_coords;
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|r| r.get(x)) {
if let Some(el) = self.elements.get_mut(*index) {
return el.interact(event);
}
}
}
}
InteractionResult::Handled
}
}

View file

@ -0,0 +1,493 @@
use crossterm::event::{self, Event, KeyCode, KeyEvent};
use ratatui::{
DefaultTerminal,
prelude::*,
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
};
use std::{any::Any, time::Duration};
use crate::{interaction_result::InteractionResult, screens::screens::Screen};
pub struct FileViewer {
title: String,
text: Vec<DisplayLine>,
scroll: u16,
scroll_x: u16,
}
impl Screen for FileViewer {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, rect: Rect) {
self.draw(f, rect);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
match event.code {
KeyCode::Char('q') | KeyCode::Esc => {
return InteractionResult::CloseScreen;
}
KeyCode::Down => self.scroll = self.scroll.saturating_add(1),
KeyCode::Up => self.scroll = self.scroll.saturating_sub(1),
KeyCode::PageDown => self.scroll = self.scroll.saturating_add(10),
KeyCode::PageUp => self.scroll = self.scroll.saturating_sub(10),
KeyCode::Right => self.scroll_x = self.scroll_x.saturating_add(2),
KeyCode::Left => self.scroll_x = self.scroll_x.saturating_sub(2),
_ => {}
}
InteractionResult::Unhandled
}
}
impl FileViewer {
pub fn new(title: String, content: &str) -> Self {
Self {
title,
text: parse_document(content.to_owned()),
scroll: 0,
scroll_x: 0,
}
}
pub fn force_popup(mut self, mut terminal: DefaultTerminal) -> DefaultTerminal {
loop {
terminal
.draw(|f| {
let area = f.area();
self.draw(f, area);
})
.unwrap();
if event::poll(Duration::from_millis(100)).unwrap() {
let ev = event::read().unwrap();
self.handle_event(&ev);
if matches!(ev, Event::Key(k) if k.code == KeyCode::Char('q')) {
break;
}
}
}
terminal
}
fn draw(&self, f: &mut Frame, area: Rect) {
use ratatui::text::Text;
let mut rendered_lines = Vec::new();
for display_line in &self.text {
if display_line.scrollable {
let content: String = display_line
.line
.spans
.iter()
.map(|s| s.content.clone())
.collect();
let start = self.scroll_x as usize;
let width = area.width as usize - 2;
let visible = if start < content.chars().count() {
content.chars().skip(start).take(width).collect()
} else {
String::new()
};
let mut chars: Vec<char> = visible.chars().collect();
if start > 0 && !chars.is_empty() {
chars[0] = '<';
}
if start + width < content.chars().count() && !chars.is_empty() {
let last = chars.len() - 1;
chars[last] = '>';
}
let visible: String = chars.into_iter().collect();
rendered_lines.push(Line::from(Span::styled(
visible,
display_line
.line
.spans
.first()
.map(|s| s.style)
.unwrap_or_default(),
)));
} else {
rendered_lines.push(display_line.line.clone());
}
}
let paragraph = Paragraph::new(Text::from(rendered_lines))
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("{} - [Q to close]", self.title.as_str(),)),
)
.wrap(Wrap { trim: false })
.scroll((self.scroll, 0));
f.render_widget(paragraph, area);
}
pub fn handle_event(&mut self, event: &Event) {
if let Event::Key(key) = event {
match key.code {
KeyCode::Down => self.scroll = self.scroll.saturating_add(1),
KeyCode::Up => self.scroll = self.scroll.saturating_sub(1),
KeyCode::PageDown => self.scroll = self.scroll.saturating_add(10),
KeyCode::PageUp => self.scroll = self.scroll.saturating_sub(10),
KeyCode::Right => self.scroll_x = self.scroll_x.saturating_add(2),
KeyCode::Left => self.scroll_x = self.scroll_x.saturating_sub(2),
_ => {}
}
}
}
}
fn parse_document(input: String) -> Vec<DisplayLine> {
let mut lines_vec = Vec::new();
let mut in_code_block = false;
let liness: Vec<String> = input.lines().map(String::from).collect();
let mut i = 0;
while i < liness.len() {
let raw = &liness[i];
if raw.trim().starts_with("```") {
in_code_block = !in_code_block;
let code: String = if raw.trim().replace("```", "").is_empty() {
"──".to_string()
} else {
raw.trim().replace("```", "")
};
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
format!("────────{}────────", code),
Style::default().fg(Color::DarkGray),
)),
scrollable: false,
});
i += 1;
continue;
}
if in_code_block {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.to_string(),
Style::default().fg(Color::Yellow),
)),
scrollable: false,
});
i += 1;
continue;
}
if raw.starts_with("### ") {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.trim_start_matches("### ").to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
scrollable: false,
});
i += 1;
continue;
}
if raw.starts_with("## ") {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.trim_start_matches("## ").to_string(),
Style::default()
.fg(Color::LightCyan)
.add_modifier(Modifier::BOLD),
)),
scrollable: false,
});
i += 1;
continue;
}
if raw.starts_with("# ") {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.trim_start_matches("# ").to_string(),
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
)),
scrollable: false,
});
i += 1;
continue;
}
if raw.trim_start().starts_with("- ") {
let indent = raw.chars().take_while(|c| *c == ' ').count();
lines_vec.push(DisplayLine {
line: Line::from(Span::raw(format!(
"{}• {}",
" ".repeat(indent),
raw.trim_start_matches("- ")
))),
scrollable: false,
});
i += 1;
continue;
}
if raw.trim().starts_with('|') && raw.contains('|') {
let mut table_lines = vec![raw.clone()];
let mut j = i + 1;
while j < liness.len() && liness[j].trim().starts_with('|') {
table_lines.push(liness[j].clone());
j += 1;
}
let table = parse_table(&table_lines.iter().map(|s| s.as_str()).collect::<Vec<_>>());
lines_vec.extend(table_to_lines(table));
i = j;
continue;
}
lines_vec.push(DisplayLine {
line: Line::from(parse_inline(raw.as_str())),
scrollable: false,
});
i += 1;
}
lines_vec
}
fn parse_inline(input: &str) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut buf = String::new();
let mut bold = false;
let mut underline = false;
let mut code = false;
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
let toggle = match c {
'*' if chars.peek() == Some(&'*') => {
chars.next();
Some("bold")
}
'_' if chars.peek() == Some(&'_') => {
chars.next();
Some("underline")
}
'`' => Some("code"),
_ => None,
};
if let Some(kind) = toggle {
flush_span(&mut spans, &mut buf, current_style(bold, underline, code));
match kind {
"bold" => bold = !bold,
"underline" => underline = !underline,
"code" => code = !code,
_ => {}
}
continue;
}
buf.push(c);
}
flush_span(&mut spans, &mut buf, current_style(bold, underline, code));
spans
}
fn current_style(bold: bool, underline: bool, code: bool) -> Style {
let mut style = Style::default();
if bold {
style = style.add_modifier(Modifier::BOLD);
}
if underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
if code {
style = style.fg(Color::Yellow);
}
style
}
#[derive(Clone)]
pub struct DisplayLine {
line: Line<'static>,
scrollable: bool,
}
fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
if table.len() < 2 {
return vec![];
}
let header = &table[0];
let mut column_heights = vec![0; table[0].len()];
for row in table.iter().skip(1) {
for (i, cell) in row.iter().enumerate() {
let lines = cell.lines().count().max(1);
column_heights[i] += lines;
}
}
let widths: Vec<usize> = header
.iter()
.enumerate()
.map(|(i, h)| {
let h_len = h.chars().count().max(1);
if i == 0 {
table
.iter()
.map(|row| row.get(i).map(|c| c.chars().count()).unwrap_or(0))
.max()
.unwrap_or(h_len)
} else {
let max = (3 * h_len) as usize;
max.max(h_len)
}
})
.collect();
let mut lines = Vec::new();
for (row_idx, row) in table.iter().enumerate() {
if row_idx == 1 {
let divider = widths
.iter()
.map(|w| "".repeat(*w))
.collect::<Vec<_>>()
.join("─┼─");
lines.push(DisplayLine {
line: Line::from(Span::styled(divider, Style::default().fg(Color::DarkGray))),
scrollable: true,
});
continue;
}
let wrapped_cells: Vec<Vec<String>> = row
.iter()
.enumerate()
.map(|(i, cell)| wrap_cell(cell, widths[i]))
.collect();
let row_height = wrapped_cells.iter().map(|c| c.len()).max().unwrap_or(1);
for line_idx in 0..row_height {
let mut line = String::new();
for (i, cell) in wrapped_cells.iter().enumerate() {
let content = cell.get(line_idx).map(String::as_str).unwrap_or("");
line.push_str(&format!("{:width$}", content, width = widths[i]));
if i < wrapped_cells.len() - 1 {
line.push_str("");
}
}
let style = if row_idx == 0 {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
lines.push(DisplayLine {
line: Line::from(Span::styled(line, style)),
scrollable: true,
});
}
}
lines
}
fn wrap_cell(cell: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current = String::new();
for word in cell.split_whitespace() {
let word_len = word.chars().count();
let current_len = current.chars().count();
if current_len == 0 {
if word_len <= width {
current.push_str(word);
} else {
for chunk in word.chars().collect::<Vec<_>>().chunks(width) {
lines.push(chunk.iter().collect());
}
}
} else if current_len + 1 + word_len <= width {
current.push(' ');
current.push_str(word);
} else {
lines.push(current);
current = String::new();
if word_len <= width {
current.push_str(word);
} else {
for chunk in word.chars().collect::<Vec<_>>().chunks(width) {
lines.push(chunk.iter().collect());
}
}
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn flush_span(spans: &mut Vec<Span>, buf: &mut String, style: Style) {
if !buf.is_empty() {
spans.push(Span::styled(buf.clone(), style));
buf.clear();
}
}
fn parse_table(lines: &[&str]) -> Vec<Vec<String>> {
let mut table = Vec::new();
for &line in lines {
if !line.starts_with('|') || !line.contains('|') {
break;
}
let row: Vec<String> = line
.trim_matches('|')
.split('|')
.map(|s| s.trim().to_string())
.collect();
table.push(row);
}
table
}

View file

@ -0,0 +1,25 @@
use std::any::Any;
use crossterm::event::KeyEvent;
use ratatui::{Frame, layout::Rect};
use crate::interaction_result::InteractionResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavDirection {
Up,
Down,
Left,
Right,
Next,
Prev,
}
pub trait Screen: Send + Sync + Any {
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn render(&self, f: &mut Frame, rect: Rect);
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult;
}

View file

@ -0,0 +1,343 @@
use crate::{
interaction_result::InteractionResult,
screens::{md_viewer::FileViewer, screens::Screen},
ui::UI,
};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
use std::{any::Any, pin::Pin, sync::Arc};
use tokio::sync::oneshot;
pub struct TermsCheckerScreen {
ui: Arc<UI>,
sender: Option<oneshot::Sender<UserChoice>>,
eula: bool,
tos: bool,
pp: bool,
focus: Focus,
}
impl TermsCheckerScreen {
pub fn new(ui: Arc<UI>, sender: Option<oneshot::Sender<UserChoice>>) -> Self {
Self {
ui,
sender,
eula: false,
tos: false,
pp: false,
focus: Focus::Eula,
}
}
}
impl Screen for TermsCheckerScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, size: Rect) {
let mut needed_height = 5;
if size.height < 6 || size.width < 27 {
f.render_widget(
Line::from(format!("too small {}/63 by {}/13", size.width, size.height)),
size,
);
return;
}
let max_width = 150;
let max_height = 26;
let content_width = if max_width < size.width {
max_width
} else {
size.width
};
let content_height = if max_height < size.height {
max_height
} else {
size.height
};
let horizontal_margin = (size.width.saturating_sub(content_width)) / 2;
let vertical_margin = (size.height.saturating_sub(content_height)) / 2;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(7), Constraint::Length(3)])
.split(Rect {
x: horizontal_margin,
y: vertical_margin,
width: content_width,
height: content_height,
});
let eula_text = if size.width < 70 {
"EULA ¹ (https://legal.tensamin.net/eula/)"
} else {
"End User Licence Agreement ¹ (https://legal.tensamin.net/eula/)"
};
let tos_text = if size.width < 72 {
"ToS ² (https://legal.tensamin.net/terms-of-service/)"
} else {
"Terms of Service ² (https://legal.tensamin.net/terms-of-service/)"
};
let pp_text = if size.width < 68 {
"PP ² (https://legal.tensamin.net/privacy-policy/)"
} else {
"Privacy Policy ² (https://legal.tensamin.net/privacy-policy/)"
};
let (mut optional_lines, agree_lines): (Vec<i16>, Vec<&str>) = if size.width > 143 {
(
vec![8, 3, 8, 5],
vec![
"",
"By selecting Continue, you confirm that you agree to the End User License Agreement and applicable Terms of Service.",
"",
"Tensamin services require acceptance of the Terms of Service and Privacy Policy.",
"",
"While having a document selected press O to view in this UI or press L to open as a link.",
],
)
} else if size.width > 92 {
(
vec![9, 3, 9, 5],
vec![
"",
"By selecting Continue, you confirm that you agree to the End User License",
"Agreement and applicable Terms of Service.",
"",
"Tensamin services require acceptance of the Terms of Service and Privacy Policy.",
"",
"While having a document selected press O to view in this UI or press L to open as a link.",
],
)
} else if size.width > 73 {
(
vec![9, 3, 9, 5],
vec![
"",
"By selecting Continue, you confirm that you agree to the",
"End User License Agreement and applicable Terms of Service.",
"",
"Tensamin services require acceptance of the ToS and Privacy Policy.",
"",
"While having a document selected press O to view in this UI or press L",
"to open as a link.",
],
)
} else {
(
vec![10, 3, 11, 5],
vec![
"",
"By selecting Continue, you confirm that you agree",
"to the End User License Agreement and",
"applicable Terms of Service.",
"",
"Tensamin services require acceptance of the",
"Terms of Service and Privacy Policy.",
"",
"While having a document selected press O to view",
"in this UI or press L to open as a link.",
],
)
};
let mut text_lines = vec![
checkbox(eula_text, self.eula, self.focus == Focus::Eula, true),
checkbox(tos_text, self.tos, self.focus == Focus::Tos, self.eula),
checkbox(pp_text, self.pp, self.focus == Focus::Pp, self.eula),
Line::from(""),
Line::from("¹ Necessary required to run the program"),
Line::from("² Optional required only for Tensamin services"),
];
for line in agree_lines {
text_lines.insert(text_lines.len(), Line::from(line));
}
while size.height - 5 < text_lines.len() as u16 {
if optional_lines.len() == 0 {
break;
}
text_lines.remove(optional_lines[0] as usize);
optional_lines.remove(0);
}
needed_height += text_lines.len();
if size.width < 60 || size.height < needed_height as u16 {
let width_style = if size.width > 76 {
Style::default().fg(Color::Green)
} else if size.width >= 60 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Red)
};
let height_style = if size.height > 19 {
Style::default().fg(Color::Green)
} else if size.height >= 13 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Red)
};
let warning_text = Text::from(vec![
Line::from(vec![
Span::raw("Width: "),
Span::styled(format!("{}", size.width), width_style),
Span::raw(" / 60"),
]),
Line::from(vec![
Span::raw("Height: "),
Span::styled(format!("{}", size.height), height_style),
Span::raw(format!(" / 13")),
]),
]);
let warning = Paragraph::new(warning_text)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("UI Too Small [Q to Quit]")),
);
f.render_widget(warning, size);
return;
}
let consent_block = Paragraph::new(Text::from(text_lines)).block(
Block::default()
.title(format!(" Tensamin User Consent [Q to Quit] ",))
.borders(Borders::ALL),
);
f.render_widget(consent_block, chunks[0]);
draw_buttons(
f,
chunks[1],
self.focus,
(self.eula, self.tos && self.pp),
true,
false,
true,
);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel];
if self.eula {
possible_states.push(Focus::Continue);
if self.tos && self.pp {
possible_states.push(Focus::ContinueAll);
}
}
match event.code {
KeyCode::Esc => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::Deny);
}
InteractionResult::CloseScreen
}
KeyCode::Up | KeyCode::Left => {
self.focus.prev(&possible_states);
InteractionResult::Handled
}
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
self.focus.next(&possible_states);
InteractionResult::Handled
}
KeyCode::Char('q') | KeyCode::Char('Q') => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::Deny);
}
InteractionResult::CloseScreen
}
KeyCode::Char('o') | KeyCode::Char('O') => {
let terms_type = match self.focus {
Focus::Eula => Some(Type::EULA),
Focus::Tos => Some(Type::TOS),
Focus::Pp => Some(Type::PP),
_ => None,
};
if let Some(terms_type) = terms_type {
let fut: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>> =
Box::pin(async move {
let content = get_terms(terms_type.clone()).await.unwrap();
let screen: FileViewer =
FileViewer::new(terms_type.to_string(), &content);
Box::new(screen) as Box<dyn Screen>
});
InteractionResult::OpenFutureScreen { screen: fut }
} else {
InteractionResult::Unhandled
}
}
KeyCode::Char('l') | KeyCode::Char('L') => match self.focus {
Focus::Eula => {
let _ = open::that(get_link(Type::EULA));
InteractionResult::Handled
}
Focus::Tos => {
let _ = open::that(get_link(Type::TOS));
InteractionResult::Handled
}
Focus::Pp => {
let _ = open::that(get_link(Type::PP));
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
},
KeyCode::Enter | KeyCode::Char(' ') => match self.focus {
Focus::Eula => {
self.eula = !self.eula;
self.tos = false;
self.pp = false;
InteractionResult::Handled
}
Focus::Tos if self.eula => {
self.tos = !self.tos;
InteractionResult::Handled
}
Focus::Pp if self.eula => {
self.pp = !self.pp;
InteractionResult::Handled
}
Focus::Cancel => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::Deny);
}
InteractionResult::CloseScreen
}
Focus::Continue if self.eula => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::AcceptEULA);
}
InteractionResult::CloseScreen
}
Focus::ContinueAll if self.eula && self.tos && self.pp => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::AcceptAll);
}
InteractionResult::CloseScreen
}
_ => InteractionResult::Unhandled,
},
_ => InteractionResult::Unhandled,
}
}
}

View file

@ -0,0 +1,682 @@
use crate::{
interaction_result::InteractionResult,
screens::{md_viewer::FileViewer, screens::Screen},
};
use chrono::{Local, TimeZone, Utc};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
use std::any::Any;
use tokio::sync::oneshot;
pub struct TermsUpdaterScreen {
sender: Option<oneshot::Sender<UserChoice>>,
eula_needed: bool,
tos_needed: bool,
pp_needed: bool,
eula_future: bool,
tos_future: bool,
pp_future: bool,
eula_for_future: Option<Doc>,
tos_for_future: Option<Doc>,
pp_for_future: Option<Doc>,
update_needed: bool,
eula: bool,
tos: bool,
pp: bool,
focus: Focus,
}
impl TermsUpdaterScreen {
pub fn new(
consent_eula: UpdateDecision,
consent_tos: UpdateDecision,
consent_pp: UpdateDecision,
sender: Option<oneshot::Sender<UserChoice>>,
) -> Self {
let (eula_needed, eula_future, eula_for_future): (bool, bool, Option<Doc>) =
match consent_eula {
UpdateDecision::NoChange => (false, false, None),
UpdateDecision::Future { newest } => (true, true, Some(newest)),
UpdateDecision::Forced(doc) => (true, false, Some(doc)),
};
let (tos_needed, tos_future, tos_for_future): (bool, bool, Option<Doc>) = match consent_tos
{
UpdateDecision::NoChange => (false, false, None),
UpdateDecision::Future { newest } => (true, true, Some(newest)),
UpdateDecision::Forced(doc) => (true, false, Some(doc)),
};
let (pp_needed, pp_future, pp_for_future): (bool, bool, Option<Doc>) = match consent_pp {
UpdateDecision::NoChange => (false, false, None),
UpdateDecision::Future { newest } => (true, true, Some(newest)),
UpdateDecision::Forced(doc) => (true, false, Some(doc)),
};
let focus = if eula_needed {
Focus::Eula
} else if tos_needed {
Focus::Tos
} else if pp_needed {
Focus::Pp
} else {
Focus::Cancel
};
let update_needed = (eula_needed && !eula_future)
|| (tos_needed && !tos_future)
|| (pp_needed && !pp_future);
Self {
sender,
eula_needed,
tos_needed,
pp_needed,
eula_for_future,
tos_for_future,
pp_for_future,
eula_future,
tos_future,
pp_future,
update_needed,
eula: !eula_needed,
tos: !tos_needed,
pp: !pp_needed,
focus,
}
}
}
impl Screen for TermsUpdaterScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, size: Rect) {
let mut needed_height = 5;
if size.height < 6 || size.width < 27 {
f.render_widget(
Line::from(format!("too small {}/63 by {}/15", size.width, size.height)),
size,
);
return;
}
let max_width = 150;
let max_height = 30;
let content_width = if max_width < size.width {
max_width
} else {
size.width
};
let content_height = if max_height < size.height {
max_height
} else {
size.height
};
let horizontal_margin = (size.width.saturating_sub(content_width)) / 2;
let vertical_margin = (size.height.saturating_sub(content_height)) / 2;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(7), Constraint::Length(3)])
.split(Rect {
x: horizontal_margin,
y: vertical_margin,
width: content_width,
height: content_height,
});
let mut text_lines: Vec<Line> = Vec::new();
let mut header_lines = 0;
let separator = if size.width > 72 {
header_lines += 3;
text_lines.push(Line::from(
"You previously accepted earlier versions of Tensamins legal documents.",
));
text_lines.push(Line::from(
"Some of them have been updated and are listed below for your review.",
));
text_lines.push(Line::from(""));
2
} else {
header_lines += 5;
text_lines.push(Line::from("You previously accepted earlier "));
text_lines.push(Line::from("versions of Tensamins legal documents."));
text_lines.push(Line::from("Some of them have been updated and"));
text_lines.push(Line::from("are listed below for your review."));
text_lines.push(Line::from(""));
4
};
if self.eula_needed {
header_lines += 1;
if self.eula_future {
if size.width < 80 {
text_lines.push(checkbox(
"EULA ¹³ (https://legal.tensamin.net/eula/newest/)",
self.eula,
self.focus == Focus::Eula,
true,
));
header_lines += 1;
let unix_timestamp = self.eula_for_future.clone().unwrap().get_time() as i64;
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
} else {
text_lines.push(checkbox(
"End User Licence Agreement ¹³ (https://legal.tensamin.net/eula/newest/)",
self.eula,
self.focus == Focus::Eula,
true,
));
header_lines += 1;
let unix_timestamp = self.eula_for_future.clone().unwrap().get_time() as i64;
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
}
} else {
if size.width < 80 {
text_lines.push(checkbox(
"EULA ¹ (https://legal.tensamin.net/eula/newest/)",
self.eula,
self.focus == Focus::Eula,
true,
));
} else {
text_lines.push(checkbox(
"End User Licence Agreement ¹ (https://legal.tensamin.net/eula/newest/)",
self.eula,
self.focus == Focus::Eula,
true,
));
}
}
}
if self.tos_needed {
header_lines += 1;
if self.tos_future {
if size.width < 80 {
text_lines.push(checkbox(
"ToS ²³ (https://legal.tensamin.net/tos/newest/)",
self.tos,
self.focus == Focus::Tos,
self.eula,
));
header_lines += 1;
let unix_timestamp = self.tos_for_future.clone().unwrap().get_time() as i64;
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
} else {
text_lines.push(checkbox(
"Terms of Service ²³ (https://legal.tensamin.net/terms-of-service/newest/)",
self.tos,
self.focus == Focus::Tos,
self.eula,
));
header_lines += 1;
let unix_timestamp = self.tos_for_future.clone().unwrap().get_time() as i64;
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
}
} else {
if size.width < 80 {
text_lines.push(checkbox(
"ToS ² (https://legal.tensamin.net/tos/newest/)",
self.tos,
self.focus == Focus::Tos,
self.eula,
));
} else {
text_lines.push(checkbox(
"Terms of Service ² (https://legal.tensamin.net/terms-of-service/newest/)",
self.tos,
self.focus == Focus::Tos,
self.eula,
));
}
}
}
if self.pp_needed {
header_lines += 1;
if self.pp_future {
if size.width < 80 {
text_lines.push(checkbox(
"PP ²³ (https://legal.tensamin.net/privacy-policy/newest/)",
self.pp,
self.focus == Focus::Pp,
self.eula,
));
header_lines += 1;
let unix_timestamp = self.pp_for_future.clone().unwrap().get_time() as i64;
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
} else {
text_lines.push(checkbox(
"Privacy Policy ²³ (https://legal.tensamin.net/privacy-policy/newest/)",
self.pp,
self.focus == Focus::Pp,
self.eula,
));
header_lines += 1;
let unix_timestamp = self.pp_for_future.clone().unwrap().get_time() as i64;
let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap();
let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M");
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
}
} else {
if size.width < 80 {
text_lines.push(checkbox(
"PP ² (https://legal.tensamin.net/privacy-policy/newest/)",
self.pp,
self.focus == Focus::Pp,
self.eula,
));
} else {
text_lines.push(checkbox(
"Privacy Policy ² (https://legal.tensamin.net/privacy-policy/newest/)",
self.pp,
self.focus == Focus::Pp,
self.eula,
));
}
}
}
text_lines.push(Line::from(""));
text_lines.push(Line::from("¹ Necessary to run the program"));
text_lines.push(Line::from("² Optional - only for Tensamin services"));
if size.width < 100 {
text_lines.push(Line::from(
"³ Future version - consent stored now, takes effect later",
));
} else {
text_lines.push(Line::from("³ Future version - Youll continue using this version, automatically updated when changes apply."));
}
text_lines.push(Line::from(""));
let mut optional_lines: Vec<i16> = if size.width > 143 {
if self.tos_needed || self.pp_needed {
text_lines.push(Line::from("By selecting Downgrade, you confirm that you agree to the End User License Agreement and applicable Terms of Service."));
text_lines.push(Line::from(
"On Downgrade: Tensamin Services will deactivate once these changes apply.",
));
} else {
text_lines.push(Line::from("By selecting Continue, you confirm that you agree to the End User License Agreement and applicable Terms of Service."));
}
text_lines.push(Line::from(""));
text_lines.push(Line::from(
"Tensamin services require acceptance of the Terms of Service and Privacy Policy.",
));
text_lines.push(Line::from(""));
text_lines.push(Line::from("While having a document selected press O to view in this UI or press L to open as a link."));
if self.tos_needed || self.pp_needed {
vec![
header_lines + 7,
header_lines,
header_lines + 7,
separator,
header_lines + 2,
]
} else {
vec![
header_lines + 6,
header_lines,
header_lines + 6,
separator,
header_lines + 2,
]
}
} else if size.width > 92 {
if self.tos_needed || self.pp_needed {
text_lines.push(Line::from(
"By selecting Continue, you confirm that you agree to the End User",
));
text_lines.push(Line::from(
"License Agreement and applicable Terms of Service.",
));
text_lines.push(Line::from(
"On Downgrade: Tensamin Services will deactivate once these changes apply.",
));
} else {
text_lines.push(Line::from(
"By selecting Continue, you confirm that you agree to the End User",
));
text_lines.push(Line::from(
"License Agreement and applicable Terms of Service.",
));
}
text_lines.push(Line::from(""));
text_lines.push(Line::from(
"Tensamin services require acceptance of the Terms of Service and Privacy Policy.",
));
text_lines.push(Line::from(""));
text_lines.push(Line::from("While having a document selected press O to view in this UI or press L to open as a link."));
if self.tos_needed || self.pp_needed {
vec![
header_lines + 8,
header_lines,
header_lines + 8,
separator,
header_lines + 2,
]
} else {
vec![
header_lines + 7,
header_lines,
header_lines + 7,
separator,
header_lines + 2,
]
}
} else if size.width > 73 {
if self.tos_needed || self.pp_needed {
text_lines.push(Line::from(
"By selecting Continue, you confirm that you read, understood and",
));
text_lines.push(Line::from(
"agree to the End User License Agreement and applicable Terms of Service.",
));
text_lines.push(Line::from(
"On Downgrade: Tensamin Services will deactivate once these changes apply.",
));
} else {
text_lines.push(Line::from(
"By selecting Continue, you confirm that you read, understood and",
));
text_lines.push(Line::from(
"agree to the End User License Agreement and applicable Terms of Service.",
));
}
text_lines.push(Line::from(""));
text_lines.push(Line::from(
"Tensamin services require acceptance of the ToS and Privacy Policy.",
));
text_lines.push(Line::from(""));
text_lines.push(Line::from(
"While having a document selected press O to view in this UI or press L",
));
text_lines.push(Line::from("to open as a link."));
if self.tos_needed || self.pp_needed {
vec![
header_lines + 8,
header_lines,
header_lines + 8,
separator,
header_lines + 2,
]
} else {
vec![
header_lines + 7,
header_lines,
header_lines + 7,
separator,
header_lines + 2,
]
}
} else {
if self.tos_needed || self.pp_needed {
text_lines.push(Line::from("By selecting Continue, you confirm that you"));
text_lines.push(Line::from("agree to the End User License"));
text_lines.push(Line::from("Agreement and applicable Terms of Service."));
text_lines.push(Line::from(
"On Downgrade: Tensamin Services will deactivate",
));
text_lines.push(Line::from("once these changes apply."));
} else {
text_lines.push(Line::from("By selecting Continue, you confirm that you"));
text_lines.push(Line::from("agree to the End User License"));
text_lines.push(Line::from("Agreement and applicable Terms of Service."));
}
text_lines.push(Line::from(""));
text_lines.push(Line::from("Tensamin services require acceptance of the"));
text_lines.push(Line::from("Terms of Service and Privacy Policy."));
text_lines.push(Line::from(""));
text_lines.push(Line::from(
"While having a document selected press O to view",
));
text_lines.push(Line::from("in this UI or press L to open as a link."));
if self.tos_needed || self.pp_needed {
vec![
header_lines + 10,
header_lines,
header_lines + 11,
separator,
header_lines + 2,
]
} else {
vec![
header_lines + 8,
header_lines,
header_lines + 9,
separator,
header_lines + 2,
]
}
};
while size.height - 5 < text_lines.len() as u16 {
if optional_lines.len() == 0 {
break;
}
text_lines.remove(optional_lines[0] as usize);
optional_lines.remove(0);
}
needed_height += text_lines.len();
let q_informer = if self.update_needed {
"Q to Exit"
} else {
"Q to Cancel"
};
if size.width < 60 || size.height < needed_height as u16 {
let width_style = if size.width > 76 {
Style::default().fg(Color::Green)
} else if size.width >= 60 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Red)
};
let height_style = if size.height > 20 {
Style::default().fg(Color::Green)
} else if size.height >= (header_lines as u16 + 10) {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Red)
};
let warning_text = Text::from(vec![
Line::from(vec![
Span::raw("Width: "),
Span::styled(format!("{}", size.width), width_style),
Span::raw(" / 60"),
]),
Line::from(vec![
Span::raw("Height: "),
Span::styled(format!("{}", size.height), height_style),
Span::raw(format!(" / {}", header_lines + 10)),
]),
]);
let warning = Paragraph::new(warning_text)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("UI Too Small [{}]", q_informer)),
);
f.render_widget(warning, size);
return;
}
let consent_block = Paragraph::new(Text::from(text_lines)).block(
Block::default()
.title(format!(" Update Tensamin User Consent [{}] ", q_informer))
.borders(Borders::ALL),
);
f.render_widget(consent_block, chunks[0]);
let downgrade_scenario = self.tos_needed || self.pp_needed;
draw_buttons(
f,
chunks[1],
self.focus,
(self.eula, self.tos && self.pp),
self.update_needed,
downgrade_scenario,
self.pp_needed || self.tos_needed,
);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
let mut possible_states = Vec::new();
if self.eula_needed {
possible_states.push(Focus::Eula);
}
if self.tos_needed {
possible_states.push(Focus::Tos);
}
if self.pp_needed {
possible_states.push(Focus::Pp);
}
possible_states.push(Focus::Cancel);
if self.eula {
possible_states.push(Focus::Continue);
if self.tos && self.pp {
possible_states.push(Focus::ContinueAll);
}
}
match event.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::Deny);
}
return InteractionResult::CloseScreen;
}
KeyCode::Enter | KeyCode::Char(' ') => match self.focus {
Focus::Eula => {
self.eula = !self.eula;
self.tos = !self.tos_needed;
self.pp = !self.pp_needed;
InteractionResult::Handled
}
Focus::Tos if self.eula => {
self.tos = !self.tos;
InteractionResult::Handled
}
Focus::Pp if self.eula => {
self.pp = !self.pp;
InteractionResult::Handled
}
Focus::Cancel => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::Deny);
}
InteractionResult::CloseScreen
}
Focus::Continue if self.eula => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::AcceptEULA);
}
InteractionResult::CloseScreen
}
Focus::ContinueAll if self.eula && self.tos && self.pp => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::AcceptAll);
}
InteractionResult::CloseScreen
}
_ => InteractionResult::Unhandled,
},
KeyCode::Char('o') | KeyCode::Char('O') => {
let terms_type = match self.focus {
Focus::Eula => Some(Type::EULA),
Focus::Tos => Some(Type::TOS),
Focus::Pp => Some(Type::PP),
_ => None,
};
if let Some(terms_type) = terms_type {
let fut = Box::pin(async move {
let content = get_terms(terms_type.clone()).await.unwrap();
Box::new(FileViewer::new(terms_type.to_string(), &content))
as Box<dyn Screen>
});
return InteractionResult::OpenFutureScreen { screen: fut };
} else {
InteractionResult::Unhandled
}
}
KeyCode::Char('l') | KeyCode::Char('L') => match self.focus {
Focus::Eula => {
let _ = open::that(get_newest_link(Type::EULA));
InteractionResult::Handled
}
Focus::Tos => {
let _ = open::that(get_newest_link(Type::TOS));
InteractionResult::Handled
}
Focus::Pp => {
let _ = open::that(get_newest_link(Type::PP));
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
},
KeyCode::Up | KeyCode::Left => {
self.focus.prev(&possible_states);
InteractionResult::Handled
}
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
self.focus.next(&possible_states);
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
}