init commit, ui functional
This commit is contained in:
42
src/app/entry.rs
Normal file
42
src/app/entry.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
error::Error,
|
||||
fmt::Display,
|
||||
io::ErrorKind,
|
||||
num::{IntErrorKind, ParseIntError},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Entry {
|
||||
fromIP: String,
|
||||
fromPort: String,
|
||||
toIP: String,
|
||||
toPort: String,
|
||||
}
|
||||
|
||||
impl Display for Entry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}:{} → {}:{}",
|
||||
self.fromIP, self.fromPort, self.toIP, self.toPort
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
pub fn new(fromIP: String, toIP: String, fromPort: String, toPort: String) -> Option<Self> {
|
||||
if fromPort.parse::<i32>().is_ok_and(|a| a > 1 && a < 65535)
|
||||
&& toPort.parse::<i32>().is_ok_and(|a| a > 1 && a < 65535)
|
||||
{
|
||||
Some(Entry {
|
||||
fromIP,
|
||||
toIP,
|
||||
fromPort,
|
||||
toPort,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/app/mod.rs
Normal file
108
src/app/mod.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
pub mod entry;
|
||||
|
||||
mod settings;
|
||||
|
||||
pub mod status;
|
||||
use crate::app::{
|
||||
entry::Entry,
|
||||
settings::Settings,
|
||||
status::{CurrentScreen, EditingField, EntryCreation},
|
||||
};
|
||||
|
||||
pub struct AppState {
|
||||
pub fromIP: String,
|
||||
pub fromPort: String,
|
||||
pub toIP: String,
|
||||
pub toPort: String,
|
||||
pub screen: CurrentScreen,
|
||||
pub field: Option<EditingField>,
|
||||
// pub current: Option<Entry>,
|
||||
pub currentlyEditing: Option<EditingField>,
|
||||
pub entries: Vec<Entry>,
|
||||
pub confDir: String,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(confDir: String) -> Self {
|
||||
let settings = Settings::new(&confDir);
|
||||
AppState {
|
||||
fromIP: String::new(),
|
||||
fromPort: String::new(),
|
||||
toIP: String::new(),
|
||||
toPort: String::new(),
|
||||
currentlyEditing: None,
|
||||
screen: CurrentScreen::Main,
|
||||
field: None,
|
||||
entries: settings.entries,
|
||||
confDir: confDir,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn store(&mut self) -> EntryCreation {
|
||||
match Entry::new(
|
||||
self.fromIP.clone(),
|
||||
self.toIP.clone(),
|
||||
self.fromPort.clone(),
|
||||
self.toPort.clone(),
|
||||
) {
|
||||
Some(entry) => {
|
||||
self.entries.push(entry);
|
||||
self.fromIP = String::new();
|
||||
self.toIP = String::new();
|
||||
self.fromPort = String::new();
|
||||
self.toPort = String::new();
|
||||
self.currentlyEditing = None;
|
||||
|
||||
EntryCreation::Success
|
||||
}
|
||||
_ => EntryCreation::PortValidationError,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn startEditing(&mut self) {
|
||||
if let Some(currentField) = &self.currentlyEditing {
|
||||
match currentField {
|
||||
EditingField::ToIP => {}
|
||||
_ => self.currentlyEditing = Some(EditingField::FromIP),
|
||||
}
|
||||
} else {
|
||||
self.currentlyEditing = Some(EditingField::FromIP);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn nextField(&mut self) {
|
||||
if let Some(currentField) = &self.currentlyEditing {
|
||||
self.currentlyEditing = match currentField {
|
||||
EditingField::FromIP => Some(EditingField::FromPort),
|
||||
EditingField::FromPort => Some(EditingField::ToIP),
|
||||
EditingField::ToIP => Some(EditingField::ToPort),
|
||||
EditingField::ToPort => Some(EditingField::FromIP),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prevField(&mut self) {
|
||||
if let Some(currentField) = &self.currentlyEditing {
|
||||
self.currentlyEditing = match currentField {
|
||||
EditingField::FromIP => Some(EditingField::ToPort),
|
||||
EditingField::FromPort => Some(EditingField::FromIP),
|
||||
EditingField::ToIP => Some(EditingField::FromPort),
|
||||
EditingField::ToPort => Some(EditingField::ToIP),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print(&self) {
|
||||
let tempEntry = &self.entries[0];
|
||||
let res = serde_json::to_string(tempEntry).unwrap();
|
||||
println!("{res}");
|
||||
let resString = tempEntry.to_string();
|
||||
println!("{resString}");
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
let mut settings = Settings::new(&self.confDir);
|
||||
settings.entries = self.entries.clone();
|
||||
settings.save(&self.confDir);
|
||||
}
|
||||
}
|
||||
38
src/app/settings.rs
Normal file
38
src/app/settings.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use crate::app::entry::Entry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Display;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
impl Display for Settings {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Total Entries: {}", self.entries.len())
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn new(config: &String) -> Self {
|
||||
let data = std::fs::read_to_string(config);
|
||||
match data {
|
||||
Ok(data) => {
|
||||
let settings =
|
||||
serde_json::from_str::<Settings>(&data).expect("Settings file corrupted");
|
||||
settings
|
||||
}
|
||||
Err(_) => {
|
||||
let newSet = Settings { entries: vec![] };
|
||||
let payload = serde_json::to_string_pretty(&newSet).unwrap();
|
||||
std::fs::write(config, payload);
|
||||
newSet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, config: &String) {
|
||||
let payload = serde_json::to_string(self).unwrap();
|
||||
std::fs::write(config, payload);
|
||||
}
|
||||
}
|
||||
19
src/app/status.rs
Normal file
19
src/app/status.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
pub enum CurrentScreen {
|
||||
Main,
|
||||
Add,
|
||||
Settings,
|
||||
Exit,
|
||||
}
|
||||
|
||||
pub enum EditingField {
|
||||
FromIP,
|
||||
FromPort,
|
||||
ToIP,
|
||||
ToPort,
|
||||
}
|
||||
|
||||
pub enum EntryCreation {
|
||||
Success,
|
||||
PortValidationError,
|
||||
IPValidationError,
|
||||
}
|
||||
164
src/main.rs
Normal file
164
src/main.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
#[allow(non_snake_case)]
|
||||
mod app;
|
||||
mod ui;
|
||||
|
||||
use crate::ui::ui;
|
||||
use app::AppState;
|
||||
use color_eyre::Result;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::crossterm::event::{
|
||||
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
|
||||
};
|
||||
use ratatui::crossterm::execute;
|
||||
use ratatui::crossterm::terminal::{
|
||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||
};
|
||||
use ratatui::prelude::{Backend, CrosstermBackend};
|
||||
use std::io;
|
||||
|
||||
use crate::app::status::{CurrentScreen, EditingField};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut stderr = io::stderr(); // This is a special case. Normally using stdout is fine
|
||||
execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stderr);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
// create app and run it
|
||||
let mut app = AppState::new("./conf.json".to_string());
|
||||
// app.load();
|
||||
let res = runApp(&mut app, &mut terminal);
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Ok(do_print) = res {
|
||||
if do_print {
|
||||
app.print();
|
||||
}
|
||||
} else if let Err(err) = res {
|
||||
println!("{err:?}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn runApp<B: Backend>(app: &mut AppState, terminal: &mut Terminal<B>) -> Result<bool> {
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, app))?; // from ui.rs
|
||||
if let Event::Key(key) = event::read()? {
|
||||
// println!("{key:?}");
|
||||
if key.kind == event::KeyEventKind::Release {
|
||||
continue;
|
||||
}
|
||||
|
||||
match app.screen {
|
||||
CurrentScreen::Settings => match key.code {
|
||||
KeyCode::Home | KeyCode::Char('m') | KeyCode::Esc => {
|
||||
app.screen = CurrentScreen::Main;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
CurrentScreen::Main => match key.code {
|
||||
KeyCode::Char('a') => {
|
||||
app.screen = CurrentScreen::Add;
|
||||
app.currentlyEditing = Some(EditingField::FromIP);
|
||||
}
|
||||
KeyCode::Char('q') | KeyCode::F(10) | KeyCode::Esc => {
|
||||
app.screen = CurrentScreen::Exit
|
||||
}
|
||||
|
||||
KeyCode::Char('s') | KeyCode::F(2) => app.screen = CurrentScreen::Settings,
|
||||
|
||||
_ => {}
|
||||
},
|
||||
CurrentScreen::Add => match (key.modifiers, key.code) {
|
||||
(KeyModifiers::NONE, KeyCode::Enter) => {
|
||||
if let Some(eF) = &app.currentlyEditing {
|
||||
match eF {
|
||||
EditingField::ToPort => {
|
||||
app.store();
|
||||
app.screen = CurrentScreen::Main;
|
||||
app.currentlyEditing = None;
|
||||
}
|
||||
_ => app.nextField(),
|
||||
}
|
||||
}
|
||||
}
|
||||
(KeyModifiers::NONE, KeyCode::Backspace) => {
|
||||
if let Some(eF) = &app.currentlyEditing {
|
||||
match eF {
|
||||
EditingField::FromIP => {
|
||||
app.fromIP.pop();
|
||||
}
|
||||
EditingField::FromPort => {
|
||||
app.fromPort.pop();
|
||||
}
|
||||
EditingField::ToIP => {
|
||||
app.toIP.pop();
|
||||
}
|
||||
EditingField::ToPort => {
|
||||
app.toPort.pop();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
(KeyModifiers::NONE, KeyCode::Esc) => {
|
||||
app.screen = CurrentScreen::Main;
|
||||
app.currentlyEditing = None;
|
||||
}
|
||||
(KeyModifiers::NONE, KeyCode::Tab) => app.nextField(),
|
||||
(KeyModifiers::SHIFT, KeyCode::Tab) => app.prevField(),
|
||||
|
||||
(m, KeyCode::Char(v)) => {
|
||||
if let Some(e) = &app.currentlyEditing {
|
||||
let mut isIP = false;
|
||||
let opField = match e {
|
||||
EditingField::FromIP => {
|
||||
isIP = true;
|
||||
&mut app.fromIP
|
||||
}
|
||||
EditingField::FromPort => &mut app.fromPort,
|
||||
EditingField::ToIP => {
|
||||
isIP = true;
|
||||
&mut app.toIP
|
||||
}
|
||||
EditingField::ToPort => &mut app.toPort,
|
||||
};
|
||||
match v {
|
||||
'l' => {
|
||||
if isIP {
|
||||
opField.clear();
|
||||
opField.push_str("127.0.0.1");
|
||||
}
|
||||
}
|
||||
'c' => opField.clear(),
|
||||
'C' => {
|
||||
app.fromIP.clear();
|
||||
app.toIP.clear();
|
||||
app.fromPort.clear();
|
||||
app.toPort.clear();
|
||||
}
|
||||
_ => opField.push(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
CurrentScreen::Exit => match key.code {
|
||||
KeyCode::Enter => {
|
||||
app.save();
|
||||
return Ok(true);
|
||||
}
|
||||
KeyCode::Esc | KeyCode::Char('m') => app.screen = CurrentScreen::Main,
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
// app.run(terminal)
|
||||
}
|
||||
194
src/ui.rs
Normal file
194
src/ui.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
#[allow(non_snake_case)]
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Layout},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph},
|
||||
};
|
||||
use ratatui::{
|
||||
layout::{Direction, Rect},
|
||||
widgets::Wrap,
|
||||
};
|
||||
|
||||
use crate::app::status::CurrentScreen;
|
||||
use crate::app::{AppState, status::EditingField};
|
||||
|
||||
pub fn ui(frame: &mut Frame, app: &AppState) {
|
||||
let chunks = Layout::default()
|
||||
.direction(ratatui::layout::Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
let footerChunks = Layout::default()
|
||||
.direction(ratatui::layout::Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(chunks[2]);
|
||||
|
||||
let titleBlock = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default());
|
||||
|
||||
let title = Paragraph::new(Text::styled(
|
||||
"Create new entry",
|
||||
Style::default().fg(Color::Green),
|
||||
))
|
||||
.block(titleBlock);
|
||||
|
||||
frame.render_widget(title, chunks[0]);
|
||||
|
||||
let mut currentItems = Vec::<ListItem>::new();
|
||||
for (i, e) in app.entries.iter().enumerate() {
|
||||
currentItems.push(ListItem::new(Line::from(Span::styled(
|
||||
format!("{}. {}", i, e),
|
||||
Style::default().fg(Color::Yellow),
|
||||
))));
|
||||
}
|
||||
let list = List::new(currentItems);
|
||||
frame.render_widget(list, chunks[1]);
|
||||
|
||||
if let CurrentScreen::Exit = app.screen {
|
||||
let exitPopup = Block::default()
|
||||
.title("Save and Exit?")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().bg(Color::DarkGray));
|
||||
|
||||
let exitText = Text::styled(
|
||||
"Save the current config and exit? (enter/esc)",
|
||||
Style::default().fg(Color::Red),
|
||||
);
|
||||
let exitPara = Paragraph::new(exitText)
|
||||
.block(exitPopup)
|
||||
.wrap(Wrap { trim: false });
|
||||
|
||||
let area = centered_rect(60, 25, frame.area());
|
||||
frame.render_widget(exitPara, area);
|
||||
}
|
||||
|
||||
if let Some(edit) = &app.currentlyEditing {
|
||||
let popup = Block::default()
|
||||
.title("Add an entry")
|
||||
.borders(Borders::NONE)
|
||||
.style(Style::default().bg(Color::DarkGray));
|
||||
let area = centered_rect(60, 25, frame.area());
|
||||
frame.render_widget(popup, area);
|
||||
|
||||
let fields = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.margin(1)
|
||||
.constraints([
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let mut fromIPBlock = Block::default().title("From IP").borders(Borders::ALL);
|
||||
let mut toIPBlock = Block::default().title("To IP").borders(Borders::ALL);
|
||||
let mut fromPortBlock = Block::default().title("From Port").borders(Borders::ALL);
|
||||
let mut toPortBlock = Block::default().title("To Port").borders(Borders::ALL);
|
||||
|
||||
let activeStyle = Style::default().bg(Color::LightYellow).fg(Color::Black);
|
||||
|
||||
match edit {
|
||||
EditingField::FromIP => fromIPBlock = fromIPBlock.style(activeStyle),
|
||||
EditingField::ToIP => toIPBlock = toIPBlock.style(activeStyle),
|
||||
EditingField::FromPort => fromPortBlock = fromPortBlock.style(activeStyle),
|
||||
EditingField::ToPort => toPortBlock = toPortBlock.style(activeStyle),
|
||||
}
|
||||
|
||||
let fromIPPara = Paragraph::new(app.fromIP.clone()).block(fromIPBlock);
|
||||
let toIPPara = Paragraph::new(app.toIP.clone()).block(toIPBlock);
|
||||
let fromPortPara = Paragraph::new(app.fromPort.clone()).block(fromPortBlock);
|
||||
let toPortPara = Paragraph::new(app.toPort.clone()).block(toPortBlock);
|
||||
|
||||
frame.render_widget(fromIPPara, fields[0]);
|
||||
frame.render_widget(fromPortPara, fields[1]);
|
||||
frame.render_widget(toIPPara, fields[2]);
|
||||
frame.render_widget(toPortPara, fields[3]);
|
||||
};
|
||||
|
||||
let screenHelp = vec![
|
||||
match app.screen {
|
||||
CurrentScreen::Main => Span::styled("Main Screen", Style::default().fg(Color::Green)),
|
||||
CurrentScreen::Add => Span::styled("Add entry", Style::default().fg(Color::Yellow)),
|
||||
CurrentScreen::Exit => {
|
||||
Span::styled("Exit Screen", Style::default().fg(Color::LightRed))
|
||||
}
|
||||
CurrentScreen::Settings => Span::styled("Settings", Style::default().fg(Color::Blue)),
|
||||
}
|
||||
.to_owned(),
|
||||
Span::styled(" | ", Style::default().fg(Color::White)),
|
||||
{
|
||||
if let Some(editing) = &app.currentlyEditing {
|
||||
let curEdit = match editing {
|
||||
EditingField::FromIP => "From IP",
|
||||
EditingField::ToIP => "To IP",
|
||||
EditingField::FromPort => "From Port",
|
||||
EditingField::ToPort => "To Port",
|
||||
_ => "Something ",
|
||||
};
|
||||
Span::styled(
|
||||
format!("Editing: {curEdit}"),
|
||||
Style::default().fg(Color::Green),
|
||||
)
|
||||
} else {
|
||||
Span::styled("Not Editing", Style::default().fg(Color::DarkGray))
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
let helpFooter =
|
||||
Paragraph::new(Line::from(screenHelp)).block(Block::default().borders(Borders::ALL));
|
||||
|
||||
let keybinds = {
|
||||
match app.screen {
|
||||
CurrentScreen::Main => Span::styled(
|
||||
"(a) add entry / (q) save and quit",
|
||||
Style::default().fg(Color::Red),
|
||||
),
|
||||
CurrentScreen::Add => Span::styled(
|
||||
"(l) localhost / (enter/tab) next field / (c) clear / (C) clear all / (esc) main menu",
|
||||
Style::default().fg(Color::Red),
|
||||
),
|
||||
CurrentScreen::Settings => Span::styled("", Style::default().fg(Color::Red)),
|
||||
CurrentScreen::Exit => Span::styled(
|
||||
"(enter) save and exit / (esc) main menu",
|
||||
Style::default().fg(Color::Red),
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
let keyBindFooter =
|
||||
Paragraph::new(Line::from(keybinds)).block(Block::default().borders(Borders::ALL));
|
||||
|
||||
frame.render_widget(helpFooter, footerChunks[0]);
|
||||
frame.render_widget(keyBindFooter, footerChunks[1]);
|
||||
}
|
||||
|
||||
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||
// Cut the given rectangle into three vertical pieces
|
||||
let popup_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
Constraint::Percentage(percent_y),
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
])
|
||||
.split(r);
|
||||
|
||||
// Then cut the middle vertical piece into three width-wise pieces
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
Constraint::Percentage(percent_x),
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
])
|
||||
.split(popup_layout[1])[1] // Return the middle chunk
|
||||
}
|
||||
Reference in New Issue
Block a user