
+ 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))
281 lines
11 KiB
Rust
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(())
|
|
}
|
|
|
|
|