freechat/tui/src/main.rs
shockrah 894a5bae34 - Removing more behavior from the cache for complexity reduciton
+ Cache is no longer contained in app structure
This should let us later throw this into an Arc<Mutex<>> for better
async support.
! Also more cache access safety is being done on the front end because the cahe doesn't guarantee much on access
Perhaps some convenience wrappers would make this look nicer(with inline)

!!! Main needs a lot of flattening
It's technically not much of a problem since most of the code behind 1/2 match's
which really just bloat the hell out of indentation making it look bad when its not _that_ bad.
However it still bugs me so I should probably do something about that(also (inline))
2021-04-14 22:44:57 -07:00

281 lines
11 KiB
Rust

mod util;
mod command;
mod config;
mod api_types;
mod log;
mod cache;
mod net;
#[macro_use] mod common;
use crate::util::event::{Event, Events};
use crate::command::Command;
use crate::cache::Cache;
use std::{env, fs, error::Error, io};
use std::sync::Mutex;
use clap::{App as Clap, Arg, ArgMatches};
use termion::{event::Key, raw::IntoRawMode, screen::AlternateScreen};
use tui::{
backend::TermionBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Span, Spans, Text},
widgets::{Wrap, Block, Borders, Paragraph},
Terminal,
};
use unicode_width::UnicodeWidthStr;
enum InputMode {
Normal,
Editing,
}
/// App holds the state of the application
struct App {
/// Current value of the input box
input: String,
/// Current input mode
input_mode: InputMode,
/// History of recorded messages
messages: Vec<Command>,
}
impl App {
fn new() -> App {
let init_commands = vec![
Command::Text("! /chan | /channel <u64> - Switches text context to that channel".into()),
Command::Text("! /serv | /server <hostname> - Must include port if its not default to 80/443".into()),
Command::Text("! /lh - List hosts in config (-H flag does this on startup)".into()),
Command::Text("! /help - Shows the help menu".into()),
Command::Text("! Commands: available".into()),
];
App {
input: String::new(),
input_mode: InputMode::Normal,
messages: init_commands,
}
}
}
fn get_args() -> ArgMatches<'static> {
Clap::new("Freechat TUI")
.version("v69.420")
.author("Author: shockrah")
.about("Description: A terminal based program for interacting with freechat servers")
.arg(Arg::with_name("config")
.short("c")
.long("config")
.value_name("CONFIG")
.help("Specify path of config to use")
.takes_value(true))
.arg(Arg::with_name("no_border")
.short("n")
.long("no-border")
.takes_value(false)
.help("Removes border from messages box "))
.arg(Arg::with_name("no_login")
.short("l")
.long("no-login")
.help("Don't automatically login on startup")).get_matches()
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
// parameter things first
let args = get_args();
let (config_path, config): (String, config::ConfigFile) = if let Some(path) = args.value_of("config") {
match fs::read_to_string(path) {
Ok(data) => (path.into(), serde_json::from_str(&data).unwrap()),
Err(e) => panic!("Unable to parse config file @{}: {}", path, e)
}
} else {
let home = env::var("HOME").unwrap();
let path = format!("{}/.config/freechat/config.json", home);
match fs::read_to_string(&path) {
Ok(data) => (path.into(), serde_json::from_str(&data).unwrap()),
Err(e) => panic!("Unable to parse config file @{}: {}", path, e)
}
};
let border_opt = args.is_present("no_border");
// Terminal initialization
let stdout = io::stdout().into_raw_mode()?;
let stdout = AlternateScreen::from(stdout);
let backend = TermionBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Setup event handlers
let mut events = Events::new();
// Create default app state
let mut app = App::new();
let cache: Mutex<Cache> = Mutex::new(Cache::from(&config));
loop {
// Draw UI
terminal.draw(|f| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints(
[
Constraint::Length(1), // Info line
Constraint::Length(3), // Text box (w/ borders
Constraint::Min(1), // the rest of it
]
.as_ref(),
)
.split(f.size());
let (msg, style) = match app.input_mode {
InputMode::Normal => (
vec![
bold!("I"),
normal!(" for insert mode"),
],
Style::default()
),
InputMode::Editing => (
vec![ bold!("/help for commands") ],
Style::default(),
),
};
let mut text = Text::from(Spans::from(msg));
text.patch_style(style);
let help_message = Paragraph::new(text);
f.render_widget(help_message, chunks[0]);
let input = Paragraph::new(app.input.as_ref())
.style(match app.input_mode {
InputMode::Normal => Style::default(),
InputMode::Editing => Style::default().fg(Color::Yellow),
})
.block(Block::default().borders(Borders::ALL));
f.render_widget(input, chunks[1]);
let mut lines: Vec<Spans> = Vec::new();
let msgs = &app.messages;
for cmd in msgs.iter().rev() {
&lines.push(cmd.styled());
}
let list = if border_opt {
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default()
.title("Messages"))
} else {
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default()
.title("Messages")
.borders(Borders::ALL))
};
f.render_widget(list, chunks[2]);
match app.input_mode {
InputMode::Normal =>
// Hide the cursor. `Frame` does this by default, so we don't need to do anything here
{}
InputMode::Editing => {
// Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering
f.set_cursor(
// Put cursor past the end of the input text
chunks[1].x + app.input.width() as u16 + 1,
// Move one line down, from the border to the input line
chunks[1].y + 1,
)
}
}
})?;
// Handle input
if let Event::Input(input) = events.next()? {
match app.input_mode {
InputMode::Normal => match input {
Key::Char('i') | Key::Char('/') => {
app.input_mode = InputMode::Editing;
// only add the slash on slash command start
if Key::Char('/') == input { app.input.push('/'); }
events.disable_exit_key();
}
Key::Char('q') => {
// save the config as is then quit out
match config.save(config_path) {
Ok(_) => println!(":^)"),
Err(e) => eprintln!("Couldn't save config state: {}", e)
};
break;
}
_ => {}
},
InputMode::Editing => match input {
Key::Char('\n') => {
let raw: String = app.input.drain(..).collect();
let trimmed = raw.trim();
// TODO: flattten me mommy
if trimmed.len() != 0 {
let cmd = Command::from(trimmed);
app.messages.push(match cmd {
// only for networked commands do we need to touch cache
Command::Channel(id) => {
cache.lock().unwrap().switch_channel(id).await
},
Command::Server(url_portion) => {
if let Some((id, secret, url)) = config.find_login_data(&url_portion) {
match net::login(&url, id, &secret).await {
Ok(jwt) => if cache.lock().unwrap().set_jwt(&url, &jwt) {
cache.lock().unwrap().set_active(&url);
Command::Text("[Tell shock to make this ux not suck btw] /lc to fetch/update channels".into())
} else {
Command::Failure("Token could not be set".into())
},
Err(_) => Command::Failure(format!("Unable to login to {}", url))
}
} else {
Command::Failure("No server found".into())
}
},
Command::Message(msg) => cache.lock().unwrap().send_message(&msg).await,
Command::ListHost => cache.lock().unwrap().list_hosts(),
Command::ListChannel => {
let session_params = cache.lock().unwrap().session();
if let Some((url, id, jwt)) = session_params {
match net::list_channels(&url , id, &jwt).await {
Ok(channels) => {
cache.lock().unwrap().add_channels(&url, channels.as_slice());
Command::Text(format!("{:?}", channels))
},
Err(_) => Command::Text("Coulnd't fetch channels".into())
}
} else {
Command::Text("adf".into())
}
},
_ => cmd
});
}
}
Key::Char(c) => {
app.input.push(c);
}
Key::Backspace => {
app.input.pop();
}
Key::Ctrl('c') | Key::Esc => {
app.input_mode = InputMode::Normal;
events.enable_exit_key();
}
_ => {}
},
}
}
}
Ok(())
}